diff --git a/App/backend/src/project-version.ts b/App/backend/src/project-version.ts index a7daef9cc..51785eb69 100644 --- a/App/backend/src/project-version.ts +++ b/App/backend/src/project-version.ts @@ -1,2 +1,2 @@ /** Generated from the root package.json by scripts/sync-project-version.mjs. */ -export const MEMMY_VERSION = "1.0.5"; +export const MEMMY_VERSION = "1.0.6"; diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 60e1f01f0..4721e8da3 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -745,6 +745,8 @@ export const zhCNMessages = { "memory.infrastructure": "基础设施状态", "memory.cli": "memmy-memory CLI", "memory.daemon": "记忆服务", + "memory.openDocs": "查看记忆服务文档", + "memory.learnMore": "了解更多", "memory.cliInstalled": "已安装", "memory.cliNotInstalled": "未安装", "memory.daemonRunning": "运行中", @@ -782,12 +784,11 @@ export const zhCNMessages = { "memory.confirmAndStart": "确认并开始", "memory.startingAgent": "正在启动...", "memory.agentDiscoveryPending": "Memmy Agent 正在自动发现历史目录", - "memory.manualAgentAiHint": "只需填写 Agent 框架名称。确认后 Memmy 会新建会话,自动寻找历史目录、识别格式、安装完整 Skill、导入最近 500 个完整对话轮次,并保存后续免 AI 的自动同步规则。", + "memory.manualAgentAiHint": "填写 Agent 框架名称即可;如果知道历史会话路径,也可以一并提供。确认后 Memmy 会新建会话,验证或寻找历史目录、识别格式、安装完整 Skill、导入最近 500 个完整对话轮次,并保存后续免 AI 的自动同步规则。", "memory.manualNamePlaceholder": "选择或输入 Agent 名称", - "memory.manualPresetPlaceholder": "从常用 Agent 中选择", - "memory.manualCustomName": "或输入其他 Agent", - "memory.manualPathPlaceholder": "例如 ~/.claude 或 /Users/you/.aider", - "memory.manualPathHint": "指定 Agent 的配置或对话历史目录,Memmy 将尝试读取其中的对话记录", + "memory.manualHistoryPathLabel": "历史会话路径(可选)", + "memory.manualPathPlaceholder": "例如 ~/.aider/history 或 /Users/you/.agent/sessions", + "memory.manualPathHint": "填写该 Agent 存储历史会话的文件或目录;留空时由 Memmy 自动寻找。", "memory.manualNameRequired": "请输入 Agent 名称", "memory.manualPathRequired": "请输入 Agent 数据路径", "memory.manualDuplicate": "该 Agent 已存在", @@ -1085,7 +1086,7 @@ export const zhCNMessages = { "memory.worldModel.structuredCognition": "结构化认知", "memory.placeholder.comingSoon": "(即将到来)", "memory.scanHint": "点击“同步新增”按钮后,只会读取上次同步后产生的新对话;还没同步过的 Agent 会先同步一次", - "memory.incrementHint": "需要回扫完整旧历史时,请在 Agent 列表下方的高级操作中手动开启深度扫描", + "memory.incrementHint": "需要回扫完整旧历史时,请在 Agent 列表下方的高级中手动开启深度扫描", "memory.cliDescription": "各 Agent 通过 Hook、插件或 Skill 接入 memmy-memory", "memory.daemonDescription": "Hook、CLI 与插件统一通过记忆服务读写记忆", "memory.unavailable": "记忆服务未连接。真实模式下不会展示假记忆数据,请先启动或配置记忆层。", @@ -1151,7 +1152,7 @@ export const zhCNMessages = { "memory.wipeConfirmTitle": "清空记忆数据库?", "memory.wipeConfirmBody": "将清空本地记忆数据库、导入去重缓存和同步进度记录,不会删除模型、API Key、账号或 ~/.memmy/config.yaml 配置。", "memory.wipeConfirmAction": "清空记忆数据库", - "memory.advancedActions": "高级操作", + "memory.advancedActions": "高级", "memory.deepScanAll": "扫描全部历史", "memory.deepScanDescription": "回扫所选 Agent 的完整历史对话,可能耗时较长并产生较高 token 消耗。", "memory.deepScanOpen": "扫描全部历史...", @@ -2277,6 +2278,8 @@ export const enUSMessages: Record = { "memory.infrastructure": "Infrastructure", "memory.cli": "memmy-memory CLI", "memory.daemon": "Memory service", + "memory.openDocs": "View memory service documentation", + "memory.learnMore": "Learn more", "memory.cliInstalled": "Installed", "memory.cliNotInstalled": "Not installed", "memory.daemonRunning": "Running", @@ -2313,12 +2316,11 @@ export const enUSMessages: Record = { "memory.confirmAndStart": "Confirm and start", "memory.startingAgent": "Starting...", "memory.agentDiscoveryPending": "Memmy Agent is discovering the history location", - "memory.manualAgentAiHint": "Only enter the Agent framework name. Memmy will open a new session, discover its history, identify the format, install the full Skill, import the latest 500 complete turns, and save a reusable rule for later syncs without AI.", + "memory.manualAgentAiHint": "Enter the Agent framework name and, if known, its conversation-history path. Memmy will open a new session, validate or discover the history location, identify the format, install the full Skill, import the latest 500 complete turns, and save a reusable rule for later syncs without AI.", "memory.manualNamePlaceholder": "Choose or enter an Agent name", - "memory.manualPresetPlaceholder": "Choose a common Agent", - "memory.manualCustomName": "Or enter another Agent", - "memory.manualPathPlaceholder": "e.g. ~/.claude or /Users/you/.aider", - "memory.manualPathHint": "Specify the Agent config or conversation history directory; Memmy will try to read conversations from it", + "memory.manualHistoryPathLabel": "Conversation history path (optional)", + "memory.manualPathPlaceholder": "e.g. ~/.aider/history or /Users/you/.agent/sessions", + "memory.manualPathHint": "Enter the file or directory where this Agent stores conversation history, or leave it blank for Memmy to discover.", "memory.manualNameRequired": "Enter an Agent name", "memory.manualPathRequired": "Enter an Agent data path", "memory.manualDuplicate": "This Agent already exists", @@ -2616,7 +2618,7 @@ export const enUSMessages: Record = { "memory.worldModel.structuredCognition": "Structured cognition", "memory.placeholder.comingSoon": "(Coming soon)", "memory.scanHint": "Click \"Sync new\" to read only conversations created since the last sync. Agents that have not synced before will run an initial sync.", - "memory.incrementHint": "To backfill complete older history, open deep scan from Advanced actions below the Agent list", + "memory.incrementHint": "To backfill complete older history, open deep scan from Advanced below the Agent list", "memory.cliDescription": "Agents connect to memmy-memory through Hooks, plugins, or Skills", "memory.daemonDescription": "Hooks, CLI, and plugins read and write memory through the memory service", "memory.unavailable": "Memory service is not connected. Real mode will not show fake memory data; start or configure the memory layer first.", @@ -2683,7 +2685,7 @@ export const enUSMessages: Record = { "memory.wipeConfirmTitle": "Clear memory database?", "memory.wipeConfirmBody": "This clears the local memory database, import dedupe cache, and sync progress records. Model settings, API keys, account data, and ~/.memmy/config.yaml are kept.", "memory.wipeConfirmAction": "Clear memory database", - "memory.advancedActions": "Advanced actions", + "memory.advancedActions": "Advanced", "memory.deepScanAll": "Scan all history", "memory.deepScanDescription": "Backfill complete conversation history for selected Agents. This may take longer and use more tokens.", "memory.deepScanOpen": "Scan all history...", diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index 0120b74b7..13464c2fe 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -2080,6 +2080,7 @@ export function HomePage() { setSlashMenuDismissed(false); setSelectedCommandIndex(0); if ( + !selectedComposerCommand && slashQueryFromInput(value) != null && clients?.memmyAgent && slashCommandsRef.current.length === 0 && diff --git a/App/frontend/desktop/src/pages/memory-page.tsx b/App/frontend/desktop/src/pages/memory-page.tsx index a160cac7e..badb70670 100644 --- a/App/frontend/desktop/src/pages/memory-page.tsx +++ b/App/frontend/desktop/src/pages/memory-page.tsx @@ -108,10 +108,10 @@ export function MemoryPage(props: MemoryPageProps) { const [referenceRequest, setReferenceRequest] = useState<(MemoryReferenceOpenRequest & { page: MemoryReferencePage }) | null>(null); const client = clients?.memoryRuntime ?? null; - function handleSubPageChange(page: MemorySubPageId) { + const handleSubPageChange = useCallback((page: MemorySubPageId) => { setReferenceRequest(null); setActivePage(page); - } + }, []); const handleOpenMemoryReference = useCallback((id, fallbackPage) => { const page = resolveMemoryReferencePage(id, fallbackPage); @@ -135,7 +135,7 @@ export function MemoryPage(props: MemoryPageProps) { const childByPage = useMemo>( () => ({ - overview: , + overview: , memories: ( , sources: }), - [client, dispatch, handleOpenMemoryReference, referenceRequest] + [client, dispatch, handleOpenMemoryReference, handleSubPageChange, referenceRequest] ); useEffect(() => { diff --git a/App/frontend/desktop/src/pages/memory-sources-page.tsx b/App/frontend/desktop/src/pages/memory-sources-page.tsx index a135556d4..1ef6c4344 100644 --- a/App/frontend/desktop/src/pages/memory-sources-page.tsx +++ b/App/frontend/desktop/src/pages/memory-sources-page.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import { Check, ChevronDown, ExternalLink } from "lucide-react"; import { MANAGED_AGENT_DISCOVERY_PENDING_DATA_PATH, type AgentSourceScanMode, @@ -14,10 +15,10 @@ import { import { useApiClients } from "../app/providers.js"; import type { MessageKey } from "../i18n/messages.js"; import { useTranslation } from "../i18n/use-translation.js"; +import { openExternalUrl } from "../utils/open-url.js"; import { Button } from "../components/button.js"; import { Banner } from "../components/banner.js"; import { Modal } from "../components/modal.js"; -import { Select } from "../components/Select.js"; import { AGENT_SOURCE_SCAN_COMPLETION_FEEDBACK_MS, agentActions, @@ -55,6 +56,11 @@ import { type MemoryServiceStatus = "checking" | "ok" | "unavailable"; +const MEMORY_DOCS_URLS = { + cn: "https://memmy.cn/docs/memory/overview/", + intl: "https://memmy.bot/docs/memory/overview/" +} as const; + export interface MemorySourcesContentProps { embedded?: boolean; } @@ -66,6 +72,7 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { const { clients } = useApiClients(); const { t } = useTranslation(); const [manualName, setManualName] = useState(""); + const [manualHistoryPath, setManualHistoryPath] = useState(""); const [manualValidating, setManualValidating] = useState(false); const [manualError, setManualError] = useState(""); const [showWipeConfirm, setShowWipeConfirm] = useState(false); @@ -432,6 +439,7 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { setManualValidating(true); setManualError(""); + const userProvidedDataPath = manualHistoryPath.trim(); void clients.agentSources .addManualSource({ displayName: manualName }) .then((source) => { @@ -440,8 +448,9 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { source ])); setManualName(""); + setManualHistoryPath(""); closeManualSource(); - launchManagedAgentTask(source, "connect"); + launchManagedAgentTask(source, "connect", userProvidedDataPath || undefined); }) .catch((error) => { const message = error instanceof Error ? error.message : String(error); @@ -453,11 +462,12 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { function launchManagedAgentTask( source: AgentSourceView, - operation: "connect" | "install" | "uninstall" + operation: "connect" | "install" | "uninstall", + userProvidedDataPath?: string ) { writePendingFirstEncounterTaskLaunch( typeof window === "undefined" ? undefined : window.sessionStorage, - buildManagedAgentTaskPrompt(source, operation) + buildManagedAgentTaskPrompt(source, operation, userProvidedDataPath) ); dispatch(agentActions.newChatRequested()); dispatch(appActions.navigate("/main")); @@ -637,12 +647,25 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) { } title={t("memory.daemon")} + description={t("memory.daemonDescription")} + descriptionAccessory={( + + )} status={memoryServiceStatus} okLabel={t("memory.daemonRunning")} errLabel={t("memory.daemonStopped")} checkingLabel={t("common.loading")} value={memoryServiceAddress ?? t("memory.daemonAddressUnavailable")} - description={t("memory.daemonDescription")} actionLabel={t(memoryServiceBusy ? "memory.restartServiceBusy" : "memory.restartService")} actionTone="success" onAction={restartMemoryService} @@ -1018,13 +1041,27 @@ export function MemorySourcesContent(props: MemorySourcesContentProps = {}) {

{t("memory.manualAgentAiHint")}

{ setManualName(value); setManualError(""); }} placeholder={t("memory.manualNamePlaceholder")} - selectPlaceholder={t("memory.manualPresetPlaceholder")} options={MANUAL_AGENT_NAME_PRESETS} /> +
+ + { setManualHistoryPath(event.target.value); setManualError(""); }} + className="w-full px-4 py-2.5 border border-border-stone rounded-input text-sm font-mono bg-background-paper focus:outline-none placeholder:text-text-ink/40" + /> +

{t("memory.manualPathHint")}

+
{manualError && (
@@ -1172,6 +1209,7 @@ function InfrastructureItem(props: { checkingLabel?: string; value: string; description: string; + descriptionAccessory?: ReactNode; actionLabel: string; actionTone?: "sky" | "success" | "muted"; bordered?: boolean; @@ -1198,7 +1236,10 @@ function InfrastructureItem(props: {
{props.value} -

{props.description}

+

+ {props.description} + {props.descriptionAccessory} +

+ {open && ( +
+ {props.options.map((option, index) => { + const selected = option === props.value; + return ( + + ); + })} +
+ )} ); } diff --git a/App/frontend/desktop/src/pages/memory/overview-sub-page.tsx b/App/frontend/desktop/src/pages/memory/overview-sub-page.tsx index 1fcc17c8e..3babcc554 100644 --- a/App/frontend/desktop/src/pages/memory/overview-sub-page.tsx +++ b/App/frontend/desktop/src/pages/memory/overview-sub-page.tsx @@ -15,12 +15,15 @@ import { type RemoteData, toErrorMessage } from "./remote-state.js"; interface OverviewCountCard { id: "memories" | "skills" | "experiences" | "worldModels"; + targetPage: OverviewCountTargetPage; labelKey: MessageKey; value: number; hintKey: MessageKey; icon: ReactNode; } +type OverviewCountTargetPage = "memories" | "policies" | "world-model" | "skills"; + interface DailyActivityCell { date: string; count: number; @@ -56,6 +59,7 @@ interface ActivityGridLayout { export interface OverviewSubPageProps { client: MemoryRuntimeClient | null; + onNavigate: (page: OverviewCountTargetPage) => void; } export function loadOverviewData(client: MemoryRuntimeClient): Promise { @@ -90,10 +94,13 @@ export function OverviewSubPage(props: OverviewSubPageProps) { }; }, [props.client, t]); - return ; + return ; } -export function OverviewSubPageView(props: { state: RemoteData }) { +export function OverviewSubPageView(props: { + state: RemoteData; + onNavigate: (page: OverviewCountTargetPage) => void; +}) { const { t } = useTranslation(); return ( @@ -107,12 +114,15 @@ export function OverviewSubPageView(props: { state: RemoteData} {props.state.status === "error" && } - {props.state.status === "ready" && } + {props.state.status === "ready" && } ); } -function OverviewContent(props: { data: PanelOverviewOutput }) { +function OverviewContent(props: { + data: PanelOverviewOutput; + onNavigate: (page: OverviewCountTargetPage) => void; +}) { const countCards = buildCountCards(props.data); return ( @@ -123,7 +133,7 @@ function OverviewContent(props: { data: PanelOverviewOutput }) { style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 150px), 1fr))" }} > {countCards.map((item) => ( - + ))} @@ -135,18 +145,27 @@ function OverviewContent(props: { data: PanelOverviewOutput }) { function buildCountCards(data: PanelOverviewOutput): OverviewCountCard[] { return [ - { id: "memories", labelKey: "memory.overview.memories", value: data.counts.memories, hintKey: "memory.overview.memoriesHint", icon: }, - { id: "experiences", labelKey: "memory.overview.policies", value: data.counts.experiences, hintKey: "memory.overview.policiesHint", icon: }, - { id: "worldModels", labelKey: "memory.overview.worldModels", value: data.counts.worldModels, hintKey: "memory.overview.worldModelsHint", icon: }, - { id: "skills", labelKey: "memory.overview.skills", value: data.counts.skills, hintKey: "memory.overview.skillsHint", icon: } + { id: "memories", targetPage: "memories", labelKey: "memory.overview.memories", value: data.counts.memories, hintKey: "memory.overview.memoriesHint", icon: }, + { id: "experiences", targetPage: "policies", labelKey: "memory.overview.policies", value: data.counts.experiences, hintKey: "memory.overview.policiesHint", icon: }, + { id: "worldModels", targetPage: "world-model", labelKey: "memory.overview.worldModels", value: data.counts.worldModels, hintKey: "memory.overview.worldModelsHint", icon: }, + { id: "skills", targetPage: "skills", labelKey: "memory.overview.skills", value: data.counts.skills, hintKey: "memory.overview.skillsHint", icon: } ]; } -function CountCard(props: { item: OverviewCountCard }) { +function CountCard(props: { + item: OverviewCountCard; + onNavigate: (page: OverviewCountTargetPage) => void; +}) { const { t } = useTranslation(); return ( -
+
+ ); } diff --git a/App/frontend/desktop/src/pages/memory/tests/manual-agent-name-field.interaction.test.tsx b/App/frontend/desktop/src/pages/memory/tests/manual-agent-name-field.interaction.test.tsx new file mode 100644 index 000000000..9dd2d157a --- /dev/null +++ b/App/frontend/desktop/src/pages/memory/tests/manual-agent-name-field.interaction.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ManualAgentNameField } from "../../memory-sources-page.js"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("ManualAgentNameField", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + }); + + it("uses one editable combobox for preset selection and custom input", () => { + const onChange = vi.fn(); + act(() => root.render( + + )); + + const input = container.querySelector('input[role="combobox"]')!; + expect(container.querySelectorAll("input")).toHaveLength(1); + + act(() => container.querySelector(".manual-agent-combobox__toggle")?.click()); + expect(container.querySelectorAll('[role="option"]')).toHaveLength(2); + + act(() => container.querySelectorAll('[role="option"]')[1]?.click()); + expect(onChange).toHaveBeenCalledWith("zcode"); + expect(container.querySelector('[role="listbox"]')).toBeNull(); + + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, "aider"); + input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: "aider" })); + }); + expect(onChange).toHaveBeenCalledWith("aider"); + expect(container.querySelector('[role="listbox"]')).toBeNull(); + }); +}); diff --git a/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.interaction.test.tsx b/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.interaction.test.tsx new file mode 100644 index 000000000..b713dc373 --- /dev/null +++ b/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.interaction.test.tsx @@ -0,0 +1,44 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "../../../i18n/i18n-provider.js"; +import { OverviewSubPageView } from "../overview-sub-page.js"; +import { panelOverviewFixture } from "./fixtures.js"; + +describe("OverviewSubPage interaction", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it.each(["memories", "policies", "world-model", "skills"] as const)( + "点击数量卡片后跳转到 %s 页面", + (targetPage) => { + const onNavigate = vi.fn(); + act(() => { + root.render( + + + + ); + }); + + act(() => container.querySelector(`[data-overview-target="${targetPage}"]`)?.click()); + + expect(onNavigate).toHaveBeenCalledWith(targetPage); + } + ); +}); diff --git a/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.test.tsx index 6c956aa81..4839a5449 100644 --- a/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.test.tsx @@ -106,7 +106,7 @@ describe("OverviewSubPage", () => { function renderOverview(data = panelOverviewFixture): string { return renderToString( - + ); } diff --git a/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx index 2221f8928..47fde94b8 100644 --- a/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx @@ -20,6 +20,7 @@ import { resolveAgentSourceScanButtonState, resolveAgentSourceConnectionAction, resolveManagedAgentSourceSyncButtonState, + resolveMemoryDocsUrl, resolveAgentSourceStatusLabelKey, resolveScanContinueSourceId, MANUAL_AGENT_NAME_PRESETS, @@ -96,6 +97,10 @@ describe("SourcesSubPage", () => { expect(html).toContain("发现新 Agent 时自动接入"); expect(html).toContain("自动安装接入组件;关闭后只出现在下方列表,由你手动接入"); expect(html).toContain("~/.local/bin/memmy-memory"); + expect(html).toContain('aria-label="查看记忆服务文档"'); + expect(html).toContain(">了解更多"); + expect(html).toContain("lucide-external-link"); + expect(html).not.toContain('data-icon="codex-help-circle"'); expect(html).not.toContain("或安装原生插件接入记忆"); expect(html).toContain("memory-panel__header memory-panel__header--single-line"); expect(html).toContain("memory-panel__title"); @@ -104,7 +109,8 @@ describe("SourcesSubPage", () => { expect(html).toContain("同步新增"); expect(html).toContain("点击“同步新增”按钮后,只会读取上次同步后产生的新对话"); expect(html).not.toContain("上次扫描水位"); - expect(html).toContain("高级操作"); + expect(html).toContain("高级"); + expect(html).not.toContain("高级操作"); expect(html).not.toContain("添加其他 Agent"); expect(html).toContain("本地数据存储位置"); expect(html).toContain("清除所有本地数据"); @@ -128,8 +134,10 @@ describe("SourcesSubPage", () => { expect(source).toContain('variant="soft"'); expect(source).toContain("manual-source-modal__footer"); expect(source).toContain("options={MANUAL_AGENT_NAME_PRESETS}"); - expect(source).toContain('import { Select } from "../components/Select.js";'); - expect(source).toContain('className="select-control--subtle"'); + expect(source).toContain('role="combobox"'); + expect(source).toContain('aria-autocomplete="list"'); + expect(source).toContain("manual-agent-combobox__menu"); + expect(source).not.toContain('import { Select } from "../components/Select.js";'); expect(source).not.toContain(" { expect(enUSMessages["memory.addOtherAgentDescription"]).toContain("Choose or enter"); }); + it("手动添加 Agent 可以提供可选的历史会话路径", () => { + const source = readFileSync(resolve(__dirname, "..", "..", "memory-sources-page.tsx"), "utf8"); + + expect(zhCNMessages["memory.manualHistoryPathLabel"]).toBe("历史会话路径(可选)"); + expect(enUSMessages["memory.manualHistoryPathLabel"]).toBe("Conversation history path (optional)"); + expect(source).toContain('id="manual-agent-history-path"'); + expect(source).toContain("value={manualHistoryPath}"); + expect(source).toContain('launchManagedAgentTask(source, "connect", userProvidedDataPath || undefined)'); + }); + it("新增 Agent 立即写入页面状态,并在重新进入页面时从后端刷新", () => { const source = readFileSync(resolve(__dirname, "..", "..", "memory-sources-page.tsx"), "utf8"); @@ -165,6 +183,20 @@ describe("SourcesSubPage", () => { expect(prompt).not.toContain("sync_boundary_at"); }); + it("把用户填写的历史会话路径作为候选路径交给 Memmy 验证", () => { + const prompt = buildManagedAgentTaskPrompt({ + sourceId: "manual-1", + displayName: "Aider", + dataPath: MANAGED_AGENT_DISCOVERY_PENDING_DATA_PATH + }, "connect", " ~/.aider/history "); + + expect(prompt).toContain('"data_path": "~/.aider/history"'); + expect(prompt).toContain("explicitly supplied by the user in the GUI"); + expect(prompt).toContain("inspect this scoped candidate first, and verify it before use"); + expect(prompt).toContain("ask for a corrected path instead of silently replacing it"); + expect(prompt).not.toMatch(/[\u3400-\u9fff]/u); + }); + it("未知 Agent 后续同步直接调用后端配方,不再启动 Agent 会话", () => { const source = readFileSync(resolve(__dirname, "..", "..", "memory-sources-page.tsx"), "utf8"); @@ -180,6 +212,8 @@ describe("SourcesSubPage", () => { "memory.addTitle", "memory.confirmAndStart", "memory.manualAgentAiHint", + "memory.manualHistoryPathLabel", + "memory.manualPathHint", "memory.deleteAgent" ] as const; @@ -237,6 +271,20 @@ describe("SourcesSubPage", () => { expect(zhCNMessages).not.toHaveProperty("memory.daemonAddress"); }); + it("记忆服务帮助入口按应用版本打开对应官网文档", () => { + expect(resolveMemoryDocsUrl("intl")).toBe("https://memmy.bot/docs/memory/overview/"); + expect(resolveMemoryDocsUrl("INTL")).toBe("https://memmy.bot/docs/memory/overview/"); + expect(resolveMemoryDocsUrl("cn")).toBe("https://memmy.cn/docs/memory/overview/"); + expect(resolveMemoryDocsUrl(undefined)).toBe("https://memmy.cn/docs/memory/overview/"); + + const source = readFileSync(resolve(__dirname, "..", "..", "memory-sources-page.tsx"), "utf8"); + expect(source).toContain("openExternalUrl(resolveMemoryDocsUrl())"); + expect(zhCNMessages["memory.openDocs"]).toBe("查看记忆服务文档"); + expect(enUSMessages["memory.openDocs"]).toBe("View memory service documentation"); + expect(zhCNMessages["memory.learnMore"]).toBe("了解更多"); + expect(enUSMessages["memory.learnMore"]).toBe("Learn more"); + }); + it("接入源不可用时展示用户文案而不是 HTTP 调试信息", () => { const message = formatAgentSourceActionError( new ApiRequestError("Request /api/agent-sources/opencode/skill failed with status 500", 409, "agent_source_unavailable", "req-1"), diff --git a/App/frontend/desktop/src/pages/memory/tests/sub-page-cache-hydration.test.tsx b/App/frontend/desktop/src/pages/memory/tests/sub-page-cache-hydration.test.tsx index e24ed66d1..91ff3c690 100644 --- a/App/frontend/desktop/src/pages/memory/tests/sub-page-cache-hydration.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/sub-page-cache-hydration.test.tsx @@ -18,7 +18,7 @@ describe("memory sub page cache hydration", () => { }); it.each([ - ["overview", () => ], + ["overview", () => undefined} />], ["memories", () => ], ["tasks", () => ], ["policies", () => undefined} />], diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index 5c970da3e..aa515a93b 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -2176,6 +2176,122 @@ body:has(.memory-drawer) .window-drag-region { transition: opacity 120ms ease; } +.agent-queue-item + .agent-queue-item { + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 22%, transparent); +} + +.agent-queue-item__icon { + flex: 0 0 auto; + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); +} + +.agent-queue-item__source { + display: inline-flex; + width: 18px; + height: 18px; + flex: 0 0 18px; + align-items: center; + justify-content: center; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); +} + +.agent-queue-item__source-mark { + display: block; + width: 16px; + height: 16px; + max-width: 16px; + max-height: 16px; + flex: 0 0 16px; + object-fit: contain; +} + +.agent-queue-item__source-image { + border-radius: 3px; +} + +.agent-queue-item__text { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + font-size: 13px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-queue-item__remove, +.agent-queue-item__steer { + display: inline-flex; + height: 28px; + align-items: center; + justify-content: center; + border-radius: 8px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + cursor: pointer; + transition: background 140ms ease, color 140ms ease, opacity 140ms ease; +} + +.agent-queue-item__remove { + width: 28px; + flex: 0 0 28px; +} + +.agent-queue-item__steer { + flex: 0 0 auto; + gap: 4px; + padding: 0 8px; + color: var(--color-action-sky); + font-size: 12px; + font-weight: 500; +} + +.agent-queue-item__steer:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); +} + +.agent-queue-item__remove:hover:not(:disabled) { + background: var(--color-canvas-oat); + color: var(--color-status-error); +} + +.agent-queue-item__remove:focus-visible, +.agent-queue-item__steer:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 32%, transparent); +} + +.agent-queue-item__remove:disabled, +.agent-queue-item__steer:disabled { + cursor: not-allowed; + opacity: 0.38; +} + +.agent-chat-bubble-frame { + display: flex; + flex-direction: column; + gap: 4px; +} + +.agent-chat-bubble-frame--assistant { + align-items: flex-start; +} + +.agent-chat-bubble-frame--user { + align-items: flex-end; +} + +.agent-message-copy-cluster { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 24px; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; +} + .agent-message-copy-button { display: inline-flex; width: 24px; @@ -2748,6 +2864,10 @@ button { line-height: 24px; } +.agent-composer-shell textarea.agent-composer-input--command-selected { + padding-left: 86px; +} + .agent-attachment-card { display: inline-flex; width: 220px; @@ -7091,101 +7211,558 @@ input[type="checkbox"].check-sky:checked::after { color: white; } -.select-control--compact { - width: 150px; - flex-shrink: 0; -} - -.select-control--compact .select-control__button { - min-height: 34px; - font-size: 12px; - padding: 8px 40px 8px 12px; +.model-assignment-label-row { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 14px; + line-height: 14px; } -.select-control--compact .select-control__icon { - right: 13px; +.model-assignment-label { + margin: 0; + color: color-mix(in srgb, var(--color-text-ink) 80%, transparent); + font-size: 14px; + line-height: 14px; } -.form-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; - margin: 16px 0; +.model-assignment-tip-icon { + display: inline-flex; + width: 14px; + height: 14px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); + cursor: help; + line-height: 0; } -.provider-pills, -.community-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; +.model-assignment-tip-icon svg { + display: block; + width: 12px; + height: 12px; } -.community-row { - justify-content: center; - padding: 14px 0 0; - color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); - font-size: 12px; +.model-assignment-tip-icon:hover, +.model-assignment-tip-icon:focus-visible { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + color: color-mix(in srgb, var(--color-action-sky) 72%, var(--color-text-ink)); + outline: none; } -.provider-pills span, -.provider-pills button { - border: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); - border-radius: var(--radius-btn); - padding: 8px 12px; - background: var(--color-background-paper); - color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); - box-shadow: var(--shadow-soft); +.model-assignment-select .select-control__button { + border-color: transparent; + background: color-mix(in srgb, var(--color-canvas-oat) 52%, transparent); } -.provider-pills button { - cursor: pointer; +.model-assignment-select .select-control__button:hover, +.model-assignment-select .select-control__button[aria-expanded="true"] { + border-color: color-mix(in srgb, var(--color-action-sky) 18%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 75%, transparent); } -.provider-pills button:hover { - color: var(--color-action-sky); - transform: translateY(-1px); +.model-assignment-select__menu { + right: 0; + left: auto; + width: max-content; + min-width: 230px; + max-width: min(320px, calc(100vw - 48px)); } -.chat-layout { - display: grid; - grid-template-columns: 260px minmax(0, 1fr); - gap: 0; - height: 100vh; - min-height: 0; - background: color-mix(in srgb, var(--color-canvas-oat) 40%, transparent); +.provider-model-list { + max-width: 100%; + max-height: 220px; + overflow-x: hidden; + overflow-y: auto; } -.session-list, -.chat-panel { +.modal.model-connection-modal { display: flex; - min-width: 0; + width: min(600px, calc(100vw - 64px)); + max-height: calc(100vh - 48px); flex-direction: column; - gap: 12px; } -.session-list { - padding: 18px; - border-right: 1px solid color-mix(in srgb, var(--color-border-stone) 30%, transparent); - background: color-mix(in srgb, var(--color-background-paper) 54%, transparent); +@media (min-width: 720px) { + .modal-backdrop.model-connection-modal__backdrop { + padding-left: calc(var(--codex-sidebar-width) + 24px); + } + + .modal.model-connection-modal { + width: min(600px, calc(100vw - var(--codex-sidebar-width) - 64px)); + } } -.session-item { - border-radius: 12px; - padding: 10px 12px; - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); - overflow-wrap: anywhere; - font-size: 12px; +.model-connection-modal__body { + min-height: 0; + overflow-y: auto; } -.session-item.active { - background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); - color: var(--color-action-sky); - font-weight: 750; +.model-connection-modal .modal-header { + position: relative; + align-items: flex-start; + padding-right: 52px; } -.chat-panel { +.model-connection-modal .modal-header > button { + display: inline-flex; + position: absolute; + top: 12px; + right: 12px; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + padding: 0; +} + +.model-connection-modal__footer { + justify-content: stretch; +} + +.model-connection-modal__footer-actions { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.model-connection-modal__footer-primary { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.model-editor-fields { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(140px, 1fr); + gap: var(--space-2); +} + +.model-editor-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); +} + +.model-connection-select__label, +.model-capability-select__label, +.model-editor-fields label { + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); + font-size: 12px; + font-weight: 400; + line-height: 1.25; +} + +.model-connection-modal .auth-code-form-input, +.model-connection-select .select-control__button { + min-height: 46px; + border-color: color-mix(in srgb, var(--color-border-stone) 70%, white); + background: color-mix(in srgb, var(--color-canvas-oat) 30%, transparent); + font-size: 14px; + font-weight: 400; + line-height: 20px; +} + +.model-connection-select .select-control__button { + padding: 12px 42px 12px 20px; +} + +/* Nested add/edit model row: match list density, not full form controls. */ +.model-connection-modal .model-editor-fields .auth-code-form-input, +.model-capability-select .select-control__button { + min-height: 36px; + height: 36px; + border-color: color-mix(in srgb, var(--color-border-stone) 70%, white); + background: color-mix(in srgb, var(--color-canvas-oat) 30%, transparent); + font-size: 12px; + font-weight: 400; + line-height: 16px; +} + +.model-connection-modal .model-editor-fields .auth-code-form-input { + padding: 6px 12px; +} + +.model-capability-select .select-control__button { + padding: 6px 34px 6px 12px; +} + +.model-capability-select .select-control__icon { + right: 10px; + width: 14px; + height: 14px; +} + +.model-capability-select__menu { + top: auto; + right: 0; + bottom: calc(100% + 6px); + left: auto; + width: max-content; + min-width: 100%; + max-width: min(300px, calc(100vw - 48px)); +} + +.model-capability-select__menu .select-control__option { + min-height: 30px; + padding: 6px 8px; + font-size: 12px; +} + +.model-capability-picker { + position: relative; + min-width: 0; +} + +.model-capability-picker__trigger { + display: flex; + width: 100%; + height: 36px; + min-width: 0; + cursor: pointer; + list-style: none; + align-items: center; + justify-content: space-between; + gap: 8px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, white); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-canvas-oat) 30%, transparent); + padding: 6px 10px 6px 12px; + color: var(--color-text-ink); + font-size: 12px; + line-height: 16px; +} + +.model-capability-picker__trigger::-webkit-details-marker { + display: none; +} + +.model-capability-picker__value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.model-capability-picker__trigger svg { + flex: 0 0 auto; + transition: transform 160ms ease; +} + +.model-capability-picker[open] .model-capability-picker__trigger svg { + transform: rotate(180deg); +} + +.model-capability-picker__menu { + position: absolute; + right: 0; + bottom: calc(100% + 6px); + z-index: 40; + width: 220px; + max-width: min(280px, calc(100vw - 48px)); + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 72%, transparent); + border-radius: var(--radius-lg); + background: var(--color-background-paper); + box-shadow: 0 12px 30px rgb(47 42 35 / 14%); + padding: 6px; +} + +.model-capability-picker__text-group { + display: grid; + gap: 2px; + border: 0; + margin: 0; + padding: 0 0 6px; +} + +.model-capability-picker__text-group legend { + width: 100%; + padding: 5px 8px 4px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 10px; + line-height: 14px; +} + +.model-capability-picker__option, +.model-capability-picker__type { + display: flex; + min-height: 30px; + cursor: pointer; + align-items: center; + gap: 8px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + padding: 6px 8px; + color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); + font-size: 12px; + line-height: 16px; + text-align: left; +} + +.model-capability-picker__option:hover, +.model-capability-picker__type:hover { + background: color-mix(in srgb, var(--color-action-sky) 8%, transparent); +} + +.model-capability-picker__option input { + width: 14px; + height: 14px; + accent-color: var(--color-action-sky); +} + +.model-capability-picker__single-types { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 3px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); + padding-top: 6px; +} + +.model-capability-picker__type { + min-width: 0; + justify-content: center; + padding-inline: 5px; + white-space: nowrap; +} + +.model-capability-picker__type--selected { + background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); + color: var(--color-action-sky); +} + +.task-model-selection-summary { + max-width: 360px; + margin: 4px 0 0; + overflow: hidden; + color: color-mix(in srgb, var(--color-action-sky) 78%, var(--color-text-ink)); + font-size: 11px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-model-picker { + margin: 6px 14px 12px; + padding: 10px; + border-radius: var(--radius-card); + background: color-mix(in srgb, var(--color-canvas-oat) 55%, transparent); +} + +.task-model-picker__group + .task-model-picker__group { + margin-top: 10px; +} + +.task-model-picker__group-title { + display: flex; + align-items: center; + gap: 6px; + padding: 0 4px 4px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + font-size: 10px; + font-weight: 600; +} + +.task-model-picker__option { + display: flex; + width: 100%; + min-height: 32px; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 0; + border-radius: var(--radius-input); + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 70%, transparent); + cursor: pointer; + font-family: inherit; + font-size: 12px; +} + +.task-model-picker__choice { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 8px; + text-align: left; +} + +.task-model-picker__default-button { + margin-left: 8px; + display: inline-flex; + min-height: 24px; + align-items: center; + justify-content: center; + padding: 3px 9px; + border-width: 1px; + border-style: solid; + border-color: color-mix(in srgb, var(--color-action-sky) 42%, transparent); + border-radius: var(--radius-pill); + background: var(--color-background-paper); + color: var(--color-action-sky-hover); + cursor: pointer; + font-family: inherit; + font-size: 10px; + font-weight: 600; + line-height: 1; + transition: border-color var(--duration-fast), background var(--duration-fast), color var(--duration-fast); +} + +.task-model-picker__default-button:hover { + border-color: var(--color-action-sky); + background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); +} + +.task-model-picker__default-button[aria-pressed="true"] { + border-color: var(--color-action-sky); + background: var(--color-action-sky); + color: white; + font-weight: 700; +} + +.task-model-picker__default-button[aria-pressed="true"]:hover { + border-color: var(--color-action-sky-hover); + background: var(--color-action-sky-hover); +} + +.task-model-picker__default-button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-action-sky) 55%, transparent); + outline-offset: 2px; +} + +.task-model-picker__option:hover:not(:disabled) { + background: var(--color-background-paper); +} + +.task-model-picker__option:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.task-model-picker__checkbox { + display: inline-flex; + width: 16px; + height: 16px; + flex: 0 0 16px; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, transparent); + border-radius: 4px; + background: var(--color-background-paper); +} + +.task-model-picker__checkbox.is-selected { + border-color: var(--color-action-sky); + background: var(--color-action-sky); + color: white; +} + +.select-control--compact { + width: 150px; + flex-shrink: 0; +} + +.select-control--compact .select-control__button { + min-height: 34px; + font-size: 12px; + padding: 8px 40px 8px 12px; +} + +.select-control--compact .select-control__icon { + right: 13px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin: 16px 0; +} + +.provider-pills, +.community-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.community-row { + justify-content: center; + padding: 14px 0 0; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + font-size: 12px; +} + +.provider-pills span, +.provider-pills button { + border: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + border-radius: var(--radius-btn); + padding: 8px 12px; + background: var(--color-background-paper); + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); + box-shadow: var(--shadow-soft); +} + +.provider-pills button { + cursor: pointer; +} + +.provider-pills button:hover { + color: var(--color-action-sky); + transform: translateY(-1px); +} + +.chat-layout { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: 0; + height: 100vh; + min-height: 0; + background: color-mix(in srgb, var(--color-canvas-oat) 40%, transparent); +} + +.session-list, +.chat-panel { + display: flex; + min-width: 0; + flex-direction: column; + gap: 12px; +} + +.session-list { + padding: 18px; + border-right: 1px solid color-mix(in srgb, var(--color-border-stone) 30%, transparent); + background: color-mix(in srgb, var(--color-background-paper) 54%, transparent); +} + +.session-item { + border-radius: 12px; + padding: 10px 12px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); + overflow-wrap: anywhere; + font-size: 12px; +} + +.session-item.active { + background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); + color: var(--color-action-sky); + font-weight: 750; +} + +.chat-panel { min-height: 0; border: 0; border-radius: 0; @@ -7505,6 +8082,38 @@ input[type="checkbox"].check-sky:checked::after { height: 100%; } +.im-channel-title-icon { + --im-channel-title-icon-size: 18px; + display: inline-flex; + width: var(--im-channel-title-icon-size); + height: var(--im-channel-title-icon-size); + flex: 0 0 var(--im-channel-title-icon-size); + align-items: center; + justify-content: center; +} + +.im-channel-title-icon :is(.integration-logo-slot, .channel-integration-icon) { + width: 100%; + height: 100%; +} + +.im-channel-title-icon :is(.integration-logo-badge, .channel-integration-icon-badge, .generic-integration-icon-badge) { + width: 100%; + height: 100%; + border-radius: 5px; + box-shadow: none; + outline: none; +} + +.im-channel-title-icon .integration-logo-image { + padding: 0; +} + +.im-channel-title-icon .channel-integration-icon-badge svg { + width: 100%; + height: 100%; +} + .generic-integration-icon-badge svg { width: 18px; height: 18px; @@ -7966,6 +8575,96 @@ input[type="checkbox"].check-sky:checked::after { min-height: 40px; } +.manual-agent-combobox { + position: relative; +} + +.manual-agent-combobox__control { + position: relative; +} + +.manual-agent-combobox__input { + width: 100%; + min-height: 40px; + border: 1px solid var(--color-border-stone); + border-radius: var(--radius-input); + outline: none; + padding: 10px 42px 10px 14px; + background: var(--color-background-paper); + color: var(--color-text-ink); + cursor: text; + font-family: inherit; + font-size: 14px; + line-height: 1.25; +} + +.manual-agent-combobox__input::placeholder { + color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); +} + +.manual-agent-combobox__toggle { + position: absolute; + top: 0; + right: 0; + display: grid; + width: 42px; + height: 100%; + place-items: center; + border: 0; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + cursor: pointer; +} + +.manual-agent-combobox__toggle svg { + transition: transform var(--duration-fast); +} + +.manual-agent-combobox__toggle[aria-expanded="true"] svg { + transform: rotate(180deg); +} + +.manual-agent-combobox__menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + left: 0; + z-index: 80; + overflow: auto; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 72%, transparent); + border-radius: var(--radius-input); + padding: 6px; + background: var(--color-background-paper); + box-shadow: var(--shadow-popover); +} + +.manual-agent-combobox__option { + display: flex; + width: 100%; + min-height: 34px; + align-items: center; + justify-content: space-between; + border: 0; + border-radius: 7px; + padding: 8px 9px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); + cursor: pointer; + font-family: inherit; + font-size: 13px; + text-align: left; +} + +.manual-agent-combobox__option--active { + background: color-mix(in srgb, var(--color-canvas-oat) 72%, transparent); + color: var(--color-text-ink); +} + +.manual-agent-combobox__option svg { + flex: 0 0 auto; + color: var(--color-action-sky); +} + .confirm-dialog { display: flex; max-height: calc(100dvh - 48px); diff --git a/App/shell/desktop/src/main/runtime-services.ts b/App/shell/desktop/src/main/runtime-services.ts index d8e13e872..0dd3043fb 100644 --- a/App/shell/desktop/src/main/runtime-services.ts +++ b/App/shell/desktop/src/main/runtime-services.ts @@ -17,6 +17,7 @@ const STARTUP_TIMEOUT_MS = 30_000; const POLL_INTERVAL_MS = 250; const HTTP_TIMEOUT_MS = 1_000; const STOP_MANAGED_CHILD_GRACE_MS = 1_000; +const EXISTING_MEMORY_STARTUP_GRACE_MS = 10_000; type RuntimeEnv = Record; type ConfigRecord = Record; @@ -131,6 +132,13 @@ interface DesktopManagedRestartNotice { type HttpProbeResult = "ready" | "unreachable" | "unexpected"; +export interface MemoryServerLock { + pid: number; + host?: string; + port?: number; + sqlitePath?: string; +} + export async function startPackagedRuntimeServices( options: StartPackagedRuntimeServicesOptions ): Promise { @@ -157,6 +165,7 @@ export async function startPackagedRuntimeServices( browserPreparationAttemptId ); let memoryRestart: Promise | null = null; + let memoryStartup: Promise | null = null; let browserPreparation: PackagedBrowserPreparation | null = null; let closing = false; @@ -172,7 +181,10 @@ export async function startPackagedRuntimeServices( spawn, browserPreparationAttemptId ); - await ensureMemoryService(entries, runtimeConfig, children, options); + memoryStartup = ensureMemoryService(entries, runtimeConfig, children, options) + .catch((error) => { + console.warn(`Memory service unavailable during desktop startup: ${errorMessage(error)}`); + }); await gatewaySupervisor.ensureStarted(); return { @@ -189,6 +201,10 @@ export async function startPackagedRuntimeServices( workspace: runtimeConfig.agentWorkspace }, async restartMemory() { + if (closing) { + throw new Error("Memmy is shutting down"); + } + await memoryStartup; if (closing) { throw new Error("Memmy is shutting down"); } @@ -203,6 +219,7 @@ export async function startPackagedRuntimeServices( async close() { closing = true; browserPreparation?.stop(); + await memoryStartup; await memoryRestart?.catch(() => undefined); await gatewaySupervisor.close(); await stopManagedChildren(children); @@ -215,6 +232,7 @@ export async function startPackagedRuntimeServices( }; } catch (error) { browserPreparation?.stop(); + await memoryStartup; await gatewaySupervisor.close(); await stopManagedChildren(children); throw error; @@ -655,7 +673,7 @@ async function copyDirectoryContents(sourceDirectory: string, targetDirectory: s } } -async function ensureMemoryService( +export async function ensureMemoryService( entries: RuntimeEntryPaths, runtimeConfig: PackagedRuntimeConfig, children: ManagedChild[], @@ -671,6 +689,12 @@ async function ensureMemoryService( throw new Error(`Memory endpoint is occupied by an unexpected service: ${healthUrl}`); } + const existingLock = readLiveMemoryServerLock(runtimeConfig.memoryDatabasePath); + if (existingLock) { + await waitForExistingMemoryService(healthUrl, healthHeaders, existingLock); + return; + } + const memoryChild = spawnNodeService("memory", entries.memoryEntry, [ "--config", runtimeConfig.configPath, @@ -694,7 +718,15 @@ async function ensureMemoryService( logLevel: options.logLevel }); children.push(memoryChild); - await waitForHttpService("memory", healthUrl, memoryChild, healthHeaders); + try { + await waitForHttpService("memory", healthUrl, memoryChild, healthHeaders); + } catch (error) { + const lockOwner = readLiveMemoryServerLock(runtimeConfig.memoryDatabasePath); + if (!lockOwner || lockOwner.pid === memoryChild.process.pid) { + throw error; + } + await waitForExistingMemoryService(healthUrl, healthHeaders, lockOwner); + } } async function restartManagedMemoryService( @@ -718,6 +750,8 @@ async function restartManagedMemoryService( }); } else if (probe === "unexpected") { throw new Error(`Memory endpoint is occupied by an unexpected service: ${healthUrl}`); + } else { + await stopLockedMemoryService(runtimeConfig.memoryDatabasePath, entries.memoryEntry); } } @@ -1138,9 +1172,10 @@ async function waitForHttpServiceStop(url: string, headers: Record = {} + headers: Record = {}, + timeoutMs = STARTUP_TIMEOUT_MS ): Promise { - const deadline = Date.now() + STARTUP_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; let lastProbe: HttpProbeResult = "unreachable"; while (Date.now() < deadline) { lastProbe = await probeHttpService(url, headers); @@ -1152,6 +1187,119 @@ async function waitForHttpServiceReady( throw new Error(`${name} did not restart at ${url} (last probe: ${lastProbe})`); } +export function readLiveMemoryServerLock(databasePath: string): MemoryServerLock | null { + const lockPath = `${resolve(databasePath)}.server.lock`; + try { + const parsed = JSON.parse(readFileSync(lockPath, "utf8")) as Record; + if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) { + return null; + } + if (typeof parsed.sqlitePath === "string" && resolve(parsed.sqlitePath) !== resolve(databasePath)) { + return null; + } + if (!isProcessAlive(parsed.pid)) { + return null; + } + return { + pid: parsed.pid, + ...(typeof parsed.host === "string" ? { host: parsed.host } : {}), + ...(typeof parsed.port === "number" ? { port: parsed.port } : {}), + ...(typeof parsed.sqlitePath === "string" ? { sqlitePath: parsed.sqlitePath } : {}) + }; + } catch { + return null; + } +} + +async function waitForExistingMemoryService( + healthUrl: string, + healthHeaders: Record, + lock: MemoryServerLock +): Promise { + try { + await waitForHttpServiceReady( + "existing memory", + healthUrl, + healthHeaders, + EXISTING_MEMORY_STARTUP_GRACE_MS + ); + } catch (error) { + throw new Error( + `Existing Memory service pid ${lock.pid} did not become ready at ${healthUrl}: ${errorMessage(error)}` + ); + } +} + +async function stopLockedMemoryService(databasePath: string, memoryEntry: string): Promise { + const lock = readLiveMemoryServerLock(databasePath); + if (!lock) return; + if (lock.pid === process.pid) { + throw new Error("Memory server lock unexpectedly belongs to the desktop process"); + } + if (!isPackagedMemoryServiceProcess(lock.pid, memoryEntry)) { + throw new Error(`Refusing to stop unverified process pid ${lock.pid} from the Memory server lock`); + } + + terminateProcessByPid(lock.pid, false); + if (await waitForProcessExit(lock.pid, STOP_MANAGED_CHILD_GRACE_MS)) return; + terminateProcessByPid(lock.pid, true); + if (!(await waitForProcessExit(lock.pid, STOP_MANAGED_CHILD_GRACE_MS))) { + throw new Error(`Memory service pid ${lock.pid} did not exit`); + } +} + +function isPackagedMemoryServiceProcess(pid: number, memoryEntry: string): boolean { + try { + const command = process.platform === "win32" + ? execFileSync("powershell.exe", [ + "-NoProfile", + "-Command", + `(Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\").CommandLine` + ], { encoding: "utf8", windowsHide: true }) + : execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }); + const normalizedCommand = command.replaceAll("\\", "/"); + const normalizedEntry = resolve(memoryEntry).replaceAll("\\", "/"); + return normalizedCommand.includes(normalizedEntry) + || normalizedCommand.includes("/dist/runtime/memory/src/server/index.js"); + } catch { + return false; + } +} + +function terminateProcessByPid(pid: number, force: boolean): void { + try { + if (process.platform === "win32") { + execFileSync("taskkill", [...(force ? ["/F"] : []), "/T", "/PID", String(pid)], { stdio: "ignore" }); + } else { + process.kill(pid, force ? "SIGKILL" : "SIGTERM"); + } + } catch { + // The process may already have exited. + } +} + +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) return true; + await sleep(50); + } + return !isProcessAlive(pid); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return isNodeError(error) && error.code === "EPERM"; + } +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + async function waitForHttpService( name: string, url: string, @@ -1247,7 +1395,7 @@ function terminateProcessTreeSync(child: ChildProcess): void { } } -async function stopManagedChild(child: ManagedChild): Promise { +export async function stopManagedChild(child: ManagedChild): Promise { if (child.exitDescription || child.process.exitCode !== null || child.process.signalCode !== null) { return; } @@ -1265,18 +1413,29 @@ async function stopManagedChild(child: ManagedChild): Promise { // The process may already have exited or we may lack permission; ignore. } } + await waitForManagedChildExit(child, STOP_MANAGED_CHILD_GRACE_MS); return; } child.process.kill(); - await Promise.race([ - new Promise((resolveStop) => child.process.once("exit", () => resolveStop())), - sleep(STOP_MANAGED_CHILD_GRACE_MS).then(() => { - if (!child.exitDescription && child.process.exitCode === null && child.process.signalCode === null) { - child.process.kill("SIGKILL"); - } - }) - ]); + if (await waitForManagedChildExit(child, STOP_MANAGED_CHILD_GRACE_MS)) return; + child.process.kill("SIGKILL"); + await waitForManagedChildExit(child, STOP_MANAGED_CHILD_GRACE_MS); +} + +async function waitForManagedChildExit(child: ManagedChild, timeoutMs: number): Promise { + if (!isManagedChildRunning(child)) return true; + return new Promise((resolveExit) => { + const onExit = () => { + clearTimeout(timer); + resolveExit(true); + }; + const timer = setTimeout(() => { + child.process.off("exit", onExit); + resolveExit(!isManagedChildRunning(child)); + }, timeoutMs); + child.process.once("exit", onExit); + }); } async function readConfig(configPath: string): Promise { diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index a11356e37..cb7c53c6d 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -207,10 +207,15 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain('if [ -L "$RUNTIME_MIGRATIONS_DIR" ]; then'); expect(source).toContain('if [ ! -f "$RUNTIME_MIGRATIONS_DIR/dist/index.js" ]; then'); expect(source).toContain('if [ -e "$MIGRATIONS_STAGING_DIR" ]; then'); - expect(source).toContain('import { runMigrations } from "@memmy/migrations";'); + expect(source).toContain("CURRENT_MIGRATION_STATE_FORMAT_VERSION"); + expect(source).toContain("SUPPORTED_MIGRATION_STATE_FORMAT_VERSIONS"); + expect(source).toMatch( + /import \{[\s\S]*CURRENT_MIGRATION_STATE_FORMAT_VERSION,[\s\S]*SUPPORTED_MIGRATION_STATE_FORMAT_VERSIONS,[\s\S]*runMigrations,[\s\S]*\} from "@memmy\/migrations";/u, + ); expect(source).toContain( 'if (typeof runMigrations !== "function") throw new Error("Migrations runtime export is unavailable")', ); + expect(source).toContain("Migrations runtime state compatibility mismatch"); expect(source).toContain( '$unpacked_runtime/memmy-agent/node_modules/@memmy/migrations/dist/index.js', ); @@ -242,6 +247,17 @@ describe("desktop packaged runtime boundaries", () => { expect(winSource.indexOf('run build --prefix "$MIGRATIONS_DIR"')).toBeLessThan( winSource.indexOf('ci --prefix "$AGENT_DIR"'), ); + expect(winSource).toContain("verify_migration_state_compatibility_module \\"); + expect(winSource).toContain( + '"$unpacked_runtime/memmy-agent/node_modules/@memmy/migrations/dist/state-store.js"', + ); + expect(winSource).toContain("MEMMY_MIGRATION_STATE_MODULE_PATH"); + expect(winSource).toContain('import { pathToFileURL } from "node:url";'); + expect(winSource).toContain("stateStore.validateMigrationState"); + expect(winSource).toContain("Migrations runtime state behavior mismatch"); + expect(winSource.lastIndexOf("verify_packaged_windows_unpacked_artifacts")).toBeGreaterThan( + winSource.lastIndexOf("npx electron-builder"), + ); }); it("materializes private Memory workspace packages in the Windows runtime", () => { @@ -264,6 +280,38 @@ describe("desktop packaged runtime boundaries", () => { expect(source).not.toContain('cp "$MEMORY_DIR/package-lock.json"'); }); + it("installs and verifies better-sqlite3 for both Windows runtimes", () => { + const source = readFileSync(packageWinX64Path, "utf8"); + + expect(source).toContain('install_better_sqlite3_win_x64 "$RUNTIME_DIR/memory"'); + expect(source).toContain('install_better_sqlite3_win_x64 "$RUNTIME_DIR/memmy-agent"'); + expect(source).toContain( + '$RUNTIME_DIR/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node', + ); + expect(source).toContain( + '$unpacked_runtime/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node', + ); + const agentDependenciesIndex = source.indexOf('npm_ci_win_x64 "$RUNTIME_DIR/memmy-agent"'); + const agentInstallIndex = source.indexOf( + 'install_better_sqlite3_win_x64 "$RUNTIME_DIR/memmy-agent"', + agentDependenciesIndex, + ); + const agentVerifyIndex = source.indexOf("verify_windows_agent_native_artifacts", agentInstallIndex); + const agentSmokeIndex = source.indexOf( + 'verify_windows_better_sqlite3_runtime "$RUNTIME_DIR/memmy-agent"', + agentVerifyIndex, + ); + const builderIndex = source.lastIndexOf("npx electron-builder"); + const finalVerifyIndex = source.lastIndexOf("verify_packaged_windows_unpacked_artifacts"); + expect(agentDependenciesIndex).toBeGreaterThanOrEqual(0); + expect(agentInstallIndex).toBeGreaterThan(agentDependenciesIndex); + expect(agentVerifyIndex).toBeGreaterThan(agentInstallIndex); + expect(agentSmokeIndex).toBeGreaterThan(agentVerifyIndex); + expect(builderIndex).toBeGreaterThan(agentSmokeIndex); + expect(finalVerifyIndex).toBeGreaterThan(builderIndex); + expect(source).toContain("verify_packaged_file_matches_runtime"); + }); + it("unpacks the migrations runtime in every desktop package variant", () => { for (const configPath of [ electronBuilderPath, @@ -872,7 +920,7 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).toContain("await services?.close()"); expect(mainSource).toContain("app.quit()"); expect(runtimeServicesSource).toContain("STOP_MANAGED_CHILD_GRACE_MS"); - expect(runtimeServicesSource).toContain("sleep(STOP_MANAGED_CHILD_GRACE_MS)"); + expect(runtimeServicesSource).toContain("waitForManagedChildExit(child, STOP_MANAGED_CHILD_GRACE_MS)"); expect(interfaceSource).toContain("export type DesktopUpdateMode"); expect(interfaceSource).toContain("export interface DesktopUpdateDownloadOptions"); expect(interfaceSource).toContain("minSupportedVersion?: string"); @@ -958,8 +1006,11 @@ describe("desktop packaged runtime boundaries", () => { expect(source).not.toContain("await preparePackagedBrowser(entries, runtimeConfig, options)"); expect(source).toContain('[entries.agentEntry, "internal", "browser-prepare"]'); expect(source.indexOf("browserPreparation = startPackagedBrowserPreparation")).toBeLessThan( - source.indexOf("await ensureMemoryService"), + source.indexOf("memoryStartup = ensureMemoryService"), ); + expect(source).toContain("memoryStartup = ensureMemoryService"); + expect(source).toContain("Memory service unavailable during desktop startup"); + expect(source).toContain("readLiveMemoryServerLock(runtimeConfig.memoryDatabasePath)"); expect(source).toContain("browserPreparation?.stop()"); expect(source).toContain("terminateProcessTreeSync(child)"); expect(source).toContain('detached: process.platform !== "win32"'); diff --git a/App/shell/desktop/tests/runtime-services.test.ts b/App/shell/desktop/tests/runtime-services.test.ts index 0692d017c..0a254b02a 100644 --- a/App/shell/desktop/tests/runtime-services.test.ts +++ b/App/shell/desktop/tests/runtime-services.test.ts @@ -9,13 +9,16 @@ import YAML from "yaml"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AgentGatewaySupervisor, + ensureMemoryService, preparePackagedBrowser, preparePackagedRuntimeConfig, + readLiveMemoryServerLock, resolvePackagedRuntimeMigrationTargets, runPackagedMigrationCommand, restartExternalMemoryService, spawnNodeService, startPackagedBrowserPreparation, + stopManagedChild, syncBundledAgentSkills, type ManagedChild, type PackagedRuntimeConfig, @@ -304,6 +307,87 @@ describe("packaged desktop runtime config", () => { expect(recordValue(recordValue(config, "agents"), "defaults")).not.toHaveProperty("provider"); }); + it("recognizes a live Memory server lock for the configured sqlite database", async () => { + const root = await makeTempRoot(); + const databasePath = join(root, "memory.sqlite"); + await writeFile(`${databasePath}.server.lock`, JSON.stringify({ + pid: process.pid, + host: "127.0.0.1", + port: 18960, + sqlitePath: databasePath + })); + + expect(readLiveMemoryServerLock(databasePath)).toEqual({ + pid: process.pid, + host: "127.0.0.1", + port: 18960, + sqlitePath: databasePath + }); + }); + + it("ignores a Memory server lock that names another sqlite database", async () => { + const root = await makeTempRoot(); + const databasePath = join(root, "memory.sqlite"); + await writeFile(`${databasePath}.server.lock`, JSON.stringify({ + pid: process.pid, + sqlitePath: join(root, "other.sqlite") + })); + + expect(readLiveMemoryServerLock(databasePath)).toBeNull(); + }); + + it("waits for and reuses a live locked Memory service instead of spawning another", async () => { + const root = await makeTempRoot(); + const databasePath = join(root, "memory.sqlite"); + const reservation = createServer(); + await new Promise((resolveListen) => reservation.listen(0, "127.0.0.1", resolveListen)); + const address = reservation.address(); + if (!address || typeof address === "string") throw new Error("expected TCP address"); + const port = address.port; + await new Promise((resolveClose) => reservation.close(() => resolveClose())); + await writeFile(`${databasePath}.server.lock`, JSON.stringify({ + pid: process.pid, + host: "127.0.0.1", + port, + sqlitePath: databasePath + })); + + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: true })); + }); + testServers.push(server); + setTimeout(() => server.listen(port, "127.0.0.1"), 100); + const children: ManagedChild[] = []; + + await ensureMemoryService( + { memoryEntry: join(root, "missing-memory.js"), agentEntry: join(root, "missing-agent.js") }, + { + configPath: join(root, "config.yaml"), + agentWorkspace: join(root, "workspace"), + memoryDatabasePath: databasePath, + memoryBaseUrl: `http://127.0.0.1:${port}`, + memoryToken: "", + memoryListenHost: "127.0.0.1", + memoryListenPort: port, + agentGatewayBaseUrl: "http://127.0.0.1:18980", + agentGatewayHealthHost: "127.0.0.1", + agentGatewayHealthPort: 18970, + agentGatewayBootstrapSecret: "secret" + }, + children, + { + appPath: root, + appDatabaseFile: join(root, "app.sqlite"), + resourcesPath: root, + logDirectory: root, + logLevel: "info" + } + ); + + expect(children).toHaveLength(0); + }); + it("rereads the migrated workspace instead of pinning the pre-migration legacy value", async () => { const memmyHome = await makeTempRoot(); const configPath = join(memmyHome, "config.yaml"); @@ -989,6 +1073,25 @@ describe("spawnNodeService 落盘与 env 注入", () => { expect(await readFile(logFile, "utf8")).toContain("debug"); }); + + it("强杀后等待 Memory 子进程真正退出", async () => { + const root = await makeTempRoot(); + const entry = join(root, "stubborn-memory.js"); + await writeFile(entry, [ + "process.on('SIGTERM', () => {});", + "process.stdout.write('ready\\n');", + "setInterval(() => {}, 1000);" + ].join("\n")); + const managed = spawnNodeService("memory", entry, [], {}, { + logFilePath: join(root, "stubborn-memory.log"), + logLevel: "info" + }); + await new Promise((ready) => managed.process.stdout?.once("data", () => ready())); + + await stopManagedChild(managed); + + expect(managed.exitDescription).toBe("signal SIGKILL"); + }); }); function createSupervisorHarness(overrides: { diff --git a/Memory/src/server/index.ts b/Memory/src/server/index.ts index d7dbfffb4..38cf3954b 100644 --- a/Memory/src/server/index.ts +++ b/Memory/src/server/index.ts @@ -3,7 +3,8 @@ import { mutateRuntimeConfig } from "@memmy/migrations"; import { closeSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { createStorageBackend } from "../storage/backend.js"; +import type { Server } from "node:http"; +import { createStorageBackend, type StorageBackend } from "../storage/backend.js"; import { loadMemmyConfig } from "../config/index.js"; import { createMemoryLogger, memoryErrorFields } from "../logging/logger.js"; import { MemoryService } from "../service/memory-service.js"; @@ -33,9 +34,16 @@ export async function main(argv = process.argv.slice(2)): Promise { const serverLock = config.storage.backend === "openmem-cloud-rest" ? undefined : acquireSqliteServerLock({ sqlitePath, host, port }); + let backend: StorageBackend | undefined; + let server: Server | undefined; + let requestShutdown: (() => void) | undefined; + const shutdownRequested = new Promise((resolveShutdown) => { + requestShutdown = resolveShutdown; + }); + const handleShutdownSignal = () => requestShutdown?.(); try { - const backend = createStorageBackend({ + backend = createStorageBackend({ mode: config.storage.mode, backend: config.storage.backend, sqlitePath, @@ -48,18 +56,18 @@ export async function main(argv = process.argv.slice(2)): Promise { configPath: options.configPath, config }); - const { url } = await listenMemoryHttpServer({ + const listening = await listenMemoryHttpServer({ service, host, port, timeZone: config.timeZone, - onShutdownRequested: () => { - setTimeout(() => process.kill(process.pid, "SIGTERM"), 0); - }, + onShutdownRequested: () => requestShutdown?.(), auth: config.storage.token ? { localServiceToken: config.storage.token } : { allowAnonymous: true } }); + server = listening.server; + const { url } = listening; if (configPath) { await writeCurrentEndpoint(configPath, url); } @@ -69,15 +77,28 @@ export async function main(argv = process.argv.slice(2)): Promise { mode: config.storage.mode, storageBackend: config.storage.backend }); - await new Promise(() => { - // Keep the process alive while the HTTP server owns the service lifecycle. - }); - } catch (error) { + process.once("SIGINT", handleShutdownSignal); + process.once("SIGTERM", handleShutdownSignal); + await shutdownRequested; + } finally { + process.off("SIGINT", handleShutdownSignal); + process.off("SIGTERM", handleShutdownSignal); + if (server) { + await closeHttpServer(server); + } + backend?.close(); serverLock?.release(); - throw error; } } +async function closeHttpServer(server: Server): Promise { + if (!server.listening) return; + await new Promise((resolveClose, rejectClose) => { + server.close((error) => error ? rejectClose(error) : resolveClose()); + server.closeAllConnections(); + }); +} + export interface SqliteServerLock { path: string; release(): void; diff --git a/Memory/src/service/session/session-turn-service.ts b/Memory/src/service/session/session-turn-service.ts index b06a6d22b..5a0850ce3 100644 --- a/Memory/src/service/session/session-turn-service.ts +++ b/Memory/src/service/session/session-turn-service.ts @@ -807,18 +807,19 @@ export class SessionTurnService { const turnId = request.turnId ?? newId("turn"); const intentDecision = classifyIntent(request.query); const endTopicDecision = explicitEndTopicDecision(request.query); - const routeProposal = await this.proposeEpisodeRouteWithLlm( - session, + const latestEpisode = this.deps.repos.runtime.latestEpisodeForSession(session.id); + const routeProposalPromise = this.proposeEpisodeRouteWithLlm( + latestEpisode, request.query, endTopicDecision ); const contextHints = turnStartContextHints(request); - const search = await this.deps.search({ + const searchPromise = this.deps.search({ requestId: request.requestId, adapterId: request.adapterId, namespace: namespaceForSession(session), sessionId: session.id, - episodeId: routeProposal.baseEpisodeId, + episodeId: latestEpisode?.id, turnId, query: buildSearchQuery({ ...request, contextHints }, this.deps.config.domain), layers: endTopicDecision @@ -830,9 +831,10 @@ export class SessionTurnService { retrievalMode: "turn_start", contextHints, injectedContextQuery: request.query, - turnIntentDecision: intentDecision, - routeProposal + turnIntentDecision: intentDecision }); + const [routeProposal, search] = await Promise.all([routeProposalPromise, searchPromise]); + this.persistTurnStartRouteProposal(search.searchEventId, routeProposal); const contextPacketId = turnContextPacketId( session.id, routeProposal.baseEpisodeId, @@ -2209,11 +2211,10 @@ export class SessionTurnService { } private async proposeEpisodeRouteWithLlm( - session: SessionRecord, + latest: EpisodeRecord | undefined, userText: string, forcedDecision?: TurnRelationDecision ): Promise { - const latest = this.deps.repos.runtime.latestEpisodeForSession(session.id); const relationContext = latest ? this.episodeRelationContext(latest) : undefined; if (forcedDecision || !latest || !relationContext?.prevUserText) { const decision = forcedDecision ?? classifyTurnRelation({ @@ -2241,6 +2242,16 @@ export class SessionTurnService { return this.buildTurnRouteProposal(latest, decision, relationContext.lastTurnAtMs); } + private persistTurnStartRouteProposal(searchEventId: string, routeProposal: TurnRouteProposal): void { + const recall = this.deps.repos.runtime.getRecallEvent(searchEventId); + if (!recall) return; + const request = isRecord(recall.request) ? recall.request : {}; + this.deps.repos.runtime.updateRecallEventRequest(searchEventId, { + ...request, + routeProposal + }); + } + private commitTurnRouteProposal( session: SessionRecord, proposal: TurnRouteProposal, diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index c00a819ba..089470d4b 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -1960,6 +1960,13 @@ export class RuntimeRepository { return row ? recallEventFromSql(row) : undefined; } + updateRecallEventRequest(id: string, request: unknown): RecallEventRecord | undefined { + this.db + .prepare(`UPDATE recall_events SET request_json = ? WHERE id = ?`) + .run(toJson(request), id); + return this.getRecallEvent(id); + } + updateRecallEventOutcome( id: string, outcome: NonNullable diff --git a/Memory/tests/server-entrypoint.test.ts b/Memory/tests/server-entrypoint.test.ts index e13a94870..97d8a48af 100644 --- a/Memory/tests/server-entrypoint.test.ts +++ b/Memory/tests/server-entrypoint.test.ts @@ -1,9 +1,9 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import YAML from "yaml"; import { describe, expect, it } from "vitest"; -import { isDirectRun, writeCurrentEndpoint } from "../src/server/index.js"; +import { isDirectRun, main, writeCurrentEndpoint } from "../src/server/index.js"; describe("memmy memory server entrypoint", () => { it("recognizes Windows packaged paths as direct server execution", () => { @@ -62,4 +62,52 @@ describe("memmy memory server entrypoint", () => { rmSync(root, { recursive: true, force: true }); } }); + + it("shuts down through the admin endpoint and releases the sqlite server lock", async () => { + const root = mkdtempSync(join(tmpdir(), "memmy-memory-server-shutdown-")); + const configPath = join(root, "config.yaml"); + const databasePath = join(root, "memory.sqlite"); + const lockPath = `${databasePath}.server.lock`; + writeFileSync(configPath, YAML.stringify({ + memmyMemory: { + storage: { + mode: "local", + backend: "sqlite", + sqlitePath: databasePath + } + } + })); + + try { + const running = main([ + "--config", configPath, + "--host", "127.0.0.1", + "--port", "0", + "--db", databasePath + ]); + const endpoint = await waitForWrittenEndpoint(configPath); + const response = await fetch(`${endpoint}/api/v1/admin/shutdown`, { method: "POST" }); + + expect(response.ok).toBe(true); + await running; + expect(existsSync(lockPath)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); + +async function waitForWrittenEndpoint(configPath: string): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const config = YAML.parse(readFileSync(configPath, "utf8")) as { + memmyMemory?: { storage?: { endpoint?: unknown } }; + }; + const endpoint = config.memmyMemory?.storage?.endpoint; + if (typeof endpoint === "string" && !endpoint.endsWith(":18960")) { + return endpoint; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + } + throw new Error("Memory server did not write its bound endpoint"); +} diff --git a/Memory/tests/service/session/episode-relation.test.ts b/Memory/tests/service/session/episode-relation.test.ts index 30a3ffb68..b6dc2ad61 100644 --- a/Memory/tests/service/session/episode-relation.test.ts +++ b/Memory/tests/service/session/episode-relation.test.ts @@ -912,6 +912,115 @@ describe("MemoryService / session / episode relation", () => { db.close(); }); + it("runs memory retrieval while the relation classifier is still pending", async () => { + let signalRelationStarted: (() => void) | undefined; + let releaseRelation: (() => void) | undefined; + const relationStarted = new Promise((resolve) => { + signalRelationStarted = resolve; + }); + const relationRelease = new Promise((resolve) => { + releaseRelation = resolve; + }); + let retrievalExtractStarted = false; + const llm: LlmClient = { + config: { + ...DEFAULT_MEMMY_CONFIG.summary, + provider: "host", + endpoint: "http://127.0.0.1/parallel-turn-start", + model: "parallel-turn-start" + }, + isConfigured() { + return true; + }, + async complete() { + return "{}"; + }, + async completeJson>( + _messages: Array<{ role: "system" | "user" | "assistant"; content: string }>, + options: { operation: string } + ): Promise { + if (options.operation === "relation.classify.v1") { + signalRelationStarted?.(); + await relationRelease; + return { + relation: "new_task", + confidence: 0.95, + reason: "the database task is unrelated to nginx" + } as unknown as T; + } + if (options.operation === "retrieval.retrieval.query.extract.v2") { + retrievalExtractStarted = true; + return { + queryVecText: "database certificate rotation", + keywords: ["database", "certificate"] + } as unknown as T; + } + return { ranked: [1], sufficient: true } as unknown as T; + }, + status() { + return { + provider: "host", + model: "parallel-turn-start", + configured: true, + remote: true + }; + } + }; + const config = { + ...DEFAULT_MEMMY_CONFIG, + algorithm: { + ...DEFAULT_MEMMY_CONFIG.algorithm, + retrieval: { + ...DEFAULT_MEMMY_CONFIG.algorithm.retrieval, + llmFilterEnabled: false + } + } + }; + const { db, service } = createTestService({ llm, config }); + const session = service.openSession({ + namespace: { + source: "codex", + profileId: "jiang", + userId: "user-parallel-turn-start" + } + }); + const first = service.completeTurn("turn-parallel-first", { + sessionId: session.sessionId, + query: "Configure nginx TLS for the service", + answer: "Use port 443 and verify the certificate chain." + }); + + const startPromise = service.startTurn({ + turnId: "turn-parallel-next", + sessionId: session.sessionId, + query: "Database certificate rotation details please" + }); + await relationStarted; + await new Promise((resolve) => setImmediate(resolve)); + const retrievalStartedBeforeRelationCompleted = retrievalExtractStarted; + releaseRelation?.(); + const started = await startPromise; + + expect(retrievalStartedBeforeRelationCompleted).toBe(true); + const recall = db.db.prepare( + "SELECT request_json FROM recall_events WHERE id = ?" + ).get(started.searchEventId) as { request_json: string }; + expect(JSON.parse(recall.request_json)).toMatchObject({ + routeProposal: { + action: "split", + baseEpisodeId: first.episodeId, + relationDecision: { relation: "new_task" } + } + }); + + const completed = service.completeTurn("turn-parallel-next", { + sessionId: session.sessionId, + query: "Database certificate rotation details please", + answer: "Rotate the database certificate and reload the client." + }); + expect(completed.episodeId).not.toBe(first.episodeId); + }); + it("uses the account summary model for relation classification", async () => { const root = createTestRoot("mindock-memory-account-relation-"); const db = new MemoryDb({ diff --git a/Migrations/src/index.ts b/Migrations/src/index.ts index 6246a6767..98df1c116 100644 --- a/Migrations/src/index.ts +++ b/Migrations/src/index.ts @@ -18,6 +18,10 @@ export type { } from "./runtime-config-writer.js"; export { resolveMigrationTargets } from "./target-resolver.js"; export type { ResolveMigrationTargetsOptions } from "./target-resolver.js"; +export { + CURRENT_MIGRATION_STATE_FORMAT_VERSION, + SUPPORTED_MIGRATION_STATE_FORMAT_VERSIONS, +} from "./state-store.js"; export { MigrationError } from "./types.js"; export type { AppliedMigrationSummary, diff --git a/Migrations/src/state-store.ts b/Migrations/src/state-store.ts index 90b4269fe..1ff9c232c 100644 --- a/Migrations/src/state-store.ts +++ b/Migrations/src/state-store.ts @@ -8,6 +8,9 @@ import { type MigrationScope, } from "./types.js"; +export const CURRENT_MIGRATION_STATE_FORMAT_VERSION = 2 as const; +export const SUPPORTED_MIGRATION_STATE_FORMAT_VERSIONS = Object.freeze([1, 2] as const); + export type AppliedMigrationTarget = | { type: "agent-workspace" } | { type: "runtime-config"; key: string } @@ -21,7 +24,7 @@ export type AppliedMigrationRecord = { }; export type MigrationState = { - formatVersion: 2; + formatVersion: typeof CURRENT_MIGRATION_STATE_FORMAT_VERSION; scope: "agent-workspace"; applied: AppliedMigrationRecord[]; }; @@ -213,7 +216,7 @@ export function isMigrationApplied( export function emptyMigrationState(): MigrationState { return { - formatVersion: 2, + formatVersion: CURRENT_MIGRATION_STATE_FORMAT_VERSION, scope: "agent-workspace", applied: [], }; @@ -233,14 +236,16 @@ export function validateMigrationState( if (value.formatVersion === 1) { const legacy = value as unknown as LegacyMigrationState; return { - formatVersion: 2, + formatVersion: CURRENT_MIGRATION_STATE_FORMAT_VERSION, scope: "agent-workspace", applied: validateAppliedRecords(legacy.applied, definitions, true), }; } - if (value.formatVersion !== 2) throw stateError("Unsupported migration state format"); + if (value.formatVersion !== CURRENT_MIGRATION_STATE_FORMAT_VERSION) { + throw stateError("Unsupported migration state format"); + } return { - formatVersion: 2, + formatVersion: CURRENT_MIGRATION_STATE_FORMAT_VERSION, scope: "agent-workspace", applied: validateAppliedRecords(value.applied, definitions, false), }; diff --git a/Migrations/tests/state-store.test.ts b/Migrations/tests/state-store.test.ts index 83dadc9e6..e065ec787 100644 --- a/Migrations/tests/state-store.test.ts +++ b/Migrations/tests/state-store.test.ts @@ -2,6 +2,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + CURRENT_MIGRATION_STATE_FORMAT_VERSION, + SUPPORTED_MIGRATION_STATE_FORMAT_VERSIONS, +} from "../src/index.js"; import { emptyMigrationState, getMigrationStatePaths, @@ -46,6 +50,25 @@ afterEach(async () => { }); describe("migration state store", () => { + it("declares the v1 and v2 compatibility required by packaged runtimes", () => { + expect(CURRENT_MIGRATION_STATE_FORMAT_VERSION).toBe(2); + expect([...SUPPORTED_MIGRATION_STATE_FORMAT_VERSIONS]).toEqual([1, 2]); + expect( + validateMigrationState( + { + formatVersion: 2, + scope: "agent-workspace", + applied: [], + }, + definitions, + ), + ).toEqual({ + formatVersion: 2, + scope: "agent-workspace", + applied: [], + }); + }); + it("normalizes v1 records to v2 workspace targets", () => { expect( validateMigrationState( diff --git a/scripts/internal/mac/build-dmg.sh b/scripts/internal/mac/build-dmg.sh index 8ed764bb9..345259aad 100755 --- a/scripts/internal/mac/build-dmg.sh +++ b/scripts/internal/mac/build-dmg.sh @@ -719,7 +719,11 @@ fi import fs from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; - import { runMigrations } from "@memmy/migrations"; + import { + CURRENT_MIGRATION_STATE_FORMAT_VERSION, + SUPPORTED_MIGRATION_STATE_FORMAT_VERSIONS, + runMigrations, + } from "@memmy/migrations"; import { createConnection } from "@playwright/mcp"; import { chromium } from "playwright"; const require = createRequire(import.meta.url); @@ -731,6 +735,7 @@ fi const playwrightPackage = require(playwrightPath); const corePackage = require(corePath); if (typeof runMigrations !== "function") throw new Error("Migrations runtime export is unavailable"); + if (CURRENT_MIGRATION_STATE_FORMAT_VERSION !== 2 || JSON.stringify(SUPPORTED_MIGRATION_STATE_FORMAT_VERSIONS) !== "[1,2]") throw new Error("Migrations runtime state compatibility mismatch"); if (typeof createConnection !== "function" || typeof chromium?.executablePath !== "function") throw new Error("Playwright MCP runtime exports are unavailable"); if (mcpPackage.version !== runtimePackage.dependencies["@playwright/mcp"]) throw new Error("Playwright MCP runtime version mismatch"); if (playwrightPackage.version !== runtimePackage.dependencies.playwright || corePackage.version !== runtimePackage.dependencies.playwright) throw new Error("Playwright runtime version mismatch"); diff --git a/scripts/internal/win/build-nsis.sh b/scripts/internal/win/build-nsis.sh index 240d5dd1b..0b2c75c75 100755 --- a/scripts/internal/win/build-nsis.sh +++ b/scripts/internal/win/build-nsis.sh @@ -145,6 +145,26 @@ require_packaged_runtime_glob() { fi } +verify_migration_state_compatibility_module() { + local module_path + module_path="$(to_node_readable_path "$1")" + + MEMMY_MIGRATION_STATE_MODULE_PATH="$module_path" node --input-type=module --eval ' + import { pathToFileURL } from "node:url"; + const modulePath = process.env.MEMMY_MIGRATION_STATE_MODULE_PATH; + if (!modulePath) throw new Error("Migrations state-store path is unavailable"); + const stateStore = await import(pathToFileURL(modulePath).href); + if (stateStore.CURRENT_MIGRATION_STATE_FORMAT_VERSION !== 2 || JSON.stringify(stateStore.SUPPORTED_MIGRATION_STATE_FORMAT_VERSIONS) !== "[1,2]") { + throw new Error("Migrations runtime state compatibility mismatch"); + } + const legacyState = stateStore.validateMigrationState({ formatVersion: 1, scope: "agent-workspace", applied: [] }, []); + const currentState = stateStore.validateMigrationState({ formatVersion: 2, scope: "agent-workspace", applied: [] }, []); + if (legacyState.formatVersion !== 2 || currentState.formatVersion !== 2) { + throw new Error("Migrations runtime state behavior mismatch"); + } + ' +} + patch_electron_builder_nsis_refresh() { local template_path="$ROOT_DIR/node_modules/app-builder-lib/templates/nsis/uninstaller.nsh" local windows_template_path @@ -380,31 +400,71 @@ EOF exit 1 } -verify_windows_native_module() { - local better_sqlite_node="$RUNTIME_DIR/memory/node_modules/better-sqlite3/build/Release/better_sqlite3.node" - - require_packaged_runtime_file "$RUNTIME_DIR/memory/node_modules/@memmy/local-api-contracts/dist/index.js" - require_packaged_runtime_file "$RUNTIME_DIR/memory/node_modules/@memmy/migrations/dist/index.js" - - if [ ! -f "$better_sqlite_node" ]; then - echo "Missing better-sqlite3 native module: $better_sqlite_node" >&2 - exit 1 - fi +verify_windows_x64_native_module() { + local native_file="$1" + local description="$2" + require_packaged_runtime_file "$native_file" local file_description - file_description="$(file "$better_sqlite_node")" + file_description="$(file "$native_file")" echo "$file_description" case "$file_description" in *PE32+*x86-64* | *PE32+*AMD64*) ;; *) - echo "Expected a Windows x64 better-sqlite3 native module." >&2 + echo "Expected a Windows x64 $description native module: $native_file" >&2 exit 1 ;; esac } +verify_windows_better_sqlite3_runtime() { + local runtime_dir="$1" + local electron_executable="$DESKTOP_DIR/node_modules/electron/dist/electron.exe" + local node_runtime_dir + node_runtime_dir="$(to_node_readable_path "$runtime_dir")" + + require_packaged_runtime_file "$electron_executable" + MEMMY_BETTER_SQLITE_RUNTIME_DIR="$node_runtime_dir" \ + ELECTRON_RUN_AS_NODE=1 \ + "$electron_executable" --input-type=module --eval ' + import { createRequire } from "node:module"; + import path from "node:path"; + import { pathToFileURL } from "node:url"; + const runtimeDir = process.env.MEMMY_BETTER_SQLITE_RUNTIME_DIR; + if (!runtimeDir) throw new Error("better-sqlite3 runtime directory is unavailable"); + const require = createRequire(pathToFileURL(path.join(runtimeDir, "package.json"))); + const Database = require("better-sqlite3"); + const database = new Database(":memory:"); + try { + const row = database.prepare("SELECT 1 AS ok").get(); + if (row?.ok !== 1) throw new Error("better-sqlite3 smoke query failed"); + } finally { + database.close(); + } + ' +} + +verify_packaged_file_matches_runtime() { + local runtime_file="$1" + local packaged_file="$2" + local description="$3" + + if ! cmp -s "$runtime_file" "$packaged_file"; then + echo "Packaged $description differs from the runtime artifact that passed smoke validation." >&2 + exit 1 + fi +} + +verify_windows_native_module() { + require_packaged_runtime_file "$RUNTIME_DIR/memory/node_modules/@memmy/local-api-contracts/dist/index.js" + require_packaged_runtime_file "$RUNTIME_DIR/memory/node_modules/@memmy/migrations/dist/index.js" + verify_windows_x64_native_module \ + "$RUNTIME_DIR/memory/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "Memory better-sqlite3" +} + verify_windows_onnxruntime_module() { local onnxruntime_node="$RUNTIME_DIR/memory/node_modules/onnxruntime-node/bin/napi-v3/win32/x64/onnxruntime_binding.node" local onnxruntime_dir @@ -442,6 +502,9 @@ verify_windows_sharp_module() { verify_windows_agent_native_artifacts() { local node_pty_dir="$RUNTIME_DIR/memmy-agent/node_modules/openclaw/node_modules/@lydell/node-pty-win32-x64/prebuilds/win32-x64" + verify_windows_x64_native_module \ + "$RUNTIME_DIR/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "memmy-agent better-sqlite3" require_packaged_runtime_file "$node_pty_dir/conpty.node" require_packaged_runtime_file "$node_pty_dir/conpty/conpty.dll" require_packaged_runtime_file "$node_pty_dir/conpty/OpenConsole.exe" @@ -453,10 +516,27 @@ verify_packaged_windows_unpacked_artifacts() { local packaged_embedding_model="$DESKTOP_DIR/release/win-unpacked/resources/embedding-models/$EMBEDDING_MODEL_ID" require_packaged_runtime_file "$DESKTOP_DIR/release/win-unpacked/resources/app.asar" + verify_windows_x64_native_module \ + "$unpacked_runtime/memory/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "packaged Memory better-sqlite3" + verify_windows_x64_native_module \ + "$unpacked_runtime/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "packaged memmy-agent better-sqlite3" + verify_packaged_file_matches_runtime \ + "$RUNTIME_DIR/memory/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "$unpacked_runtime/memory/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "Memory better-sqlite3 module" + verify_packaged_file_matches_runtime \ + "$RUNTIME_DIR/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "$unpacked_runtime/memmy-agent/node_modules/better-sqlite3/build/Release/better_sqlite3.node" \ + "memmy-agent better-sqlite3 module" require_packaged_runtime_file "$unpacked_runtime/memory/node_modules/onnxruntime-node/bin/napi-v3/win32/x64/onnxruntime.dll" require_packaged_runtime_glob "$unpacked_runtime/memory/node_modules/onnxruntime-node/bin/napi-v3/win32/x64/*.dll" require_packaged_runtime_glob "$unpacked_runtime/memory/node_modules/@img/sharp-win32-x64/lib/libvips*.dll" require_packaged_runtime_file "$unpacked_runtime/memmy-agent/node_modules/@memmy/migrations/dist/index.js" + require_packaged_runtime_file "$unpacked_runtime/memmy-agent/node_modules/@memmy/migrations/dist/state-store.js" + verify_migration_state_compatibility_module \ + "$unpacked_runtime/memmy-agent/node_modules/@memmy/migrations/dist/state-store.js" require_packaged_runtime_file "$packaged_embedding_model/config.json" require_packaged_runtime_file "$packaged_embedding_model/tokenizer.json" require_packaged_runtime_file "$packaged_embedding_model/onnx/model_quantized.onnx" @@ -475,11 +555,12 @@ npm_ci_win_x64() { } install_better_sqlite3_win_x64() { + local runtime_dir="$1" local electron_version electron_version="${MEMMY_ELECTRON_VERSION:-$(read_package_version "$DESKTOP_DIR/node_modules/electron/package.json")}" ( - cd "$RUNTIME_DIR/memory/node_modules/better-sqlite3" + cd "$runtime_dir/node_modules/better-sqlite3" run_with_retries 3 ../.bin/prebuild-install --platform win32 --arch x64 --runtime electron --target "$electron_version" || install_better_sqlite3_prebuild_with_download_fallback "$electron_version" ) @@ -535,8 +616,9 @@ cp "$ROOT_DIR/App/backend/local-api-contracts/package.json" "$RUNTIME_DIR/memory cp -R "$ROOT_DIR/App/backend/local-api-contracts/dist" "$RUNTIME_DIR/memory/node_modules/@memmy/local-api-contracts/dist" cp "$MIGRATIONS_STAGING_DIR/package.json" "$RUNTIME_DIR/memory/node_modules/@memmy/migrations/package.json" cp -R "$MIGRATIONS_STAGING_DIR/dist" "$RUNTIME_DIR/memory/node_modules/@memmy/migrations/dist" -install_better_sqlite3_win_x64 +install_better_sqlite3_win_x64 "$RUNTIME_DIR/memory" verify_windows_native_module +verify_windows_better_sqlite3_runtime "$RUNTIME_DIR/memory" verify_windows_onnxruntime_module verify_windows_sharp_module @@ -546,6 +628,7 @@ cp "$AGENT_DIR/package-lock.json" "$RUNTIME_DIR/memmy-agent/package-lock.json" log "Installing Windows x64 memmy-agent runtime dependencies" npm_ci_win_x64 "$RUNTIME_DIR/memmy-agent" +install_better_sqlite3_win_x64 "$RUNTIME_DIR/memmy-agent" RUNTIME_MIGRATIONS_DIR="$RUNTIME_DIR/memmy-agent/node_modules/@memmy/migrations" rm -rf "$RUNTIME_MIGRATIONS_DIR" mkdir -p "$RUNTIME_MIGRATIONS_DIR" @@ -565,13 +648,18 @@ if [ -e "$MIGRATIONS_STAGING_DIR" ]; then exit 1 fi verify_windows_agent_native_artifacts +verify_windows_better_sqlite3_runtime "$RUNTIME_DIR/memmy-agent" ( cd "$RUNTIME_DIR/memmy-agent" node --input-type=module --eval ' import fs from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; - import { runMigrations } from "@memmy/migrations"; + import { + CURRENT_MIGRATION_STATE_FORMAT_VERSION, + SUPPORTED_MIGRATION_STATE_FORMAT_VERSIONS, + runMigrations, + } from "@memmy/migrations"; import { createConnection } from "@playwright/mcp"; import { chromium } from "playwright"; const require = createRequire(import.meta.url); @@ -583,6 +671,7 @@ verify_windows_agent_native_artifacts const playwrightPackage = require(playwrightPath); const corePackage = require(corePath); if (typeof runMigrations !== "function") throw new Error("Migrations runtime export is unavailable"); + if (CURRENT_MIGRATION_STATE_FORMAT_VERSION !== 2 || JSON.stringify(SUPPORTED_MIGRATION_STATE_FORMAT_VERSIONS) !== "[1,2]") throw new Error("Migrations runtime state compatibility mismatch"); if (typeof createConnection !== "function" || typeof chromium?.executablePath !== "function") throw new Error("Playwright MCP runtime exports are unavailable"); if (mcpPackage.version !== runtimePackage.dependencies["@playwright/mcp"]) throw new Error("Playwright MCP runtime version mismatch"); if (playwrightPackage.version !== runtimePackage.dependencies.playwright || corePackage.version !== runtimePackage.dependencies.playwright) throw new Error("Playwright runtime version mismatch");