From 38d54984fcb948e7f5b1d123629916210a165d78 Mon Sep 17 00:00:00 2001 From: zhuqingyv Date: Sun, 12 Jul 2026 13:40:57 +0800 Subject: [PATCH 1/5] feat: redesign Agora memory control plane --- README.md | 12 +- README.zh-CN.md | 12 +- docs/agora-vip-provider-requirements.md | 11 +- package-lock.json | 4 +- package.json | 3 +- scripts/fix-node-pty.mjs | 16 + scripts/package-portable.mjs | 39 ++ src/agent.ts | 5 + src/cli/App.tsx | 166 +++++++- src/cli/components/MemoryConsole.tsx | 132 +++++++ src/cli/components/ModelPicker.tsx | 3 +- src/cli/components/StatusBar.tsx | 55 ++- src/cli/utils/commands.ts | 85 +++- src/cli/utils/modelProfiles.ts | 1 + src/mcp/types.ts | 1 + src/provider/agora.ts | 501 ++++++++++++++++++------ test/agora-provider-runtime.test.ts | 182 ++++++++- test/cli-ux.test.ts | 44 +++ test/commands.test.ts | 24 +- 19 files changed, 1095 insertions(+), 201 deletions(-) create mode 100644 scripts/fix-node-pty.mjs create mode 100644 src/cli/components/MemoryConsole.tsx diff --git a/README.md b/README.md index 01b7b06..9d39c45 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ DeepSeek in minutes. Local small models turned into real productivity. MA is a terminal coding agent built around two practical promises: remote setup should be brainless, and local small models should become useful production tools. DeepSeek config is interactive and direct. LM Studio/Qwen gets long-context handling, tool hardening, model switching, and benchmark-driven fixes so small models can do real repo work. -`v0.2.0-alpha.1` supports LM Studio local models, DeepSeek official API, and Agora through MCP stdio. Agora is MA's local-first provider integration: it exposes real model-loading and MemoryPatch state instead of pretending memory was added to a prompt. +`v0.3.0` supports LM Studio local models, DeepSeek official API, and Agora through MCP stdio. Agora is MA's local-first provider integration: it exposes real model-loading and MemoryPatch state instead of pretending memory was added to a prompt. Website: https://zimoos.github.io/my-agent/ -Release: https://github.com/zimoos/my-agent/releases/tag/v0.2.0-alpha.1 +Release: https://github.com/zimoos/my-agent/releases/tag/v0.3.0 [Roadmap](ROADMAP.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Discussions](https://github.com/zimoos/my-agent/discussions) @@ -132,6 +132,7 @@ Inside MA: ```text / show slash command suggestions /model switch model/profile with arrow keys +/memory open the project memory console Tab complete selected command Enter run selected slash command ESC ESC switch session @@ -144,6 +145,7 @@ User-facing slash commands: | Command | Purpose | | --- | --- | | `/model` | Open the model/profile picker | +| `/memory` | Manage named profiles, patch selection, writable memory, automatic intake, and rollback | | `/help` | Show user-facing commands | | `/clear` | Clear current conversation | | `/exit` | Exit MA | @@ -184,6 +186,12 @@ MA can run Agora as a provider-owned MCP stdio subprocess instead of asking user When the active provider is Agora, MA can expose verified MemoryPatch operations: mount, disable, internalize, roll back, and inspect state. MA treats a memory module as active only after Agora returns matching response metadata; it never fakes memory by injecting facts into a prompt. +`/memory` controls named MemoryProfiles stored in Agora. A profile may mount one writable family plus multiple read-only overlays. Automatic intake advances only the writable family, preserves overlays, and runs in the background after 4 new user turns or about 2,000 pending tokens and 60 seconds of idle time. + +Context Usage remains independent from MemoryPatch state: the TUI continues to show used/trigger/window/source from `agent.getContextUsage()`. Internalization never clears context, and compaction never claims to internalize memory. + +The MA portable release pins the exact Agora `0.2.0` Mach-O artifact. Agora npm user artifacts contain no `.py`, `.pyc`, `.js`, or source maps and require no login or device activation. + ## Built-In Tools MA starts with built-in MCP servers: diff --git a/README.zh-CN.md b/README.zh-CN.md index 7d20cf4..ca2bf68 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,11 +5,11 @@ DeepSeek 无脑配置,本地小模型也能变生产力。 MA 的核心不是“又接了一个模型”,而是两件事:远程模型配置必须无脑,本地小模型必须能做真实项目。DeepSeek 走交互式配置,LM Studio/Qwen 则通过长上下文、工具调用加固、模型切换和 benchmark 驱动修复,把小模型转化成能持续工作的生产力。 -`v0.2.0-alpha.1` 当前支持 LM Studio 本地模型、DeepSeek 官方 API,以及通过 MCP stdio 接入的 Agora。Agora 是 MA 的本地优先深度适配:它展示真实的模型加载和 MemoryPatch 状态,不会把记忆伪装成一段 prompt。 +`v0.3.0` 当前支持 LM Studio 本地模型、DeepSeek 官方 API,以及通过 MCP stdio 接入的 Agora。Agora 是 MA 的本地优先深度适配:它展示真实的模型加载和 MemoryPatch 状态,不会把记忆伪装成一段 prompt。 官网:https://zimoos.github.io/my-agent/ -发布页:https://github.com/zimoos/my-agent/releases/tag/v0.2.0-alpha.1 +发布页:https://github.com/zimoos/my-agent/releases/tag/v0.3.0 [路线图](ROADMAP.md) · [变更记录](CHANGELOG.md) · [参与贡献](CONTRIBUTING.md) · [Discussions](https://github.com/zimoos/my-agent/discussions) @@ -131,6 +131,7 @@ DeepSeek official -> Base URL -> credential name -> Keychain API key -> 发现 D ```text / 显示斜杠指令提示 /model 用上下键切换模型/profile +/memory 打开项目记忆控制台 Tab 补全当前选中的指令 Enter 执行当前选中的斜杠指令 ESC ESC 切换历史会话 @@ -143,6 +144,7 @@ ESC ESC 切换历史会话 | 指令 | 用途 | | --- | --- | | `/model` | 打开模型/profile 选择器 | +| `/memory` | 管理命名 Profile、Patch 多选、主记忆、自动内化和回滚 | | `/help` | 查看用户可用指令 | | `/clear` | 清空当前对话 | | `/exit` | 退出 MA | @@ -183,6 +185,12 @@ MA 可以把 Agora 作为 provider 自己管理的 MCP stdio 子进程运行, 当 Agora 是当前 provider 时,MA 会提供经过验证的 MemoryPatch 操作:挂载、停用、内化、回滚和状态查看。只有 Agora 响应 metadata 返回匹配的状态,MA 才会说明记忆已生效;不会把一段事实塞进 prompt 伪装成记忆。 +`/memory` 直接控制 Agora registry 中的命名 MemoryProfile。一个 Profile 可以挂载一个可写主记忆 family 和多个只读 overlay;自动内化只推进主 family,overlay 不会被覆盖。默认资格阈值是 4 个新增用户回合或约 2000 pending tokens,并在空闲 60 秒后后台运行,不阻塞输入。 + +Context Usage 与 MemoryPatch 是两条独立状态:底栏始终从 `agent.getContextUsage()` 显示 used/trigger/window/source;内化不会清空 context,compact 也不会冒充记忆内化。 + +正式 MA portable 包锁定 Agora `0.2.0` 的 Mach-O 平台制品。Agora npm 用户制品不包含 `.py/.pyc/.js/.map`;用户无需登录或机器授权即可运行。 + ## 内置工具 MA 默认启动以下 MCP server: diff --git a/docs/agora-vip-provider-requirements.md b/docs/agora-vip-provider-requirements.md index 524730c..43c4ed1 100644 --- a/docs/agora-vip-provider-requirements.md +++ b/docs/agora-vip-provider-requirements.md @@ -17,7 +17,7 @@ MA 将 Agora 作为内部 VIP provider 适配:Agora 不再只是一个 OpenAI- 1. Agora provider 运行时 - `provider: "agora"` 必须使用 MCP stdio runtime。 - runtime readiness 仍以 `doctor`、`models_list`、`chat_complete` 为基础能力。 - - memory 能力只有在完整 memory MCP tools 存在时启用。 + - memory 使用 granular capability matrix;缺少目录、下载或 intake 工具时只降级对应功能,不能关闭 Agora chat。 - provider-owned Agora subprocess 必须在 MA 退出时关闭。 2. Agora 进度展示 @@ -27,9 +27,10 @@ MA 将 Agora 作为内部 VIP provider 适配:Agora 不再只是一个 OpenAI- - 第一次本地模型聊天时,TUI 不能只显示 `thinking`,要显示类似 `Agora · 加载本地模型 ...`。 - 如果 Agora 只提供阶段级进度,MA 只展示阶段,不伪造文件级、权重级百分比。 -3. Agora 底栏状态 - - 当 active provider 是 Agora 时,TUI 底栏展示 Agora 专属摘要。 - - 摘要至少包含:provider 标识、当前模型、memory status、已挂载 patch 数、Agora session 简写。 +3. Agora 状态层级 + - 第一行始终展示 provider、model 和 Context Usage(used/trigger/window/source)。 + - 第二行仅在 Agora 下展示 Profile、patch 数、verified/stale 和后台 intake activity。 + - session/binding/job/完整 patch id 只放在 `/memory status`,不再堆入主底栏。 - 该状态来自 runtime 当前 state 或 session meta 的 `providerState`。 - 普通 provider 不展示 Agora 专属 memory/session 信息。 @@ -37,6 +38,8 @@ MA 将 Agora 作为内部 VIP provider 适配:Agora 不再只是一个 OpenAI- - Session meta 保留 `providerState`,字段包括 `provider_id`、`agora_session_id`、`memory.status`、`profile_id`、`binding_id`、`active_memory_patch_ids`、`last_verified_at`。 - MA 只能从 Agora `chat_complete` 响应 metadata 更新 mounted 证据。 - mount、disable、internalize、rollback 必须验证下一次 `chat_complete` metadata 后才报告成功。 + - `/memory` 提供命名 Profile、Patch 多选、唯一 writable family、overlay、自动策略、历史和回滚。 + - 自动 intake 只推进 writable family,通过 CAS 保留 overlay;后台任务不得禁用输入框。 5. 普通 provider 回归边界 - DeepSeek、LM Studio、OpenAI-compatible 的请求参数、重试策略、stream parser、tool-call 解析保持不变。 diff --git a/package-lock.json b/package-lock.json index a161c01..5fad920 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-agent", - "version": "0.2.0-alpha.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-agent", - "version": "0.2.0-alpha.1", + "version": "0.3.0", "license": "MIT", "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/package.json b/package.json index 7797cb5..9fe5c67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-agent", - "version": "0.2.0-alpha.1", + "version": "0.3.0", "description": "Local-first multi-model terminal coding agent", "homepage": "https://zimoos.github.io/my-agent/", "type": "module", @@ -15,6 +15,7 @@ "LICENSE" ], "scripts": { + "postinstall": "node scripts/fix-node-pty.mjs", "start": "tsx src/cli/index.tsx", "build": "tsc && chmod +x dist/src/cli/index.js bin/ma scripts/package-portable.mjs", "package:portable": "npm run build && node scripts/package-portable.mjs --out release", diff --git a/scripts/fix-node-pty.mjs b/scripts/fix-node-pty.mjs new file mode 100644 index 0000000..5de282e --- /dev/null +++ b/scripts/fix-node-pty.mjs @@ -0,0 +1,16 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +if (process.platform !== 'win32') { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const helper = path.join( + root, + 'node_modules', + 'node-pty', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'spawn-helper' + ); + if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755); +} diff --git a/scripts/package-portable.mjs b/scripts/package-portable.mjs index b43c018..3a38066 100755 --- a/scripts/package-portable.mjs +++ b/scripts/package-portable.mjs @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')); @@ -27,6 +28,43 @@ function copyRequired(src, dest) { fs.cpSync(src, dest, { recursive: true }); } +function sha256(file) { + return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +function copyAgoraRuntime(appDir, target) { + if (target !== 'macos-arm64') return; + const source = process.env.MA_AGORA_ARTIFACT_DIR + ? path.resolve(process.env.MA_AGORA_ARTIFACT_DIR) + : path.join(root, 'resources', 'agora'); + const manifestPath = path.join(source, 'manifest.json'); + const binaryPath = path.join(source, 'bin', 'agora'); + if (!fs.existsSync(manifestPath) || !fs.existsSync(binaryPath)) { + throw new Error('macos-arm64 portable release requires MA_AGORA_ARTIFACT_DIR with Agora 0.2.0 native artifact'); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (manifest.version !== '0.2.0' || manifest.host_protocol_major !== 1) { + throw new Error(`unexpected Agora contract: ${manifest.version}/host-v${manifest.host_protocol_major}`); + } + if (manifest.files?.['bin/agora'] !== sha256(binaryPath)) { + throw new Error('Agora binary SHA-256 does not match manifest'); + } + const destination = path.join(appDir, 'resources', 'agora'); + copyRequired(source, destination); + fs.writeFileSync( + path.join(destination, 'runtime-lock.json'), + JSON.stringify({ + version: '0.2.0', + package: '@zimoos/agora-darwin-arm64', + platform: 'darwin-arm64', + host_protocol_major: 1, + native_core_abi: 1, + manifest_sha256: sha256(manifestPath), + }, null, 2) + '\n', + 'utf8' + ); +} + const outRoot = path.resolve(root, arg('--out', 'release')); const target = arg('--target', platformId()); const name = `ma-${pkg.version}-${target}`; @@ -42,6 +80,7 @@ copyRequired(path.join(root, 'dist'), path.join(app, 'dist')); copyRequired(path.join(root, 'node_modules'), path.join(app, 'node_modules')); copyRequired(path.join(root, 'package.json'), path.join(app, 'package.json')); copyRequired(path.join(root, 'README.md'), path.join(app, 'README.md')); +copyAgoraRuntime(app, target); if (fs.existsSync(path.join(root, 'LICENSE'))) { fs.copyFileSync(path.join(root, 'LICENSE'), path.join(app, 'LICENSE')); } diff --git a/src/agent.ts b/src/agent.ts index 7ba8992..2b79ce3 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -1849,6 +1849,10 @@ export async function createAgent( } } + function getMemoryController(): AgoraMemoryController | null { + return agoraMemoryController; + } + function inspectContext(): string { return contextManager.inspect(); } @@ -1894,6 +1898,7 @@ export async function createAgent( revertLastTurnContextOnly, respondConfirm, getProviderState, + getMemoryController, getContextUsage, inspectContext, searchContext, diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 42ec49e..408974b 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -24,6 +24,8 @@ import { type SessionPickerSession, } from './components/SessionPicker.js'; import { ModelPicker } from './components/ModelPicker.js'; +import { MemoryConsole } from './components/MemoryConsole.js'; +import type { AgoraMemoryPatch, AgoraMemoryProfile } from '../provider/agora.js'; import type { PendingConfirm } from './hooks/useAgent.js'; import { isCommand, executeCommand } from './utils/commands.js'; import { @@ -72,10 +74,65 @@ export function App({ config, connections, agent, sessionStore, currentSessionId agent.getProviderState?.() ?? sessionStore.list(100).find((session) => session.id === currentSessionId)?.providerState ?? null; + const memoryController = agent.getMemoryController?.() ?? null; const [pendingImages, setPendingImages] = useState([]); const [sessionPickerSessions, setSessionPickerSessions] = useState(null); const [modelPickerModels, setModelPickerModels] = useState(null); + const [memoryConsole, setMemoryConsole] = useState<{ + profiles: AgoraMemoryProfile[]; + patches: AgoraMemoryPatch[]; + } | null>(null); + const [memoryActivity, setMemoryActivity] = useState(''); + const lastUserActivityRef = useRef(Date.now()); + const intakeInFlightRef = useRef(false); + + const refreshMemoryConsole = useCallback(async () => { + if (!memoryController) throw new Error('Agora memory controller is unavailable'); + const [profiles, patches] = await Promise.all([ + memoryController.listProfiles(), + memoryController.listPatches(true), + ]); + setMemoryConsole({ profiles, patches }); + }, [memoryController]); + + const openMemoryConsole = useCallback(async () => { + try { + await refreshMemoryConsole(); + } catch (err) { + store.pushMessage({ kind: 'system', id: nextSysId(), text: `Memory error: ${(err as Error).message}` }); + } + }, [refreshMemoryConsole, store]); + + const runMemoryIntake = useCallback(async (profileId: string) => { + if (!memoryController || intakeInFlightRef.current) return; + intakeInFlightRef.current = true; + try { + const submitted = await memoryController.startIntake({ profile_id: profileId }); + const jobId = String(submitted.job_id ?? submitted.job?.id ?? ''); + if (!jobId) throw new Error('Agora did not return an intake job id'); + setMemoryActivity(`queued · ${jobId}`); + for (;;) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + const current = await memoryController.finalizeIntake(jobId, profileId); + const status = String(current.status ?? current.job?.status ?? ''); + const stage = String(current.stage ?? current.job?.stage ?? status); + setMemoryActivity(stage); + if (status === 'failed') throw new Error(current.error?.message ?? current.job?.error ?? stage); + if (status === 'completed' || current.outcome) { + setMemoryActivity(current.outcome === 'review_required' ? 'review required' : current.outcome ?? 'completed'); + const nextState = agent.getProviderState?.(); + if (nextState) sessionStore.updateProviderState(currentSessionId, nextState); + await refreshMemoryConsole().catch(() => undefined); + break; + } + } + } catch (err) { + setMemoryActivity(`failed · ${(err as Error).message}`); + } finally { + intakeInFlightRef.current = false; + } + }, [agent, currentSessionId, memoryController, refreshMemoryConsole, sessionStore]); const handleConfirm = useCallback( (approved: boolean) => { @@ -97,7 +154,25 @@ export function App({ config, connections, agent, sessionStore, currentSessionId const openModelPicker = useCallback(async () => { try { store.pushMessage({ kind: 'system', id: nextSysId(), text: '[正在查询模型列表...]' }); - const choices = await listModelChoices(config); + let choices = await listModelChoices(config); + if (memoryController?.getCapabilities().modelCatalog) { + const models = await memoryController.listModels(); + const liveChoices: ModelChoice[] = models + .filter((item) => typeof item.id === 'string') + .map((item) => ({ + id: `agora-local/${item.id}`, + credentialId: 'agora-local', + provider: 'agora', + baseURL: 'mcp-stdio://agora', + model: String(item.id), + label: `Agora/${item.name ?? item.id}`, + current: config.model.provider === 'agora' && config.model.model === item.id, + source: 'remote', + status: typeof item.status === 'string' ? item.status : undefined, + })); + const liveIds = new Set(liveChoices.map((item) => item.id)); + choices = [...liveChoices, ...choices.filter((item) => !liveIds.has(item.id))]; + } setModelPickerModels(choices); } catch (err) { store.pushMessage({ @@ -106,9 +181,25 @@ export function App({ config, connections, agent, sessionStore, currentSessionId text: `Failed to list models: ${(err as Error).message}`, }); } - }, [config, store]); + }, [config, memoryController, store]); - const switchModelChoice = useCallback((model: ModelChoice) => { + const switchModelChoice = useCallback(async (model: ModelChoice) => { + if (model.provider === 'agora' && model.status && model.status !== 'available') { + if (!memoryController?.getCapabilities().modelDownload) { + store.pushMessage({ kind: 'system', id: nextSysId(), text: '[Agora runtime 不支持模型下载,请升级 Agora]' }); + return; + } + setMemoryActivity(`下载模型 ${model.model}`); + try { + await memoryController.downloadModel(model.model, (event) => { + setMemoryActivity(`下载模型 ${model.model} · ${event.progress ?? '?'}${event.total ? `/${event.total}` : ''}`); + }); + setMemoryActivity(`模型 ${model.model} 已下载`); + } catch (err) { + setMemoryActivity(`下载失败 · ${(err as Error).message}`); + return; + } + } setModelPickerModels(null); saveDefaultModelChoice(model); store.pushMessage({ @@ -119,10 +210,11 @@ export function App({ config, connections, agent, sessionStore, currentSessionId log(`switch model: ${model.id}`); onRestartSession?.(currentSessionId); app.exit(); - }, [app, currentSessionId, log, onRestartSession, store]); + }, [app, currentSessionId, log, memoryController, onRestartSession, store]); const handleSubmit = useCallback( (text: string) => { + lastUserActivityRef.current = Date.now(); log(`submit: ${text}`); if (isCommand(text)) { (async () => { @@ -133,6 +225,7 @@ export function App({ config, connections, agent, sessionStore, currentSessionId exit: () => app.exit(), revertLastTurn: handleRevertLastTurn, openModelPicker, + openMemoryConsole, switchModelChoice, }); if (text === '/clear') { @@ -158,9 +251,35 @@ export function App({ config, connections, agent, sessionStore, currentSessionId send(text); } }, - [agent, connections, app, store, send, log, pendingImages, handleRevertLastTurn, config, openModelPicker, switchModelChoice] + [agent, connections, app, store, send, log, pendingImages, handleRevertLastTurn, config, openModelPicker, openMemoryConsole, switchModelChoice] ); + useEffect(() => { + if (!memoryController || config.model.provider?.toLowerCase() !== 'agora') return; + const timer = setInterval(async () => { + if (intakeInFlightRef.current || thinkingRef.current || pendingConfirm || sessionPickerSessions || modelPickerModels || memoryConsole) return; + const profileId = agent.getProviderState?.()?.memory?.profile_id; + if (!profileId) return; + try { + const profiles = await memoryController.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + const policy = profile?.auto_intake_policy; + if (!profile || !profile.writable_patch_family || !policy?.enabled) return; + const idleSeconds = policy.idle_seconds ?? 60; + if (Date.now() - lastUserActivityRef.current < idleSeconds * 1000) return; + const status = await memoryController.getIntakeStatus(); + if (status.active_job) return; + const turnsReady = Number(status.pending_user_turns ?? 0) >= Number(policy.min_user_turns ?? 4); + const tokensReady = Number(status.pending_tokens ?? 0) >= Number(policy.min_pending_tokens ?? 2000); + if (!turnsReady && !tokensReady) return; + void runMemoryIntake(profileId); + } catch (err) { + setMemoryActivity(`stale · ${(err as Error).message}`); + } + }, 5000); + return () => clearInterval(timer); + }, [agent, config.model.provider, memoryConsole, memoryController, modelPickerModels, pendingConfirm, runMemoryIntake, sessionPickerSessions]); + const openSessionPicker = useCallback(() => { const allSessions = sessionStore.list(50); const currentCwd = allSessions.find((session) => session.id === currentSessionId)?.cwd ?? process.cwd(); @@ -325,9 +444,43 @@ export function App({ config, connections, agent, sessionStore, currentSessionId /> ) : null} + {memoryConsole ? ( + { + void memoryController?.selectProfile(profileId).then(() => { + setMemoryActivity('已选择 · 下一次真实对话生效'); + return refreshMemoryConsole(); + }); + }} + onApply={(profileId, patchIds, writableFamily) => { + void memoryController?.applyPatchSelection(profileId, patchIds, writableFamily).then(() => { + setMemoryActivity('Patch 已更新 · 下一次真实对话生效'); + return refreshMemoryConsole(); + }); + }} + onCreate={(name) => { + const profileId = `ma-${name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-').replace(/^-|-$/g, '') || 'memory'}-${Date.now().toString(36)}`; + void memoryController?.createProfile({ profile_id: profileId, name }).then(() => refreshMemoryConsole()); + }} + onRename={(profileId, name) => { + void memoryController?.renameProfile(profileId, name).then(() => refreshMemoryConsole()); + }} + onAuto={(profileId, enabled) => { + void memoryController?.setAutoPolicy(profileId, enabled).then(() => refreshMemoryConsole()); + }} + onInternalize={(profileId) => { void runMemoryIntake(profileId); }} + onCancel={() => setMemoryConsole(null)} + /> + ) : null} + setPendingImages([])} onOpenSessionPicker={openSessionPicker} @@ -343,6 +496,7 @@ export function App({ config, connections, agent, sessionStore, currentSessionId contextTotal={contextUsage.total} contextThreshold={contextUsage.compactThreshold} contextSource={contextUsage.source} + memoryActivity={memoryActivity} /> ); diff --git a/src/cli/components/MemoryConsole.tsx b/src/cli/components/MemoryConsole.tsx new file mode 100644 index 0000000..3d2f038 --- /dev/null +++ b/src/cli/components/MemoryConsole.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import TextInput from 'ink-text-input'; +import type { AgoraMemoryPatch, AgoraMemoryProfile } from '../../provider/agora.js'; + +export interface MemoryConsoleProps { + project: string; + profiles: AgoraMemoryProfile[]; + patches: AgoraMemoryPatch[]; + activeProfileId?: string; + activity?: string; + onUse(profileId: string): void; + onApply(profileId: string, patchIds: string[], writableFamily: string | null): void; + onCreate(name: string): void; + onRename(profileId: string, name: string): void; + onAuto(profileId: string, enabled: boolean): void; + onInternalize(profileId: string): void; + onCancel(): void; +} + +export function MemoryConsole(props: MemoryConsoleProps) { + const initial = Math.max(0, props.profiles.findIndex((profile) => profile.id === props.activeProfileId)); + const [profileIndex, setProfileIndex] = useState(initial); + const [patchIndex, setPatchIndex] = useState(0); + const [focus, setFocus] = useState<'profiles' | 'patches'>('profiles'); + const [selected, setSelected] = useState([]); + const [writableFamily, setWritableFamily] = useState(null); + const [editMode, setEditMode] = useState<'create' | 'rename' | null>(null); + const [editValue, setEditValue] = useState(''); + const profile = props.profiles[profileIndex]; + const compatiblePatches = useMemo( + () => props.patches.filter((patch) => !profile || patch.base_model_id === profile.base_model_id), + [profile, props.patches] + ); + + useEffect(() => { + setSelected(profile?.active_memory_patch_ids ?? []); + setWritableFamily(profile?.writable_patch_family ?? null); + }, [profile?.id]); + + useInput((input, key) => { + if (editMode) return; + if (key.escape) return props.onCancel(); + if (key.tab) return setFocus((value) => value === 'profiles' ? 'patches' : 'profiles'); + if (key.upArrow) { + if (focus === 'profiles') setProfileIndex((value) => Math.max(0, value - 1)); + else setPatchIndex((value) => Math.max(0, value - 1)); + return; + } + if (key.downArrow) { + if (focus === 'profiles') setProfileIndex((value) => Math.min(props.profiles.length - 1, value + 1)); + else setPatchIndex((value) => Math.min(compatiblePatches.length - 1, value + 1)); + return; + } + if (input === 'n') { + setEditValue(''); + setEditMode('create'); + return; + } + if (input === 'e' && profile) { + setEditValue(profile.name); + setEditMode('rename'); + return; + } + if (input === 'a' && profile) { + props.onAuto(profile.id, !profile.auto_intake_policy?.enabled); + return; + } + if (input === 'i' && profile) { + props.onInternalize(profile.id); + return; + } + const patch = compatiblePatches[patchIndex]; + if (focus === 'patches' && input === ' ' && patch?.mountable) { + setSelected((value) => value.includes(patch.id) + ? value.filter((id) => id !== patch.id) + : [...value, patch.id]); + return; + } + if (focus === 'patches' && input === 'w' && patch?.mountable) { + setWritableFamily((value) => value === patch.family ? null : patch.family); + if (!selected.includes(patch.id)) setSelected((value) => [...value, patch.id]); + return; + } + if (key.return && profile) { + if (focus === 'profiles') props.onUse(profile.id); + else props.onApply(profile.id, selected, writableFamily); + } + }); + + const submitEdit = (value: string) => { + const name = value.trim(); + if (name) { + if (editMode === 'create') props.onCreate(name); + if (editMode === 'rename' && profile) props.onRename(profile.id, name); + } + setEditMode(null); + setEditValue(''); + }; + + return ( + + Memory Console · 项目: {props.project} + Profiles {focus === 'profiles' ? '‹' : ''} + {props.profiles.length === 0 ? 没有 Profile,按 n 新建 : null} + {props.profiles.map((item, index) => ( + + {focus === 'profiles' && index === profileIndex ? '› ' : ' '} + {item.name} · {item.active_memory_patch_ids.length} patches · auto {item.auto_intake_policy?.enabled ? 'on' : 'off'} + {item.id === props.activeProfileId ? ' · verified' : ''} + + ))} + Patches {focus === 'patches' ? '‹' : ''} + {compatiblePatches.length === 0 ? 没有兼容 Patch : null} + {compatiblePatches.map((patch, index) => { + const checked = selected.includes(patch.id); + const writable = writableFamily === patch.family; + return ( + + {focus === 'patches' && index === patchIndex ? '› ' : ' '} + {checked ? '☑' : '☐'} {patch.name} · {patch.version} · {writable ? '主记忆/可写' : 'overlay'} · {patch.status} + + ); + })} + {editMode ? ( + {editMode === 'create' ? '新建名称: ' : '重命名: '} + ) : null} + {props.activity ? Activity: {props.activity} : null} + Tab 切区 · Space 挂载 · w 设主记忆 · Enter 应用 · n 新建 · e 重命名 · a 自动 · i 内化 · Esc 返回 + + ); +} diff --git a/src/cli/components/ModelPicker.tsx b/src/cli/components/ModelPicker.tsx index b353c2a..6e91c40 100644 --- a/src/cli/components/ModelPicker.tsx +++ b/src/cli/components/ModelPicker.tsx @@ -38,6 +38,7 @@ export function ModelPicker({ models, onSelect, onCancel }: ModelPickerProps) { const suffix = [ model.current ? '当前' : '', model.source === 'cache' ? 'cache' : '', + model.status && model.status !== 'available' ? model.status : '', ].filter(Boolean).join(' · '); return ( @@ -47,7 +48,7 @@ export function ModelPicker({ models, onSelect, onCancel }: ModelPickerProps) { ); })} - ↑/↓ 选择 · Enter 切换 · Esc 取消 + ↑/↓ 选择 · Enter 下载/切换 · Esc 取消 ); } diff --git a/src/cli/components/StatusBar.tsx b/src/cli/components/StatusBar.tsx index 9e3c995..f8a8f70 100644 --- a/src/cli/components/StatusBar.tsx +++ b/src/cli/components/StatusBar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Text } from 'ink'; +import { Box, Text } from 'ink'; import type { ProviderSessionState } from '../../mcp/types.js'; interface StatusBarProps { @@ -12,6 +12,7 @@ interface StatusBarProps { contextTotal?: number; contextThreshold?: number; contextSource?: string; + memoryActivity?: string; } export function StatusBar({ @@ -24,40 +25,33 @@ export function StatusBar({ contextTotal, contextThreshold, contextSource, + memoryActivity, }: StatusBarProps) { - const agoraLabel = provider?.toLowerCase() === 'agora' - ? formatAgoraStatus(model, providerState) - : ''; + const isAgora = provider?.toLowerCase() === 'agora'; let ctxLabel = ''; + let ctxColor: 'red' | 'yellow' | undefined; if (contextUsed != null && contextTotal && contextTotal > 0) { const threshold = contextThreshold && contextThreshold > 0 ? contextThreshold : contextTotal; const pct = Math.round((contextUsed / threshold) * 100); - const color = pct > 100 ? 'red' : pct > 80 ? 'yellow' : undefined; + ctxColor = pct > 100 ? 'red' : pct > 80 ? 'yellow' : undefined; const source = contextSource ? ` ${contextSource}` : ''; ctxLabel = ` · ctx: ${formatK(contextUsed)}/${formatK(threshold)} trigger · win ${formatK(contextTotal)}${source}`; - if (color) { - return ( - - {' '}Ctrl+V 图片 · ESC 中断 · 双击 ESC 切会话 · /quit 退出 - {taskCount ? ` · tasks: ${taskCount}` : ''} - {agoraLabel} - {ctxLabel} - {debug ? ' · 🔧 debug' : ''} - - ); - } + if (ctxColor) ctxLabel = ` · ${pct > 100 ? 'context risk' : 'context warning'}${ctxLabel}`; } return ( - - {' '}Ctrl+V 图片 · ESC 中断 · 双击 ESC 切会话 · /quit 退出 - {taskCount ? ` · tasks: ${taskCount}` : ''} - {agoraLabel} - {ctxLabel} - {debug ? ' · 🔧 debug' : ''} - + + + {' '}Provider: {provider || 'openai'} · Model: {shortText(model)} + {ctxColor ? {ctxLabel} : ctxLabel} + {taskCount ? ` · tasks: ${taskCount}` : ''} + {debug ? ' · 🔧 debug' : ''} + + {isAgora ? {formatAgoraMemoryLine(providerState, memoryActivity)} : null} + {' '}Ctrl+V 图片 · ESC 中断 · 双击 ESC 切会话 · /memory 记忆 · /quit 退出 + ); } @@ -73,14 +67,7 @@ function shortText(value: string | undefined, max = 24): string { return `${text.slice(0, max - 1)}…`; } -function shortId(value: string | undefined): string { - const text = value?.trim(); - if (!text) return '-'; - if (text.length <= 12) return text; - return `${text.slice(0, 6)}…${text.slice(-3)}`; -} - -function formatAgoraStatus(model: string, state?: ProviderSessionState | null): string { +function formatAgoraMemoryLine(state?: ProviderSessionState | null, activity?: string): string { const memory = state?.memory; const status = typeof memory?.status === 'string' && memory.status ? memory.status @@ -88,6 +75,8 @@ function formatAgoraStatus(model: string, state?: ProviderSessionState | null): const patches = Array.isArray(memory?.active_memory_patch_ids) ? memory.active_memory_patch_ids.length : 0; - const patchLabel = patches > 0 ? `(${patches})` : ''; - return ` · agora:${shortText(model)} · mem ${status}${patchLabel} · sess ${shortId(state?.agora_session_id)}`; + const profile = typeof memory?.profile_name === 'string' + ? memory.profile_name + : typeof memory?.profile_id === 'string' ? memory.profile_id : '未选择 Profile'; + return ` Memory: ${shortText(profile, 32)} · ${patches} patches · ${status}${activity ? ` · ${activity}` : ''}`; } diff --git a/src/cli/utils/commands.ts b/src/cli/utils/commands.ts index 59022da..1d7c05f 100644 --- a/src/cli/utils/commands.ts +++ b/src/cli/utils/commands.ts @@ -13,6 +13,7 @@ interface CommandContext { exit: () => void; setModel?: (model: string) => void; openModelPicker?: () => Promise | void; + openMemoryConsole?: () => Promise | void; switchModelChoice?: (choice: ModelChoice) => void; revertLastTurn?: () => boolean; } @@ -249,12 +250,94 @@ commands.set('/model', { }, }); +commands.set('/memory', { + description: 'Open Agora Memory console or manage profiles', + suggest: true, + handler: async (args, ctx) => { + const controller = ctx.agent.getMemoryController?.(); + if (!controller) return 'Agora memory requires the Agora provider.'; + const trimmed = args.trim(); + if (!trimmed) { + await ctx.openMemoryConsole?.(); + return ctx.openMemoryConsole ? null : 'Memory console is unavailable in this UI.'; + } + try { + if (trimmed === 'list') { + const profiles = await controller.listProfiles(); + if (profiles.length === 0) return 'No Agora MemoryProfiles.'; + return profiles.map((profile) => { + const writable = profile.writable_patch_family ? ` · writable=${profile.writable_patch_family}` : ''; + const auto = profile.auto_intake_policy?.enabled ? ' · auto=on' : ' · auto=off'; + return `${profile.id} ${profile.name} · ${profile.active_memory_patch_ids.length} patches${writable}${auto}`; + }).join('\n'); + } + if (trimmed === 'status') return (await controller.status()).content; + const newMatch = trimmed.match(/^new\s+(.+)$/); + if (newMatch) { + const name = newMatch[1].trim(); + const id = `ma-${name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-').replace(/^-|-$/g, '') || 'memory'}-${Date.now().toString(36)}`; + return JSON.stringify(await controller.createProfile({ profile_id: id, name }), null, 2); + } + const renameMatch = trimmed.match(/^rename\s+(\S+)\s+(.+)$/); + if (renameMatch) { + return JSON.stringify(await controller.renameProfile(renameMatch[1], renameMatch[2].trim()), null, 2); + } + const useMatch = trimmed.match(/^use\s+(\S+)(?:\s+(--session))?$/); + if (useMatch) { + return JSON.stringify( + await controller.selectProfile(useMatch[1], useMatch[2] ? 'conversation' : 'project'), + null, + 2 + ); + } + const intakeMatch = trimmed.match(/^internalize(?:\s+--into\s+(\S+))?$/); + if (intakeMatch) { + const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; + if (!profileId) return 'No verified MemoryProfile is selected.'; + const result = await controller.startIntake({ profile_id: profileId, into: intakeMatch[1] }); + return `Memory intake queued: ${result.job_id ?? result.job?.id}`; + } + const autoMatch = trimmed.match(/^auto\s+(on|off)$/); + if (autoMatch) { + const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; + if (!profileId) return 'No verified MemoryProfile is selected.'; + return JSON.stringify(await controller.setAutoPolicy(profileId, autoMatch[1] === 'on'), null, 2); + } + if (trimmed === 'history') { + const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; + const profiles = await controller.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + if (!profile?.writable_patch_family) return 'Current profile has no writable memory family.'; + const patches = await controller.listPatches(true); + return patches + .filter((patch) => patch.family === profile.writable_patch_family) + .map((patch) => `${patch.id} ${patch.name} · ${patch.version} · ${patch.status}`) + .join('\n') || 'No versions found.'; + } + const rollbackMatch = trimmed.match(/^rollback\s+(\S+)$/); + if (rollbackMatch) { + const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; + if (!profileId) return 'No verified MemoryProfile is selected.'; + return (await controller.rollback({ profile_id: profileId, patch_id: rollbackMatch[1] })).content; + } + if (trimmed === 'disable') { + const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; + if (!profileId) return 'No verified MemoryProfile is selected.'; + return (await controller.disable({ profile_id: profileId })).content; + } + return 'usage: /memory | list | new | rename | use [--session] | status | internalize [--into ] | auto on|off | history | rollback | disable'; + } catch (err) { + return `Memory error: ${(err as Error).message}`; + } + }, +}); + commands.set('/skills', { description: 'List all available skills', handler: async (_, ctx) => { await loadSkills(); const skillCommands = Array.from(commands.entries()) - .filter(([name, cmd]) => name.startsWith('/') && !['/quit', '/exit', '/tools', '/stack', '/abort', '/archive', '/context', '/clear', '/help', '/revert', '/undo', '/models', '/model', '/skills'].includes(name)); + .filter(([name, cmd]) => name.startsWith('/') && !['/quit', '/exit', '/tools', '/stack', '/abort', '/archive', '/context', '/clear', '/help', '/revert', '/undo', '/models', '/model', '/memory', '/skills'].includes(name)); if (skillCommands.length === 0) { return 'No custom skills found. Create skills in .ma/skills/ directory.'; diff --git a/src/cli/utils/modelProfiles.ts b/src/cli/utils/modelProfiles.ts index 44e9e63..1925769 100644 --- a/src/cli/utils/modelProfiles.ts +++ b/src/cli/utils/modelProfiles.ts @@ -13,6 +13,7 @@ export interface ModelChoice { label: string; current: boolean; source: 'remote' | 'cache' | 'config'; + status?: string; } function unique(values: string[]): string[] { diff --git a/src/mcp/types.ts b/src/mcp/types.ts index ef4e0e8..ae8c7ea 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -170,6 +170,7 @@ export interface Agent { revertLastTurnContextOnly(): number; respondConfirm(requestId: string, approved: boolean): void; getProviderState?(): ProviderSessionState | null; + getMemoryController?(): import('../provider/agora.js').AgoraMemoryController | null; getContextUsage(): { used: number; total: number; compactThreshold: number; source: string }; inspectContext(): string; searchContext(query: string): SessionPoolEntry[]; diff --git a/src/provider/agora.ts b/src/provider/agora.ts index 6683fcb..04f6f7a 100644 --- a/src/provider/agora.ts +++ b/src/provider/agora.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; import type { ChatCompletion, ChatCompletionCreateParamsNonStreaming, @@ -31,7 +32,61 @@ export interface AgoraMemoryToolResult { isError: boolean; } +export interface AgoraCapabilities { + chat: boolean; + modelCatalog: boolean; + modelDownload: boolean; + profileRead: boolean; + profileWrite: boolean; + patchCatalog: boolean; + mount: boolean; + intake: boolean; + lineageCas: boolean; + rollback: boolean; + progress: boolean; +} + +export interface AgoraMemoryProfile { + id: string; + name: string; + base_model_id: string; + active_memory_patch_ids: string[]; + writable_patch_family?: string | null; + auto_intake_policy?: { + enabled?: boolean; + min_user_turns?: number; + min_pending_tokens?: number; + idle_seconds?: number; + activation_mode?: 'auto' | 'review'; + }; + memory_enabled?: boolean; + status?: string; +} + +export interface AgoraMemoryPatch { + id: string; + name: string; + base_model_id: string; + family: string; + version: string; + mountable: boolean; + status: string; +} + export interface AgoraMemoryController { + getCapabilities(): AgoraCapabilities; + listProfiles(): Promise; + listPatches(includeDisabled?: boolean): Promise; + createProfile(args: Record): Promise>; + renameProfile(profileId: string, name: string): Promise>; + selectProfile(profileId: string, scope?: 'project' | 'conversation'): Promise>; + applyPatchSelection(profileId: string, patchIds: string[], writableFamily?: string | null): Promise>; + getIntakeStatus(): Promise>; + startIntake(args?: Record): Promise>; + finalizeIntake(jobId: string, profileId: string): Promise>; + setAutoPolicy(profileId: string, enabled: boolean): Promise>; + listModels(): Promise[]>; + downloadModel(modelId: string, onProgress?: (event: McpProgressEvent) => void): Promise>; status(args?: Record): Promise; mount(args: Record): Promise; disable(args: Record): Promise; @@ -40,29 +95,11 @@ export interface AgoraMemoryController { } const REQUIRED_TOOLS = new Set(['doctor', 'models_list', 'chat_complete']); -const REQUIRED_MEMORY_TOOLS = new Set([ - 'memory_profiles_list', - 'memory_profiles_create', - 'memory_profiles_update', - 'memory_profile_bindings_list', - 'memory_profile_bindings_create', - 'memory_sources_create', - 'memory_sources_list', - 'memory_sources_get', - 'memory_intake_run', - 'memory_intake_get', - 'memory_patch_versions', -]); - const TERMINAL_INTAKE_STATUSES = new Set(['completed', 'failed']); const DEFAULT_AGORA_MAX_TOKENS = 4096; const AGORA_TIMEOUT_CAP_SECONDS = 300; const AGORA_TIMEOUT_HEADROOM_MS = 5000; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function stringifyArgs(value: unknown): string { if (typeof value === 'string') return value; if (value === undefined) return '{}'; @@ -117,25 +154,57 @@ function bundledAgoraCandidates(): string[] { ]; } -function resolveAgoraCommand(runtime?: AgoraRuntimeConfig): { command: string; args: string[] } { +interface ResolvedAgoraCommand { + command: string; + args: string[]; + trust: 'verified' | 'unverified'; + source: 'configured' | 'override' | 'bundled' | 'path'; + lock?: Record; +} + +function sha256File(file: string): string { + return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +function verifyBundledAgora(command: string): Record | null { + const root = path.dirname(path.dirname(command)); + const manifestPath = path.join(root, 'manifest.json'); + const lockPath = path.join(root, 'runtime-lock.json'); + if (!fs.existsSync(manifestPath) || !fs.existsSync(lockPath)) return null; + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + const expected = manifest?.files?.['bin/agora']; + if (typeof expected !== 'string' || sha256File(command) !== expected) return null; + if (manifest.version !== lock.version || manifest.host_protocol_major !== lock.host_protocol_major) return null; + if (lock.manifest_sha256 && sha256File(manifestPath) !== lock.manifest_sha256) return null; + return lock; + } catch { + return null; + } +} + +function resolveAgoraCommand(runtime?: AgoraRuntimeConfig): ResolvedAgoraCommand { const configured = runtime?.command?.trim(); - if (configured) return { command: configured, args: runtime?.args ?? ['mcp', 'serve'] }; + if (configured) return { command: configured, args: runtime?.args ?? ['mcp', 'serve'], trust: 'unverified', source: 'configured' }; const envCommand = process.env.MA_AGORA_COMMAND?.trim(); - if (envCommand) return { command: envCommand, args: runtime?.args ?? ['mcp', 'serve'] }; + if (envCommand) return { command: envCommand, args: runtime?.args ?? ['mcp', 'serve'], trust: 'unverified', source: 'override' }; for (const candidate of bundledAgoraCandidates()) { - if (commandExists(candidate)) return { command: candidate, args: ['mcp', 'serve'] }; + if (!commandExists(candidate)) continue; + const lock = verifyBundledAgora(candidate); + if (!lock) throw new Error(`Bundled Agora failed integrity verification: ${candidate}`); + return { command: candidate, args: ['mcp', 'serve'], trust: 'verified', source: 'bundled', lock }; } - const pathAgora = findOnPath('agora'); - if (pathAgora) return { command: pathAgora, args: ['mcp', 'serve'] }; - - const devFallback = '/Users/zhuqingyu/dev/agora/.venv/bin/agora'; - if (commandExists(devFallback)) return { command: devFallback, args: ['mcp', 'serve'] }; + if (process.env.MA_AGORA_ALLOW_UNVERIFIED === '1') { + const pathAgora = findOnPath('agora'); + if (pathAgora) return { command: pathAgora, args: ['mcp', 'serve'], trust: 'unverified', source: 'path' }; + } throw new Error( - 'Agora provider requires an Agora runtime command. Set model.agoraRuntime.command or MA_AGORA_COMMAND.' + 'Agora runtime is not installed in the verified MA bundle. Set MA_AGORA_COMMAND only for an explicit unverified development override.' ); } @@ -156,8 +225,16 @@ function safeUserId(): string { } function defaultProjectId(cwd?: string): string { - const base = path.basename(cwd || process.cwd()).trim(); - return base || 'my-agent'; + const requested = path.resolve(cwd || process.cwd()); + let canonical = requested; + try { + canonical = fs.realpathSync.native(requested); + } catch { + // Keep the normalized absolute path for a project that is not mounted yet. + } + const base = path.basename(canonical).trim() || 'my-agent'; + const suffix = createHash('sha256').update(canonical).digest('hex').slice(0, 12); + return `${base}-${suffix}`; } function toStringList(value: unknown): string[] { @@ -354,6 +431,8 @@ export class AgoraProviderRuntime implements AgoraMemoryController { private selectedProfileId: string | null = null; private doctorPayload: Record | null = null; private modelsPayload: Record | null = null; + private runtimeTrust: 'verified' | 'unverified' = 'unverified'; + private runtimeSource = 'unknown'; constructor( private readonly model: ModelConfig, @@ -370,10 +449,25 @@ export class AgoraProviderRuntime implements AgoraMemoryController { } memoryReady(): boolean { - for (const name of REQUIRED_MEMORY_TOOLS) { - if (!this.toolNames.has(name)) return false; - } - return true; + const capabilities = this.getCapabilities(); + return capabilities.profileRead && capabilities.profileWrite && capabilities.mount; + } + + getCapabilities(): AgoraCapabilities { + const has = (name: string) => this.toolNames.has(name); + return { + chat: has('chat_complete'), + modelCatalog: has('models_list') && has('models_status'), + modelDownload: has('models_download'), + profileRead: has('memory_profiles_list'), + profileWrite: has('memory_profiles_create') && has('memory_profiles_update'), + patchCatalog: has('memory_patches_list'), + mount: has('memory_profile_bindings_list') && has('memory_profile_bindings_create'), + intake: has('memory_intake_run') && has('memory_intake_get') && has('memory_intake_status'), + lineageCas: has('memory_lineage_advance'), + rollback: has('memory_patch_versions'), + progress: true, + }; } getProviderState(): ProviderSessionState | null { @@ -381,7 +475,158 @@ export class AgoraProviderRuntime implements AgoraMemoryController { } getMemoryController(): AgoraMemoryController | null { - return this.memoryReady() ? this : null; + return this.toolNames.has('chat_complete') ? this : null; + } + + async listProfiles(): Promise { + this.requireTools('memory_profiles_list'); + const payload = await this.callJsonTool('memory_profiles_list', { base_model_id: this.model.model }); + return Array.isArray(payload.profiles) ? payload.profiles as AgoraMemoryProfile[] : []; + } + + async listPatches(includeDisabled = false): Promise { + this.requireTools('memory_patches_list'); + const payload = await this.callJsonTool('memory_patches_list', { + base_model_id: this.model.model, + include_disabled: includeDisabled, + }); + return Array.isArray(payload.patches) ? payload.patches as AgoraMemoryPatch[] : []; + } + + async createProfile(args: Record): Promise> { + this.requireTools('memory_profiles_create'); + const profileId = this.resolveProfileId(args); + const payload = await this.callJsonTool('memory_profiles_create', { + id: profileId, + name: typeof args.name === 'string' && args.name.trim() ? args.name.trim() : profileId, + base_model_id: this.model.model, + active_memory_patch_ids: this.patchIdsFromArgs(args), + writable_patch_family: typeof args.writable_patch_family === 'string' ? args.writable_patch_family : undefined, + auto_intake_policy: args.auto_intake_policy ?? { enabled: false }, + memory_enabled: args.memory_enabled !== false, + }); + await this.ensureBinding(profileId, args.scope === 'conversation' ? 'conversation' : 'project'); + this.selectedProfileId = profileId; + return payload; + } + + async renameProfile(profileId: string, name: string): Promise> { + this.requireTools('memory_profiles_update'); + return this.callJsonTool('memory_profiles_update', { profile_id: profileId, name }); + } + + async selectProfile( + profileId: string, + scope: 'project' | 'conversation' = 'project' + ): Promise> { + const profiles = await this.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error(`memory profile not found: ${profileId}`); + await this.ensureBinding(profileId, scope); + this.selectedProfileId = profileId; + return { profile, scope, mount_status: 'pending_next_chat' }; + } + + async applyPatchSelection( + profileId: string, + patchIds: string[], + writableFamily?: string | null + ): Promise> { + this.requireTools('memory_profiles_update'); + const updated = await this.callJsonTool('memory_profiles_update', { + profile_id: profileId, + active_memory_patch_ids: patchIds, + writable_patch_family: writableFamily ?? null, + memory_enabled: true, + }); + await this.ensureBinding(profileId, 'project'); + this.selectedProfileId = profileId; + return { ...updated, mount_status: 'pending_next_chat' }; + } + + async getIntakeStatus(): Promise> { + this.requireTools('memory_intake_status'); + const sessionId = this.requireSessionId(); + return this.callJsonTool('memory_intake_status', { session_id: sessionId }); + } + + async startIntake(args: Record = {}): Promise> { + this.requireTools('memory_intake_run'); + const sessionId = this.requireSessionId(); + const profileId = this.resolveProfileId(args); + const profiles = await this.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error(`memory profile not found: ${profileId}`); + const family = typeof args.into === 'string' && args.into.trim() + ? args.into.trim() + : typeof args.writable_patch_family === 'string' && args.writable_patch_family.trim() + ? args.writable_patch_family.trim() + : profile.writable_patch_family?.trim(); + if (!family) throw new Error('memory intake requires a writable memory module; use --into '); + const patches = await this.listPatches(true); + const expected = profile.active_memory_patch_ids.find( + (id) => patches.find((patch) => patch.id === id)?.family === family + ); + return this.callJsonTool('memory_intake_run', { + session_id: sessionId, + profile_id: profileId, + writable_patch_family: family, + ...(expected ? { expected_previous_patch_id: expected } : {}), + }); + } + + async finalizeIntake(jobId: string, profileId: string): Promise> { + this.requireTools('memory_intake_get'); + const current = await this.callJsonTool('memory_intake_get', { job_id: jobId }); + if (!TERMINAL_INTAKE_STATUSES.has(String(current.status))) return current; + if (String(current.status) === 'failed') throw new Error(errorText(current)); + const job = asRecord(current.job) ?? current; + const patchId = typeof job.output_memory_patch_id === 'string' + ? job.output_memory_patch_id + : typeof current.output_memory_patch_id === 'string' ? current.output_memory_patch_id : undefined; + if (!patchId) return { ...current, outcome: 'noop' }; + const profiles = await this.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error(`memory profile not found: ${profileId}`); + if (profile.auto_intake_policy?.activation_mode === 'review') { + return { ...current, outcome: 'review_required' }; + } + this.requireTools('memory_lineage_advance'); + const lineage = asRecord(job.result)?.lineage ?? asRecord(current.result)?.lineage ?? {}; + const advanced = await this.callJsonTool('memory_lineage_advance', { + profile_id: profileId, + family: lineage.family ?? profile.writable_patch_family, + expected_previous_patch_id: lineage.previous_patch_id ?? null, + new_patch_id: patchId, + session_id: this.requireSessionId(), + job_id: jobId, + }); + return { ...advanced, outcome: 'activated', mount_status: 'pending_next_chat' }; + } + + async setAutoPolicy(profileId: string, enabled: boolean): Promise> { + this.requireTools('memory_profiles_update'); + const profiles = await this.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error(`memory profile not found: ${profileId}`); + return this.callJsonTool('memory_profiles_update', { + profile_id: profileId, + auto_intake_policy: { ...profile.auto_intake_policy, enabled }, + }); + } + + async listModels(): Promise[]> { + this.requireTools('models_list'); + const payload = await this.callJsonTool('models_list', {}); + return Array.isArray(payload.models) ? payload.models : []; + } + + async downloadModel( + modelId: string, + onProgress?: (event: McpProgressEvent) => void + ): Promise> { + this.requireTools('models_download'); + return this.callJsonTool('models_download', { model_id: modelId }, undefined, true, onProgress); } async close(): Promise { @@ -417,10 +662,12 @@ export class AgoraProviderRuntime implements AgoraMemoryController { provider: 'agora', ready_tools: [...this.toolNames].sort(), resources: this.resources, - memory_capability_ready: this.memoryReady(), + capabilities: this.getCapabilities(), provider_state: this.lastState, doctor: this.doctorPayload, models: this.modelsPayload, + runtime_trust: this.runtimeTrust, + runtime_source: this.runtimeSource, }, null, 2 @@ -434,18 +681,27 @@ export class AgoraProviderRuntime implements AgoraMemoryController { this.ensureMemoryReady(); const profileId = this.resolveProfileId(args); const patchIds = this.patchIdsFromArgs(args); - await this.upsertProfile(profileId, patchIds.length > 0 ? patchIds : undefined, true); - const bindingId = await this.ensureBinding(profileId); - const verified = await this.verifyMemoryState({ + await this.upsertProfile( profileId, - expectedPatchIds: patchIds.length > 0 ? patchIds : undefined, - }); + patchIds.length > 0 ? patchIds : undefined, + true, + { + name: typeof args.name === 'string' ? args.name : undefined, + writablePatchFamily: typeof args.writable_patch_family === 'string' + ? args.writable_patch_family + : undefined, + } + ); + const bindingId = await this.ensureBinding( + profileId, + args.scope === 'conversation' ? 'conversation' : 'project' + ); this.selectedProfileId = profileId; return this.ok({ action: 'mount', profile_id: profileId, binding_id: bindingId, - provider_state: verified, + mount_status: 'pending_next_chat', }); } catch (err) { return this.fail(err); @@ -457,12 +713,11 @@ export class AgoraProviderRuntime implements AgoraMemoryController { this.ensureMemoryReady(); const profileId = this.resolveProfileId(args); await this.upsertProfile(profileId, undefined, false); - const verified = await this.verifyMemoryState({ profileId, expectedPatchIds: [] }); this.selectedProfileId = profileId; return this.ok({ action: 'disable', profile_id: profileId, - provider_state: verified, + mount_status: 'pending_next_chat', }); } catch (err) { return this.fail(err); @@ -471,53 +726,17 @@ export class AgoraProviderRuntime implements AgoraMemoryController { async internalize(args: Record): Promise { try { - this.ensureMemoryReady(); - const sessionId = this.lastState?.agora_session_id; - if (!sessionId) { - throw new Error('Agora session is not established yet; run one Agora chat turn before internalizing memory.'); - } - const submitted = await this.callJsonTool('memory_intake_run', { session_id: sessionId }); + const submitted = await this.startIntake(args); const jobId = typeof submitted.job_id === 'string' ? submitted.job_id : submitted.job?.id; if (typeof jobId !== 'string' || !jobId) throw new Error('memory_intake_run did not return job_id'); - let current = submitted; - for (let i = 0; i < 120; i++) { - if (TERMINAL_INTAKE_STATUSES.has(String(current.status))) break; - await sleep(500); - current = await this.callJsonTool('memory_intake_get', { job_id: jobId }); - } - if (String(current.status) === 'failed') { - throw new Error(errorText(current)); - } - const patchId = - typeof current.output_memory_patch_id === 'string' - ? current.output_memory_patch_id - : typeof current.job?.output_memory_patch_id === 'string' - ? current.job.output_memory_patch_id - : undefined; - if (!patchId) { - return this.ok({ - action: 'internalize', - status: 'noop', - job_id: jobId, - source_id: current.source_id ?? current.job?.source_id, - message: 'memory intake completed but did not produce a MemoryPatch', - job: current.job, - }); - } const profileId = this.resolveProfileId(args); - await this.upsertProfile(profileId, [patchId], true); - const bindingId = await this.ensureBinding(profileId); - const verified = await this.verifyMemoryState({ profileId, expectedPatchIds: [patchId] }); - this.selectedProfileId = profileId; return this.ok({ action: 'internalize', - status: 'mounted', + status: 'queued', profile_id: profileId, - binding_id: bindingId, job_id: jobId, - source_id: current.source_id ?? current.job?.source_id, - output_memory_patch_id: patchId, - provider_state: verified, + source_id: submitted.source_id ?? submitted.job?.source_id, + message: 'memory intake is running in the background; use /memory status to inspect progress', }); } catch (err) { return this.fail(err); @@ -534,16 +753,21 @@ export class AgoraProviderRuntime implements AgoraMemoryController { ? args.target_patch_id.trim() : await this.resolveRollbackPatchId(); if (!targetPatchId) throw new Error('rollback requires patch_id when there is no single previous patch candidate'); - await this.upsertProfile(profileId, [targetPatchId], true); - const bindingId = await this.ensureBinding(profileId); - const verified = await this.verifyMemoryState({ profileId, expectedPatchIds: [targetPatchId] }); - this.selectedProfileId = profileId; + const [profiles, patches] = await Promise.all([this.listProfiles(), this.listPatches(true)]); + const profile = profiles.find((item) => item.id === profileId); + const target = patches.find((item) => item.id === targetPatchId); + if (!profile || !target) throw new Error('rollback profile or target patch not found'); + const nextIds = profile.active_memory_patch_ids.map((id) => { + const patch = patches.find((item) => item.id === id); + return patch?.family === target.family ? targetPatchId : id; + }); + if (!nextIds.includes(targetPatchId)) nextIds.push(targetPatchId); + const result = await this.applyPatchSelection(profileId, nextIds, profile.writable_patch_family); return this.ok({ action: 'rollback', profile_id: profileId, - binding_id: bindingId, - active_memory_patch_ids: [targetPatchId], - provider_state: verified, + active_memory_patch_ids: nextIds, + mount_status: result.mount_status, }); } catch (err) { return this.fail(err); @@ -553,6 +777,8 @@ export class AgoraProviderRuntime implements AgoraMemoryController { private async start(): Promise { const runtime = this.model.agoraRuntime; const command = resolveAgoraCommand(runtime); + this.runtimeTrust = command.trust; + this.runtimeSource = command.source; const dataRoot = resolveAgoraDataRoot(runtime); fs.mkdirSync(dataRoot, { recursive: true }); const child = spawn(command.command, command.args, { @@ -579,6 +805,17 @@ export class AgoraProviderRuntime implements AgoraMemoryController { this.resources = []; } this.doctorPayload = await this.callJsonTool('doctor', {}, undefined, false); + if (command.trust === 'verified') { + const expectedVersion = command.lock?.version; + const actualVersion = this.doctorPayload.version; + const actualMajor = this.doctorPayload.contract?.host_protocol_major; + if (actualVersion !== expectedVersion || actualMajor !== command.lock?.host_protocol_major) { + throw new Error( + `Agora runtime contract mismatch: expected ${expectedVersion}/host-v${command.lock?.host_protocol_major}, ` + + `got ${actualVersion}/host-v${actualMajor}` + ); + } + } this.modelsPayload = await this.callJsonTool('models_list', {}, undefined, false); } catch (err) { await this.close(); @@ -592,6 +829,21 @@ export class AgoraProviderRuntime implements AgoraMemoryController { } } + private requireTools(...names: string[]): void { + const missing = names.filter((name) => !this.toolNames.has(name)); + if (missing.length > 0) { + throw new Error(`Agora capability unavailable: ${missing.join(', ')}`); + } + } + + private requireSessionId(): string { + const sessionId = this.lastState?.agora_session_id; + if (!sessionId) { + throw new Error('Agora session is not established yet; run one Agora chat turn first.'); + } + return sessionId; + } + private async callJsonTool( toolName: string, args: Record, @@ -602,6 +854,9 @@ export class AgoraProviderRuntime implements AgoraMemoryController { if (waitForReady) await this.ready(); if (!this.mcp) throw new Error('Agora MCP runtime is not running'); const result = await this.mcp.call(toolName, args, signal, onProgress); + if (result.isError) { + throw new Error(result.content || `Agora MCP tool ${toolName} failed`); + } let payload: unknown; try { payload = JSON.parse(result.content || '{}'); @@ -612,7 +867,7 @@ export class AgoraProviderRuntime implements AgoraMemoryController { throw new Error(`Agora MCP tool ${toolName} returned invalid JSON payload`); } const record = payload as Record; - if (result.isError || isErrorPayload(record)) throw new Error(errorText(record)); + if (isErrorPayload(record)) throw new Error(errorText(record)); return record; } @@ -644,6 +899,8 @@ export class AgoraProviderRuntime implements AgoraMemoryController { : undefined ); this.lastState = stateFromChatPayload(payload); + this.lastState.runtime_trust = this.runtimeTrust; + this.lastState.runtime_source = this.runtimeSource; return payload; } @@ -749,7 +1006,8 @@ export class AgoraProviderRuntime implements AgoraMemoryController { private async upsertProfile( profileId: string, activePatchIds: string[] | undefined, - memoryEnabled: boolean + memoryEnabled: boolean, + options: { name?: string; writablePatchFamily?: string | null } = {} ): Promise { const listed = await this.callJsonTool('memory_profiles_list', { base_model_id: this.model.model }); const profiles = Array.isArray(listed.profiles) ? listed.profiles : []; @@ -758,6 +1016,10 @@ export class AgoraProviderRuntime implements AgoraMemoryController { await this.callJsonTool('memory_profiles_update', { profile_id: profileId, ...(activePatchIds !== undefined ? { active_memory_patch_ids: activePatchIds } : {}), + ...(options.name ? { name: options.name } : {}), + ...(options.writablePatchFamily !== undefined + ? { writable_patch_family: options.writablePatchFamily } + : {}), memory_enabled: memoryEnabled, }); return; @@ -767,61 +1029,40 @@ export class AgoraProviderRuntime implements AgoraMemoryController { } await this.callJsonTool('memory_profiles_create', { id: profileId, + name: options.name || profileId, base_model_id: this.model.model, active_memory_patch_ids: activePatchIds, + ...(options.writablePatchFamily !== undefined + ? { writable_patch_family: options.writablePatchFamily } + : {}), + auto_intake_policy: { enabled: false }, memory_enabled: memoryEnabled, }); } - private async ensureBinding(profileId: string): Promise { + private async ensureBinding( + profileId: string, + scope: 'project' | 'conversation' = 'project' + ): Promise { const metadata = this.buildMetadata(undefined, { memory_profile: profileId }); const listed = await this.callJsonTool('memory_profile_bindings_list', { profile_id: profileId }); const bindings = Array.isArray(listed.bindings) ? listed.bindings : []; - const match = bindings.find((binding: any) => - binding?.user_id === metadata.user_id && - binding?.project_id === metadata.project_id && - binding?.conversation_id === metadata.conversation_id - ); + const match = bindings.find((binding: any) => { + if (binding?.scope_type !== scope || binding?.user_id !== metadata.user_id) return false; + if (binding?.project_id !== metadata.project_id) return false; + return scope === 'project' || binding?.conversation_id === metadata.conversation_id; + }); if (match?.id) return String(match.id); const created = await this.callJsonTool('memory_profile_bindings_create', { profile_id: profileId, + scope_type: scope, user_id: metadata.user_id, project_id: metadata.project_id, - conversation_id: metadata.conversation_id, + ...(scope === 'conversation' ? { conversation_id: metadata.conversation_id } : {}), }); return typeof created.binding?.id === 'string' ? created.binding.id : undefined; } - private async verifyMemoryState(input: { - profileId: string; - expectedPatchIds?: string[]; - }): Promise { - await this.chatCompletePayload( - { - model: this.model.model, - messages: [{ role: 'user', content: 'Verify Agora memory state.' }], - stream: false, - max_tokens: 4, - } as ChatCompletionCreateParamsNonStreaming, - undefined, - { memory_profile: input.profileId } - ); - const state = this.lastState; - if (!state) throw new Error('Agora verification did not produce provider state'); - if (state.memory?.profile_id !== input.profileId) { - throw new Error(`Agora verification did not mount requested profile ${input.profileId}`); - } - if (input.expectedPatchIds !== undefined) { - const actual = state.memory?.active_memory_patch_ids ?? []; - if (JSON.stringify(actual) !== JSON.stringify(input.expectedPatchIds)) { - throw new Error( - `Agora verification patch mismatch: expected ${JSON.stringify(input.expectedPatchIds)}, got ${JSON.stringify(actual)}` - ); - } - } - return state; - } - private async resolveRollbackPatchId(): Promise { const current = this.lastState?.memory?.active_memory_patch_ids?.[0]; if (!current) return null; diff --git a/test/agora-provider-runtime.test.ts b/test/agora-provider-runtime.test.ts index cd2dacd..70c5d93 100644 --- a/test/agora-provider-runtime.test.ts +++ b/test/agora-provider-runtime.test.ts @@ -7,9 +7,10 @@ import * as path from 'node:path'; import { AgoraProviderRuntime } from '../src/provider/agora.js'; import type { ModelConfig } from '../src/mcp/types.js'; -const AGORA_DEV_ROOT = '/Users/zhuqingyu/dev/agora'; -const AGORA_DEV_COMMAND = path.join(AGORA_DEV_ROOT, '.venv/bin/agora'); -const AGORA_DEV_PYTHON = path.join(AGORA_DEV_ROOT, '.venv/bin/python'); +const AGORA_DEV_ROOT = process.env.MA_TEST_AGORA_ROOT || ''; +const AGORA_DEV_COMMAND = process.env.MA_TEST_AGORA_COMMAND || ''; +const AGORA_DEV_PYTHON = process.env.MA_TEST_AGORA_PYTHON || ''; +const AGORA_REAL_MODELS = process.env.MA_TEST_AGORA_REAL_MODELS || ''; const MEMORY_TOOLS = [ 'doctor', @@ -25,6 +26,9 @@ const MEMORY_TOOLS = [ 'memory_sources_get', 'memory_intake_run', 'memory_intake_get', + 'memory_intake_status', + 'memory_patches_list', + 'memory_lineage_advance', 'memory_patch_versions', ]; @@ -105,7 +109,31 @@ function runtimeWithFakeCalls( active_memory_patch_ids: patchIds, }; } - if (name === 'memory_profiles_list') return { service: 'agora', status: 'ok', profiles: [] }; + if (name === 'memory_profiles_list') return { + service: 'agora', + status: 'ok', + profiles: [{ + id: 'profile-a', + name: 'Profile A', + base_model_id: 'base-a', + active_memory_patch_ids: ['patch-a'], + writable_patch_family: 'project-memory', + auto_intake_policy: { enabled: true, activation_mode: 'auto' }, + }], + }; + if (name === 'memory_patches_list') return { + service: 'agora', + status: 'ok', + patches: [{ + id: 'patch-a', + name: 'Patch A', + base_model_id: 'base-a', + family: 'project-memory', + version: 'v1', + mountable: true, + status: 'available', + }], + }; if (name === 'memory_profiles_create') return { service: 'agora', status: 'available', profile: { id: args.id } }; if (name === 'memory_profiles_update') return { service: 'agora', status: 'available', profile: { id: args.profile_id } }; if (name === 'memory_profile_bindings_list') return { service: 'agora', status: 'ok', bindings: [] }; @@ -117,7 +145,11 @@ function runtimeWithFakeCalls( job_id: 'job-a', source_id: 'source-a', output_memory_patch_id: 'patch-a', - job: { id: 'job-a', output_memory_patch_id: 'patch-a' }, + job: { + id: 'job-a', + output_memory_patch_id: 'patch-a', + result: { lineage: { family: 'project-memory', previous_patch_id: 'patch-a' } }, + }, }; } if (name === 'memory_intake_get') { @@ -127,9 +159,16 @@ function runtimeWithFakeCalls( job_id: args.job_id, source_id: 'source-a', output_memory_patch_id: 'patch-a', - job: { id: args.job_id, output_memory_patch_id: 'patch-a' }, + job: { + id: args.job_id, + output_memory_patch_id: 'patch-a', + result: { lineage: { family: 'project-memory', previous_patch_id: 'patch-a' } }, + }, }; } + if (name === 'memory_lineage_advance') { + return { service: 'agora', status: 'activated', active_memory_patch_ids: ['patch-a'] }; + } if (name === 'memory_patch_versions') return { service: 'agora', status: 'ok', patches: [{ id: 'patch-a' }, { id: 'patch-previous' }] }; return { service: 'agora', status: 'ok' }; }; @@ -444,12 +483,15 @@ test('agora provider runtime wraps non-streaming chat_complete as streaming chun assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-a']); }); -test('agora provider runtime disables memory controller when required memory MCP tools are missing', async () => { +test('agora provider runtime exposes granular capability gaps without disabling chat', async () => { const runtime = runtimeWithFakeCalls([], ['doctor', 'models_list', 'chat_complete']); - assert.equal(runtime.getMemoryController(), null); + assert.ok(runtime.getMemoryController()); + assert.equal(runtime.getCapabilities().chat, true); + assert.equal(runtime.getCapabilities().profileRead, false); + assert.equal(runtime.getCapabilities().intake, false); }); -test('agora memory internalize runs intake, updates profile, binds scope, and verifies via chat metadata', async () => { +test('agora memory internalize queues intake without blocking or overwriting overlays', async () => { const calls: Array<{ name: string; args: Record }> = []; const runtime = runtimeWithFakeCalls(calls); await runtime.createChatCompletion({ @@ -461,27 +503,99 @@ test('agora memory internalize runs intake, updates profile, binds scope, and ve const result = await runtime.internalize({ profile_id: 'profile-a' }); assert.equal(result.isError, false); const payload = JSON.parse(result.content); - assert.equal(payload.output_memory_patch_id, 'patch-a'); + assert.equal(payload.status, 'queued'); + assert.equal(payload.job_id, 'job-a'); assert.deepEqual( calls.map((call) => call.name), [ 'chat_complete', - 'memory_intake_run', 'memory_profiles_list', - 'memory_profiles_create', - 'memory_profile_bindings_list', - 'memory_profile_bindings_create', - 'chat_complete', + 'memory_patches_list', + 'memory_intake_run', ] ); - assert.equal(calls.at(-1)?.args.metadata.memory_profile, 'profile-a'); assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-a']); }); +test('agora profile switch persists project/session scope and waits for a real chat boundary', async () => { + const calls: Array<{ name: string; args: Record }> = []; + const runtime = runtimeWithFakeCalls(calls); + + const project = await runtime.selectProfile('profile-a'); + assert.equal(project.mount_status, 'pending_next_chat'); + assert.equal(calls.some((call) => call.name === 'chat_complete'), false); + assert.deepEqual( + calls.find((call) => call.name === 'memory_profile_bindings_create')?.args, + { + profile_id: 'profile-a', + scope_type: 'project', + user_id: 'user-a', + project_id: 'project-a', + } + ); + + calls.length = 0; + const conversation = await runtime.selectProfile('profile-a', 'conversation'); + assert.equal(conversation.scope, 'conversation'); + assert.equal(calls.some((call) => call.name === 'chat_complete'), false); + assert.equal( + calls.find((call) => call.name === 'memory_profile_bindings_create')?.args.conversation_id, + 'conv-a' + ); +}); + const agoraDevE2eTest = fs.existsSync(AGORA_DEV_COMMAND) && fs.existsSync(AGORA_DEV_PYTHON) ? test : test.skip; +const agoraPackagedE2eTest = fs.existsSync(AGORA_DEV_COMMAND) && fs.existsSync(AGORA_REAL_MODELS) + ? test + : test.skip; + +agoraPackagedE2eTest('packaged Agora serves a real whitelist model over MCP stdio', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agora-packaged-e2e-')); + const dataRoot = path.join(tmp, 'agora-data'); + fs.mkdirSync(dataRoot, { recursive: true }); + fs.symlinkSync(path.resolve(AGORA_REAL_MODELS), path.join(dataRoot, 'models')); + let runtime: AgoraProviderRuntime | null = null; + try { + runtime = new AgoraProviderRuntime( + { + provider: 'agora', + baseURL: 'mcp-stdio://agora', + apiKey: 'agora-mcp', + model: 'qwen2.5-7b-fp16', + agoraRuntime: { + command: AGORA_DEV_COMMAND, + args: ['mcp', 'serve'], + dataRoot, + env: { HF_HUB_OFFLINE: '1', TRANSFORMERS_OFFLINE: '1' }, + }, + agoraMemory: { + userId: 'user-packaged-e2e', + projectId: 'my-agent', + conversationId: 'conv-packaged-e2e', + }, + }, + { requestTimeoutMs: 30000, streamIdleTimeoutMs: 30000, maxRetries: 0 }, + { sessionId: 'ma-packaged-e2e-session', cwd: tmp } + ); + await runtime.ready(); + assert.ok((await runtime.listModels()).some((item) => item.id === 'qwen2.5-7b-fp16')); + await runtime.createChatCompletion({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '只回答OK' }], + stream: false, + max_tokens: 2, + temperature: 0, + } as any); + assert.ok(runtime.getProviderState()?.agora_session_id); + } finally { + await runtime?.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + agoraDevE2eTest('agora provider runtime uses real MCP stdio for mount, internalize, rollback, and disable', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agora-provider-e2e-')); const dataRoot = path.join(tmp, 'agora-data'); @@ -523,9 +637,11 @@ agoraDevE2eTest('agora provider runtime uses real MCP stdio for mount, internali const mounted = await controller.mount({ profile_id: 'profile-e2e', active_memory_patch_ids: ['patch-old'], + writable_patch_family: 'ma-e2e-memory', }); assert.equal(mounted.isError, false, mounted.content); - assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-old']); + assert.equal(JSON.parse(mounted.content).mount_status, 'pending_next_chat'); + assert.equal(runtime.getProviderState(), null, 'profile selection must not inject a synthetic verification turn'); await runtime.createChatCompletion({ model: 'qwen2.5-7b-fp16', @@ -538,16 +654,46 @@ agoraDevE2eTest('agora provider runtime uses real MCP stdio for mount, internali const internalized = await controller.internalize({ profile_id: 'profile-e2e' }); assert.equal(internalized.isError, false, internalized.content); const internalizedPayload = JSON.parse(internalized.content); - const patchId = internalizedPayload.output_memory_patch_id; + assert.equal(internalizedPayload.status, 'queued'); + let finalized: Record = {}; + for (let i = 0; i < 120; i++) { + finalized = await controller.finalizeIntake(internalizedPayload.job_id, 'profile-e2e'); + if (finalized.outcome) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const patchId = finalized.active_memory_patch_ids?.find((id: string) => id !== 'patch-old') + ?? finalized.profile?.active_memory_patch_ids?.find((id: string) => id !== 'patch-old'); assert.match(patchId, /^memory-intake-/); + assert.equal(finalized.mount_status, 'pending_next_chat'); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-old']); + await runtime.createChatCompletion({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '使用内化后的记忆继续。' }], + stream: false, + max_tokens: 8, + } as any); assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [patchId]); const rolledBack = await controller.rollback({ profile_id: 'profile-e2e', patch_id: 'patch-old' }); assert.equal(rolledBack.isError, false, rolledBack.content); + assert.equal(JSON.parse(rolledBack.content).mount_status, 'pending_next_chat'); + await runtime.createChatCompletion({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '回滚后继续。' }], + stream: false, + max_tokens: 8, + } as any); assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-old']); const disabled = await controller.disable({ profile_id: 'profile-e2e' }); assert.equal(disabled.isError, false, disabled.content); + assert.equal(JSON.parse(disabled.content).mount_status, 'pending_next_chat'); + await runtime.createChatCompletion({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '禁用记忆后继续。' }], + stream: false, + max_tokens: 8, + } as any); assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, []); assert.equal(runtime.getProviderState()?.memory?.status, 'disabled'); diff --git a/test/cli-ux.test.ts b/test/cli-ux.test.ts index 2adc475..7ea819b 100644 --- a/test/cli-ux.test.ts +++ b/test/cli-ux.test.ts @@ -25,6 +25,7 @@ test('CLI UX PTY: DeepSeek status bar uses provider capability instead of 33k fa configPath, JSON.stringify( { + defaultProfile: '', model: { provider: 'deepseek', baseURL: 'http://127.0.0.1:9/v1', @@ -65,6 +66,49 @@ test('CLI UX PTY: DeepSeek status bar uses provider capability instead of 33k fa } }); +test('CLI UX PTY: Agora keeps context usage and memory activity on separate readable lines', async (t) => { + if (process.env.MA_RUN_PTY_TESTS !== '1') { + t.skip('PTY UI verification runs only in the explicit MA_RUN_PTY_TESTS E2E lane'); + return; + } + if (!canSpawnPty()) { + t.skip('node-pty cannot spawn a basic /bin/echo process in this environment'); + return; + } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-cli-agora-ux-')); + const configPath = path.join(tmp, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify({ + defaultProfile: '', + model: { + provider: 'agora', + baseURL: 'mcp-stdio://agora', + model: 'qwen3.6-35b-a3b-q4', + apiKey: 'agora-mcp', + }, + mcpServers: {}, + }, null, 2)); + const tsxCli = path.join(repoRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'); + const child = pty.spawn(process.execPath, [tsxCli, 'src/cli/index.tsx', 'chat', '--config', configPath], { + name: 'xterm-256color', + cols: 80, + rows: 30, + cwd: repoRoot, + env: { ...process.env, NO_COLOR: '1' }, + }); + let output = ''; + child.onData((data) => { output += data; }); + try { + await waitFor(() => /Memory:\s+未选择 Profile/.test(stripAnsi(output)), 10_000); + const plain = stripAnsi(output); + assert.match(plain, /ctx:/); + assert.match(plain, /trigger/); + assert.match(plain, /Memory:\s+未选择 Profile/); + assert.doesNotMatch(plain, /sess\s/); + } finally { + child.kill('SIGINT'); + } +}); + async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { diff --git a/test/commands.test.ts b/test/commands.test.ts index 3fcc603..7a3102e 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -67,6 +67,28 @@ test('/model opens the model picker when no args are provided', async () => { }); }); +test('/memory opens the console and lists named writable profiles', async () => { + await withTempCwd(async () => { + let opened = 0; + const controller = { + listProfiles: async () => [{ + id: 'profile-a', + name: 'MA 核心记忆', + active_memory_patch_ids: ['patch-a', 'overlay-a'], + writable_patch_family: 'project-memory', + auto_intake_policy: { enabled: true }, + }], + }; + const agent = { getMemoryController: () => controller }; + assert.equal(await executeCommand('/memory', baseContext({ agent, openMemoryConsole: () => { opened++; } })), null); + assert.equal(opened, 1); + const listed = await executeCommand('/memory list', baseContext({ agent })); + assert.match(String(listed), /MA 核心记忆/); + assert.match(String(listed), /writable=project-memory/); + assert.match(String(listed), /auto=on/); + }); +}); + test('/context routes inspection, search, recall, and pin commands to the agent', async () => { await withTempCwd(async () => { const calls: string[] = []; @@ -124,7 +146,7 @@ test('slash suggestions only include user-facing commands', async () => { assert.deepEqual( Array.from(suggestions.keys()).sort(), - ['/clear', '/exit', '/help', '/model'] + ['/clear', '/exit', '/help', '/memory', '/model'] ); const all = await getAllCommands(); From 1b01d16ddd29570b16548e8bd6670552d72dfd28 Mon Sep 17 00:00:00 2001 From: zhuqingyv Date: Sun, 12 Jul 2026 14:58:28 +0800 Subject: [PATCH 2/5] fix: include postinstall in npm package --- package.json | 1 + test/cli-version.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/package.json b/package.json index 9fe5c67..9fb2b97 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "files": [ "dist", "bin", + "scripts/fix-node-pty.mjs", "README.md", "README.zh-CN.md", "LICENSE" diff --git a/test/cli-version.test.ts b/test/cli-version.test.ts index 348bd7d..f05ec14 100644 --- a/test/cli-version.test.ts +++ b/test/cli-version.test.ts @@ -12,3 +12,13 @@ test('CLI version is read from package.json', () => { assert.equal(VERSION, pkg.version); assert.notEqual(VERSION, '0.1.0-alpha.0'); }); + +test('npm package includes its postinstall entrypoint', () => { + const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) as { + files: string[]; + scripts: { postinstall?: string }; + }; + + assert.equal(pkg.scripts.postinstall, 'node scripts/fix-node-pty.mjs'); + assert.ok(pkg.files.includes('scripts/fix-node-pty.mjs')); +}); From 81477db9a909ce3a1c06e14c566cfdfd0b8481e9 Mon Sep 17 00:00:00 2001 From: zhuqingyv Date: Sun, 12 Jul 2026 20:19:09 +0800 Subject: [PATCH 3/5] feat: integrate Agora Memory v2 --- README.md | 6 +- README.zh-CN.md | 6 +- docs/agora-vip-provider-requirements.md | 6 +- docs/prd-ma-agora-memory-v2.md | 705 +++++++++++++++++++++++ package-lock.json | 38 ++ package.json | 3 + scripts/package-portable.mjs | 28 +- src/agent.ts | 182 +++++- src/cli/App.tsx | 303 ++++++++-- src/cli/components/MemoryConsole.tsx | 327 ++++++++--- src/cli/components/StatusBar.tsx | 49 +- src/cli/utils/commands.ts | 162 ++++-- src/mcp/types.ts | 14 + src/provider/agora-auto-intake.ts | 74 +++ src/provider/agora.ts | 555 +++++++++++++++++- src/session/store.ts | 8 +- test/agora-auto-intake.test.ts | 88 +++ test/agora-integrity.test.ts | 31 + test/agora-provider-runtime.test.ts | 356 +++++++++--- test/cli-ux.test.ts | 99 +++- test/commands.test.ts | 37 +- test/fixtures/portable-agora-config.json | 12 + test/session-store.test.ts | 29 + 23 files changed, 2787 insertions(+), 331 deletions(-) create mode 100644 docs/prd-ma-agora-memory-v2.md create mode 100644 src/provider/agora-auto-intake.ts create mode 100644 test/agora-auto-intake.test.ts create mode 100644 test/agora-integrity.test.ts create mode 100644 test/fixtures/portable-agora-config.json diff --git a/README.md b/README.md index 9d39c45..54584ed 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ User-facing slash commands: | Command | Purpose | | --- | --- | | `/model` | Open the model/profile picker | -| `/memory` | Manage named profiles, patch selection, writable memory, automatic intake, and rollback | +| `/memory` | Manage named Memories, multi-memory mounts, multi-target intake, automatic policy, and rollback | | `/help` | Show user-facing commands | | `/clear` | Clear current conversation | | `/exit` | Exit MA | @@ -184,9 +184,9 @@ DeepSeek/deepseek-v4-flash MA can run Agora as a provider-owned MCP stdio subprocess instead of asking users to manage a local HTTP server. The TUI reports real provider stages such as local-model loading, memory mounting, and generation. -When the active provider is Agora, MA can expose verified MemoryPatch operations: mount, disable, internalize, roll back, and inspect state. MA treats a memory module as active only after Agora returns matching response metadata; it never fakes memory by injecting facts into a prompt. +When the active provider is Agora, users operate uniquely named, independently versioned Memories; MemoryPatch is the immutable version object. MA reports `mounted` only after a later Agora response returns the requested ordered Patch ids and an advanced PatchSet revision. It never fakes memory by injecting facts into a prompt. -`/memory` controls named MemoryProfiles stored in Agora. A profile may mount one writable family plus multiple read-only overlays. Automatic intake advances only the writable family, preserves overlays, and runs in the background after 4 new user turns or about 2,000 pending tokens and 60 seconds of idle time. +`/memory` can mount zero or more Memories per project or conversation and hot-swap them at the next request boundary without restarting the base model. One intake batch may mix a new Memory with increments to multiple existing Memories; the source is extracted once and each target reports completed/noop/review/conflict/failed independently. Automatic intake requires explicit targets and runs after 4 new user turns or about 2,000 pending tokens plus 60 seconds of idle time. Failed targets can be retried alone or explicitly abandoned without blocking input or adding transcript noise. Context Usage remains independent from MemoryPatch state: the TUI continues to show used/trigger/window/source from `agent.getContextUsage()`. Internalization never clears context, and compaction never claims to internalize memory. diff --git a/README.zh-CN.md b/README.zh-CN.md index ca2bf68..7b01c20 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -144,7 +144,7 @@ ESC ESC 切换历史会话 | 指令 | 用途 | | --- | --- | | `/model` | 打开模型/profile 选择器 | -| `/memory` | 管理命名 Profile、Patch 多选、主记忆、自动内化和回滚 | +| `/memory` | 管理具名 Memory、多记忆挂载、多目标内化、自动策略和回滚 | | `/help` | 查看用户可用指令 | | `/clear` | 清空当前对话 | | `/exit` | 退出 MA | @@ -183,9 +183,9 @@ DeepSeek/deepseek-v4-flash MA 可以把 Agora 作为 provider 自己管理的 MCP stdio 子进程运行,不要求用户维护本地 HTTP 服务。TUI 会展示真实的本地模型加载、记忆挂载和生成阶段。 -当 Agora 是当前 provider 时,MA 会提供经过验证的 MemoryPatch 操作:挂载、停用、内化、回滚和状态查看。只有 Agora 响应 metadata 返回匹配的状态,MA 才会说明记忆已生效;不会把一段事实塞进 prompt 伪装成记忆。 +当 Agora 是当前 provider 时,用户操作的是名称唯一、可持续迭代的 Memory;MemoryPatch 是它的不可变版本。只有下一次 Agora 响应 metadata 返回相同的 ordered Patch ids 和更新后的 PatchSet revision,MA 才会显示 `mounted`;不会把一段事实塞进 prompt 伪装成记忆。 -`/memory` 直接控制 Agora registry 中的命名 MemoryProfile。一个 Profile 可以挂载一个可写主记忆 family 和多个只读 overlay;自动内化只推进主 family,overlay 不会被覆盖。默认资格阈值是 4 个新增用户回合或约 2000 pending tokens,并在空闲 60 秒后后台运行,不阻塞输入。 +`/memory` 可在项目或单个会话中同时挂载 0~N 个 Memory,并在下一次请求边界热拔插,不重启基座。内化时可以在一个 batch 中混合“新建 Memory”和“增量到多个旧 Memory”;同一 source 只提取一次,各目标独立报告 completed/noop/review/conflict/failed。自动内化必须显式选择目标,默认在 4 个新增用户回合或约 2000 pending tokens、空闲 60 秒后运行;失败目标可单独重试或明确放弃,输入框和 transcript 不受影响。 Context Usage 与 MemoryPatch 是两条独立状态:底栏始终从 `agent.getContextUsage()` 显示 used/trigger/window/source;内化不会清空 context,compact 也不会冒充记忆内化。 diff --git a/docs/agora-vip-provider-requirements.md b/docs/agora-vip-provider-requirements.md index 43c4ed1..4f04e68 100644 --- a/docs/agora-vip-provider-requirements.md +++ b/docs/agora-vip-provider-requirements.md @@ -29,7 +29,7 @@ MA 将 Agora 作为内部 VIP provider 适配:Agora 不再只是一个 OpenAI- 3. Agora 状态层级 - 第一行始终展示 provider、model 和 Context Usage(used/trigger/window/source)。 - - 第二行仅在 Agora 下展示 Profile、patch 数、verified/stale 和后台 intake activity。 + - 第二行仅在 Agora 下展示具名 Memory、版本、`+N`、verified/pending/stale 和后台 intake activity。 - session/binding/job/完整 patch id 只放在 `/memory status`,不再堆入主底栏。 - 该状态来自 runtime 当前 state 或 session meta 的 `providerState`。 - 普通 provider 不展示 Agora 专属 memory/session 信息。 @@ -38,8 +38,8 @@ MA 将 Agora 作为内部 VIP provider 适配:Agora 不再只是一个 OpenAI- - Session meta 保留 `providerState`,字段包括 `provider_id`、`agora_session_id`、`memory.status`、`profile_id`、`binding_id`、`active_memory_patch_ids`、`last_verified_at`。 - MA 只能从 Agora `chat_complete` 响应 metadata 更新 mounted 证据。 - mount、disable、internalize、rollback 必须验证下一次 `chat_complete` metadata 后才报告成功。 - - `/memory` 提供命名 Profile、Patch 多选、唯一 writable family、overlay、自动策略、历史和回滚。 - - 自动 intake 只推进 writable family,通过 CAS 保留 overlay;后台任务不得禁用输入框。 + - `/memory` 以具名 Memory 为第一层,提供多 Memory 挂载、新建/重命名、混合多目标内化、显式自动目标、历史和 CAS 回滚;Profile 仅作为后台 binding 实现。 + - 自动 intake 一次提交显式目标列表;completed/noop 不重复,review/conflict/failed 只重试未完成目标;后台任务不得禁用输入框或写入 transcript。 5. 普通 provider 回归边界 - DeepSeek、LM Studio、OpenAI-compatible 的请求参数、重试策略、stream parser、tool-call 解析保持不变。 diff --git a/docs/prd-ma-agora-memory-v2.md b/docs/prd-ma-agora-memory-v2.md new file mode 100644 index 0000000..c488e77 --- /dev/null +++ b/docs/prd-ma-agora-memory-v2.md @@ -0,0 +1,705 @@ +# PRD:MA × Agora Memory v2 接入改造 + +| 项目 | 内容 | +| --- | --- | +| 产品 | my-agent(MA) | +| 依赖 | Agora Memory Runtime v2 | +| 文档状态 | Ready for implementation | +| 目标版本 | MA 0.3.x 后续版本;Agora 以 capability handshake 为准 | +| 关联 | zimoos/my-agent#39、zimoos/agora#23、zimoos/agora PR #22 | + +## 1. 产品结论 + +MA 不再把 Agora 当作“带记忆的普通模型地址”,而应把它产品化为: + +> 一个可以运行本地模型,并让用户按会话挂载、组合、迭代和回滚具名记忆的 Agent 工作台。 + +用户面对的是“记忆”和“记忆版本”,不是 Profile、family、lineage、segment 等底层术语。Agora 负责记忆编译、版本、增量、运行时热切换和真实性;MA 负责用户意图、会话绑定、TUI 交互、自动调度和状态解释。 + +## 2. 背景与问题 + +现有 MA PR #40 已完成 Agora MCP stdio、本地模型目录/下载、Memory Console、Profile/Patch 选择、后台 intake 和 Context Usage,但仍建立在 v1 心智上: + +- 以 MemoryProfile 为主对象。 +- 一次只有一个 `writable_patch_family`。 +- 内化使用 `memory_intake_run` 和单 lineage advance。 +- 用户无法在一次内化中选择“新建 Memory”或多选已有 Memory。 +- 没有使用 Agora v2 的具名 Memory、batch intake、per-target 状态和 PatchSet revision。 +- 状态栏只显示 Profile 和 Patch 数量,无法让用户理解当前究竟挂载了哪些记忆。 + +Agora v2 已提供: + +- 具名 Memory CRUD 和数据库唯一性。 +- 不可变 MemoryPatch 版本和增量 segments。 +- 一次 source snapshot 扇出到多个 create/increment target。 +- 多 ModelDelta 同时挂载。 +- 单基座、请求边界热切换。 +- per-target completed/noop/review/conflict/failed。 +- `patchset_revision` 和真实 chat metadata 验证。 + +本 PRD 的任务是把 Agora 的底层能力转化为自然、低认知负担、可持续迭代的 MA 产品体验。 + +## 3. 产品目标 + +### 3.1 核心目标 + +1. 用户打开任意新会话时,可以选择 0~N 个具名 Memory,并且不同会话可以使用不同组合。 +2. 用户可以在对话过程中挂载或拔出任意兼容 Memory,不重启基座模型。 +3. 用户内化当前对话时,可以: + - 新建一个具名 Memory; + - 增量写入一个已有 Memory; + - 一次多选多个已有 Memory; + - 在同一个 batch 中混合“新建 + 增量”。 +4. 每个新 Memory 和输出 Patch 都有唯一名称;重名由 Agora 拒绝,MA 保留用户输入并允许修改后重试。 +5. 自动内化只处理尚未完成的对话增量;失败目标不能造成其他目标重复编译或漏记。 +6. 用户始终知道当前挂载了什么、是否真实生效、后台正在内化什么。 +7. Agora 记忆不污染其他 Provider,Context Usage 不因记忆操作丢失或被伪造。 + +### 3.2 成功指标 + +- 首次 Agora 使用无需理解 MCP、Profile、family 或 Patch id 即可完成模型下载和第一次对话。 +- 用户可在 3 次操作内完成“选择多个记忆并开始聊天”。 +- 用户可在一个面板内完成“新建/多选目标 → 确认范围 → 提交内化”。 +- 所有挂载成功状态均有真实 `chat_complete` metadata 证明。 +- Patch-only 切换不出现模型重新加载阶段。 +- 自动内化期间输入框持续可用,transcript 不插入进度噪声。 +- 普通 Provider 的请求、stream、tool calling、Context Usage 和状态栏无回归。 + +## 4. 非目标 + +- 不在 MA 重新实现 Memory 编译、gate、segment、CAS 或回滚算法。 +- 不把记忆事实注入 system prompt 模拟 Agora Memory。 +- 不支持生成到一半的 mid-token 热插拔;切换在下一次 chat 边界生效。 +- 不支持跨不同 base model 挂载不兼容 Patch。 +- 不引入账号、登录、机器绑定、许可证、云同步或 ZimoOS 依赖。 +- 不在 TUI 暴露完整 Patch id、binding id、batch id 等调试信息;它们进入详情页和 `/memory status`。 +- 不把旧 Agora `0.2.0` 版本号本身当作 v2 可用证明。 + +## 5. 用户与核心任务 + +### 5.1 目标用户 + +- 使用远程 API 模型、但希望在有本地模型时获得长期私有记忆的开发者。 +- 在多个项目、角色、工作上下文之间切换的 Agent 用户。 +- 没有能力维护数据库、向量库或微调流程,但希望记忆可控、可回滚的普通用户。 + +### 5.2 核心 Jobs to be Done + +- “我打开这个项目时,希望 Agent 自动带上这个项目的记忆。” +- “这次会话只需要产品记忆,不要带入私人偏好。” +- “把刚才的共识同时写入产品记忆和工程记忆。” +- “这是一类新记忆,帮我新建一个名字保存。” +- “刚才内化错了,回滚到上一个版本。” +- “我切走某个记忆后,不希望模型重载并卡住。” + +## 6. 产品对象与用户心智 + +| 用户术语 | 定义 | Agora 对应 | +| --- | --- | --- | +| 记忆 | 有唯一名称、可持续迭代的长期记忆模块 | MemoryRecord | +| 版本 | 某个记忆的一次不可变产物 | MemoryPatch | +| 挂载 | 让一个会话在下一次请求中使用指定版本 | active_memory_patch_ids | +| 记忆组合 | 当前会话挂载的多个记忆版本 | MemoryProfile + binding | +| 内化 | 把一段已确认对话增量编译进一个或多个记忆 | memory_intake_batch_run | +| 自动内化目标 | 自动内化时允许写入的具名记忆列表 | auto_intake_target_memory_ids | + +产品规则: + +- 主界面不把 Profile 当作第一层对象。 +- Profile 作为 MA 管理“项目默认组合、会话 override”的后台实现,可在高级详情中显示。 +- 列表优先显示 Memory 名称和版本,例如“Agora 产品记忆 · v4”,不显示原始 id。 +- 一个 Memory 同时只能有一个 current head,但历史版本都可查看和回滚。 +- 挂载多个 Memory 不代表它们互相合并;它们只是共同参与当前会话推理。 +- 内化到多个 Memory 时,同一 source 只提取一次,然后分别推进各自 lineage。 + +## 7. 产品原则 + +1. **开箱即用**:Agora chat 与 Memory v2 分层降级;记忆缺能力不能阻止普通聊天。 +2. **明确选择**:MA 不猜测内化目标,更不能选择第一个 mounted Patch。 +3. **真实状态**:配置成功不等于挂载成功;必须等待下一次 chat metadata。 +4. **后台执行**:下载和内化有 Activity,但不阻塞输入、不污染 transcript。 +5. **名称优先**:用户操作名称和版本,技术 id 只用于诊断。 +6. **可逆**:切换、内化和自动策略都有历史、失败恢复和回滚路径。 +7. **Provider 隔离**:只有 active provider 为 Agora 时才能操作和展示 Agora Memory。 + +## 8. 信息架构 + +### 8.1 主状态栏 + +第一行保持通用信息: + +```text +Provider: agora · Model: qwen3.6-35b-a3b-q4 · ctx: 12k/197k trigger · win 262k registry +``` + +第二行仅在 Agora 下出现,优先展示用户可读名称: + +```text +Memory: Agora产品@v4 +2 · mounted · auto: 产品, 工程 +``` + +状态规则: + +- 0 个 Patch:`Memory: 未挂载`。 +- 1 个 Patch:显示完整 Memory 名称和版本。 +- 多个 Patch:显示第一个名称、版本和 `+N`;完整列表进入 `/memory`。 +- 配置已提交但尚未经过 chat 验证:`pending next chat`。 +- metadata ids 与请求不一致:`stale`,不能显示 mounted。 +- 后台 intake:在行尾显示 `内化中 1/3`,不显示 batch id。 + +### 8.2 Memory Console + +按 `/memory` 打开,默认分为三层: + +```text +┌ Memory · 当前项目 my-agent ────────────────────────────────┐ +│ 已挂载 │ +│ ◉ Agora 产品记忆 v4 verified │ +│ ◉ 工程规范记忆 v7 verified │ +│ ○ 用户偏好记忆 v2 │ +│ │ +│ 自动内化目标 │ +│ ☑ Agora 产品记忆 ☑ 工程规范记忆 ☐ 用户偏好记忆 │ +│ │ +│ Activity: 正在内化 2 个目标 · 1 完成 / 1 编译中 │ +│ Space 挂载 · i 内化 · n 新建 · e 重命名 · h 历史 · Esc 返回│ +└────────────────────────────────────────────────────────────┘ +``` + +交互要求: + +- `Space` 多选挂载/拔出。 +- `Enter` 应用挂载组合,状态先进入 `pending next chat`。 +- `i` 打开内化目标选择器。 +- `n` 新建 Memory,不再新建一个用户可见的 Profile。 +- `e` 重命名 Memory。 +- `h` 查看当前 Memory 的版本历史和回滚。 +- 高级区才展示项目/会话 binding、Profile、Patch id 和 revision。 + +### 8.3 记忆高亮与流动颜色 + +已真实 mounted 的 Memory 名称使用动态高亮,但必须克制: + +- 默认在名称上使用低频 cyan→blue→magenta 流动高光,周期约 2 秒。 +- 动画只用于 `verified mounted`;pending、stale、failed 不使用。 +- generation、确认框、低性能终端或 `NO_COLOR`/reduced-motion 下停止动画,退化为静态 cyan bold。 +- 只更新状态栏/Memory Console 的局部区域,不触发 transcript 重绘。 +- 多个 Memory 不同时逐个动画;作为一个组合 badge 统一流动,避免视觉噪声。 + +## 9. 核心用户流程 + +### 9.1 首次使用 Agora + +1. MA 定位固定 Agora executable,启动 `agora mcp serve`。 +2. 执行 MCP initialize、list_tools、runtime_capabilities。 +3. 检查 base chat 能力:doctor、models_list、chat_complete。 +4. 检查 v2 memory tools 和五项 capability。 +5. Model Picker 展示白名单模型和本地状态。 +6. 模型缺失时提供一键下载和真实 progress。 +7. 下载完成后,用户可以“不使用记忆直接开始”或“选择已有 Memory”。 + +降级规则: + +- base chat 可用、v2 memory 不可用:允许 Agora chat,Memory Console 显示“当前 runtime 不支持 Memory v2”。 +- 工具名称存在但 capability 未开启:不显示 v2 操作。 +- 版本号相同但 schema/capability 不同:以 handshake 为准。 +- 开发环境允许命令覆盖,但必须显示 `unverified runtime`。 + +### 9.2 新会话选择记忆 + +绑定优先级固定为: + +```text +conversation override > project default > user default > no memory +``` + +行为: + +- 新会话创建时先解析项目默认组合。 +- 用户可在第一次发送消息前修改挂载列表。 +- 会话 override 不覆盖项目默认配置,只覆盖本会话。 +- 切换会话时立即恢复各自挂载组合和验证状态。 +- 非 Agora Provider 下不显示 Agora mounted 状态。 + +### 9.3 挂载和热拔插 + +1. 用户在 Console 多选 Memory 当前版本。 +2. MA 校验 base model 兼容性。 +3. MA 更新当前 binding 对应的 active patch ids。 +4. UI 显示 `pending next chat`,输入框继续可用。 +5. 下一次 chat 返回相同 ordered patch ids 和更高 PatchSet revision 后,状态变为 verified。 + +禁止行为: + +- 不因为 Patch 变化重启 Agora subprocess。 +- 不显示“重新加载模型”。 +- 不在 profile update 成功后立即声称 mounted。 +- 如果验证失败,保留旧 verified 组合并显示可重试错误。 + +### 9.4 新建和命名 + +- 新建 Memory 必须输入名称。 +- 输出 Patch 名称必须存在;MA 默认建议 `@v1`。 +- 增量版本默认建议 `@vN`,用户可编辑。 +- Agora 返回 `memory_name_conflict` 或 `memory_patch_name_conflict` 时: + - 不关闭输入框; + - 保留原输入; + - 高亮冲突字段; + - 提示用户修改后重试; + - 不自动追加随机数字。 + +### 9.5 手动内化 + +入口:`i`、`/memory internalize` 或 agent memory tool。 + +步骤: + +1. 展示本次 source range,例如“本会话第 12~28 条消息”。 +2. 用户选择一个或多个目标: + - `新建 Memory`:输入 Memory 名和输出版本名; + - `增量到已有 Memory`:多选 Memory,确认各自输出版本名。 +3. MA 展示摘要:一个 source、N 个目标,不展示实现细节。 +4. 用户确认后调用一次 `memory_intake_batch_run`。 +5. MA 轮询 `memory_intake_batch_get`,按目标独立展示状态。 +6. 只把 completed 的新 Patch 更新到当前挂载组合。 +7. noop 不生成新版本;review/conflict/failed 保持未挂载并可单独处理。 +8. 下一次 chat 验证 patch ids 和 revision 后报告最终成功。 + +同一 batch 的目标状态: + +| 状态 | 用户文案 | 后续动作 | +| --- | --- | --- | +| completed | 已生成新版本 | 可挂载并验证 | +| noop | 没有提取到可长期保存的信息 | 不重试,除非修改 source | +| review | 需要确认后再写入 | 保留任务,用户审阅 | +| conflict | 记忆已被其他任务更新 | 刷新 head,重新确认版本名后重试 | +| failed | 内化失败 | 只重试该目标 | + +### 9.6 自动内化 + +默认触发条件同时满足: + +- active provider 为 Agora。 +- runtime 具备完整 v2 capability。 +- 当前 Profile 开启 auto policy。 +- 明确配置至少一个 `auto_intake_target_memory_id`。 +- 自上次已完成 batch 后累计至少 4 个用户回合或约 2000 pending tokens。 +- 用户空闲 60 秒。 +- 当前无 generation、tool、确认框、模型切换、Memory 切换或其他 intake。 + +调度规则: + +- 每次自动任务固定一个 source range,并一次提交所有目标。 +- MA 在会话 meta 持久化 `last_auto_intake_message_end` 和当前 batch/target 状态。 +- completed/noop 目标视为完成;review/conflict/failed 不推进本轮全局自动 checkpoint。 +- 某个目标失败时,只重试失败目标,不重复提交成功目标。 +- 用户明确放弃失败目标后,才允许推进该轮 checkpoint。 +- 新加入自动目标默认只接收后续增量;用户可手动选择“用整个会话初始化该记忆”。 +- 自动内化不清空 context,不触发 compact,不改写 transcript。 + +### 9.7 历史与回滚 + +- 版本历史按 Memory 分组,而不是展示所有 Patch 平铺列表。 +- 每个版本显示:版本名、创建时间、source 摘要、状态、是否当前 head、是否已挂载。 +- 回滚使用 expected current head 做 CAS。 +- 回滚成功只改变 Memory head;当前会话是否切换到回滚版本,由用户确认。 +- 如果用户选择“回滚并挂载”,仍需下一次 chat 验证。 + +### 9.8 Provider 切换 + +- 从 Agora 切到其他 Provider:隐藏 Memory 行,保留 Agora 会话状态但标记 unavailable。 +- 切回 Agora:重新握手并恢复对应会话 binding,不沿用 stale mounted 状态。 +- 非 Agora Provider 收到记忆命令时,明确提示需要先切到 Agora;禁止 prompt 模拟。 + +## 10. 命令设计 + +| 命令 | 行为 | +| --- | --- | +| `/memory` | 打开 Memory Console | +| `/memory list` | 列出具名 Memory 和当前版本 | +| `/memory mount ` | 挂载一个或多个 Memory 当前版本 | +| `/memory unmount ` | 拔出指定或全部 Memory | +| `/memory new ` | 创建具名 Memory;没有 source 时允许空壳创建 | +| `/memory rename ` | 重命名 Memory | +| `/memory internalize` | 打开多目标内化选择器 | +| `/memory internalize --new ` | 新建并内化 | +| `/memory internalize --into ` | 多选已有 Memory 增量内化 | +| `/memory auto on --targets ` | 开启自动内化并指定目标 | +| `/memory auto off` | 关闭自动内化 | +| `/memory history ` | 查看版本历史 | +| `/memory rollback ` | CAS 回滚 | +| `/memory status` | 查看 ids、binding、batch、revision 和 capability 详情 | +| `/memory disable` | 当前 scope 不使用记忆 | + +命令、TUI 和 agent tool 必须调用同一个 Controller,禁止三套业务逻辑。 + +## 11. MA 状态模型 + +```ts +type AgoraMemoryV2State = { + providerId: 'agora'; + runtimeMode: 'v2' | 'legacy' | 'unavailable'; + sessionId?: string; + profileId?: string; + bindingId?: string; + mounted: Array<{ + memoryId: string; + memoryName: string; + patchId: string; + patchName: string; + version: string; + }>; + requestedPatchIds: string[]; + verifiedPatchIds: string[]; + patchsetRevision?: number; + status: 'unavailable' | 'disabled' | 'selecting' | 'pending' | 'mounted' | 'stale' | 'failed'; + autoTargetMemoryIds: string[]; + activeBatch?: { + batchId: string; + sourceStart: number; + sourceEnd: number; + targets: Array<{ + memoryId?: string; + name: string; + status: 'queued' | 'compiling' | 'completed' | 'noop' | 'review' | 'conflict' | 'failed'; + outputPatchId?: string; + error?: string; + }>; + }; + lastVerifiedAt?: string; +}; +``` + +状态来源: + +- Memory/版本目录:Agora MCP list/get tools。 +- requested:用户当前提交的选择。 +- verified:最近一次 `chat_complete` metadata。 +- revision:Agora runtime metadata。 +- Activity:batch get 和 progress notification。 +- Context Usage:始终来自 `agent.getContextUsage()`,不从 Agora Memory 推断。 + +## 12. Controller 改造 + +现有 `AgoraMemoryController` 升级为 v2,TUI、命令和 agent tool 共同复用: + +```text +capabilities() +memories.list/create/rename/get +memories.mount/unmount +memories.history/rollback +profiles.resolve/updateAutoTargets +intake.plan/start/get/retryTarget/abandonTarget +verification.applyChatMetadata +models.list/download/status +``` + +必须替换的 v1 主路径: + +- `writable_patch_family` 用户选择。 +- `memory_intake_run` 单目标主流程。 +- `memory_lineage_advance` 由 MA 手工推进 lineage。 +- 根据 patch family 推断主记忆/overlay。 +- 仅用 Profile 名称表达用户记忆。 + +保留为兼容层: + +- Agora v2 flag 关闭时,旧单目标 memory tool 可以继续工作。 +- legacy mode 只提供“单记忆内化”,并明确标记能力受限。 +- 任何 v2 功能都必须由 tool + runtime capability 双重确认。 + +## 13. Agora MCP 映射 + +| 产品动作 | MCP | +| --- | --- | +| 能力握手 | runtime_capabilities + list_tools | +| 创建/列出/重命名 Memory | memories_create/list/get/rename | +| 多目标内化 | memory_intake_batch_run/get | +| 挂载组合 | memory_profiles_create/update + bindings | +| 自动目标 | memory_profiles_update.auto_intake_target_memory_ids | +| 版本列表 | memory_patch_versions | +| 回滚 | memories_rollback | +| 真实挂载验证 | chat_complete metadata + patchset_revision | + +v2 必需 capability: + +- `named_memories` +- `multi_target_intake` +- `incremental_segments` +- `multi_model_delta_mount` +- `request_boundary_hot_swap` + +注意:increment target 字段以 MCP schema 为准,使用 `expected_parent_patch_id`,不能使用旧文档中的 `expected_head_patch_id`。 + +## 14. Context Usage + +- Context Usage 与 Agora Memory 是两个独立系统。 +- 所有 Provider、所有终端宽度持续显示 used/trigger/window/source。 +- 内化成功不清空 context。 +- Context compact 不推进 Memory checkpoint。 +- 模型切换分别刷新 context capability 与 Memory/Patch 兼容性。 +- 记忆通过零外部上下文运行,不代表 MA context used 归零。 + +## 15. 错误与恢复 + +| 场景 | MA 行为 | +| --- | --- | +| v2 capability 缺失 | chat 可用,Memory v2 禁用并展示升级提示 | +| 名称冲突 | 保留输入,定位冲突字段,允许重试 | +| head conflict | 刷新 Memory,展示“已被其他任务更新” | +| 某 target failed | 其他成功结果保留,只重试失败目标 | +| Patch 不兼容 | 不更新 requested 组合,旧 mounted 状态保留 | +| 下一次 chat 未验证 Patch | 状态 stale,不声称成功 | +| Agora subprocess 退出 | Memory unavailable,保留可恢复状态,不影响其他 Provider | +| 用户切换 Profile/会话 | 使用 revision/CAS,旧后台任务不得覆盖新选择 | + +## 16. 隐私与安全 + +- 内化前清晰展示 source 范围;大范围历史或敏感内容必须确认。 +- Memory 数据和模型权重放在可写 user data 目录,不放入不可变 app bundle。 +- MA 不读取或重写 Agora Patch artifact 内容。 +- 日志默认不记录对话原文、Memory 内容或 tool 参数。 +- binary package 必须通过 manifest SHA、签名和协议握手;版本号相同但 hash/capability 不同视为不同 runtime。 +- `MA_AGORA_COMMAND` 仅用于开发覆盖,TUI 显示 unverified。 + +## 17. 发布与兼容策略 + +### 17.1 开发联调 + +- 在独立 MA worktree 开发。 +- 在 Agora PR #22 未发布前,使用源码 v2 MCP 或重新构建的本地二进制。 +- 不使用当前本机旧 schema 2 的全局 `0.2.0` 作为 v2 验收对象。 + +### 17.2 正式发布 + +- Agora PR #22 先合并并发布最终平台包。 +- MA 使用 exact package version、npm integrity、manifest SHA 和 capability handshake。 +- 如果最终 v2 仍发布为 0.2.0,必须以最终 manifest SHA 区分本机旧构建;推荐避免同版本不同能力的分发歧义。 +- MA portable 包内置相同固定 Agora artifact。 + +## 18. 实施阶段 + +### Phase 1:契约与 Controller + +- capability handshake。 +- Memory/版本类型。 +- batch intake 和 per-target 状态。 +- metadata/revision verification。 +- legacy compatibility adapter。 + +### Phase 2:TUI 与命令 + +- 以 Memory 为中心重构 Console。 +- 多挂载、命名、重试、历史、回滚。 +- 状态栏名称摘要和 verified/pending/stale。 +- 动态高亮与 reduced-motion 降级。 + +### Phase 3:自动内化 + +- 明确目标列表。 +- source range 和会话 checkpoint。 +- idle trigger。 +- target 独立重试/放弃。 +- 后台 Activity。 + +### Phase 4:真实验收与发布 + +- 源码 v2 MCP E2E。 +- packaged Agora E2E。 +- 远程 Provider 回归。 +- PTY/visual/窄终端。 +- 固定版本与 hash。 + +## 19. 验收 Case + +### Case 1:首次使用 + +- clean data root 启动 Agora。 +- doctor/capability 成功。 +- 白名单模型缺失时一键下载并显示真实进度。 +- 下载后无需配置 Profile 即可开始无记忆聊天。 + +### Case 2:不同会话不同组合 + +- 会话 A 挂载“产品@v3 + 用户偏好@v2”。 +- 会话 B 只挂载“工程@v5”。 +- 交替对话,metadata 和回答无串扰。 + +### Case 3:多 Patch 热拔插 + +- 无 Patch → A → A+B+C → B+C → 无 Patch。 +- TUI 不出现模型重载阶段。 +- 每次下一 chat 的 ordered ids 和 revision 正确。 + +### Case 4:唯一命名 + +- 创建重复 Memory 名被拒绝。 +- 创建重复 Patch 输出名被拒绝。 +- 输入框不关闭,用户修改后成功。 + +### Case 5:多目标内化 + +- 一个 source 同时:新建 A、增量 B、增量 C。 +- source 只提交一次。 +- A completed、B noop、C conflict 分别展示。 +- 只挂载 A 的新版本;B 无版本;C 可单独重试。 + +### Case 6:自动内化 + +- 配置两个 auto target。 +- 达到 4 用户回合/约 2000 tokens,空闲 60 秒后启动。 +- 输入框可继续使用,transcript 无进度文本。 +- 成功/失败目标按规则推进或保留 checkpoint。 + +### Case 7:回滚 + +- Memory 从 v4 回滚到 v2。 +- CAS 冲突不会覆盖新的 head。 +- “回滚并挂载”后经下一 chat 验证。 + +### Case 8:Context Usage + +- Agora/远程 Provider、宽/窄终端都显示 used/trigger/window/source。 +- 内化、挂载、回滚、compact 后数据源和语义保持独立。 + +### Case 9:Provider 隔离 + +- 从 Agora 切到 DeepSeek/OpenAI-compatible。 +- Memory UI 不泄漏 mounted 状态。 +- stream、tool calling、retry、Context Usage 不回归。 + +### Case 10:旧 runtime + +- 使用 schema 2、无 v2 tools 的 Agora。 +- MA 允许基础 chat,Memory v2 明确不可用。 +- 不因同为 `0.2.0` 而错误开启新交互。 + +## 20. 关闭标准 + +- MA 不再以 `writable_patch_family` 作为 Memory Console 主交互。 +- TUI、命令、agent tool 全部复用同一个 v2 Controller。 +- 具名 Memory、多挂载、多目标内化、自动目标、回滚、revision 验证全部可用。 +- 所有挂载成功都有真实 chat metadata 证据。 +- Patch-only 切换不重启 Agora、不显示模型重新加载。 +- MA build、全量测试、PTY、visual、真实 MCP、packaged E2E 通过。 +- DeepSeek、LM Studio、OpenAI-compatible 等普通 Provider 回归通过。 +- Context Usage 始终存在且不与 Memory 生命周期混淆。 +- Agora 最终 runtime version、integrity、manifest SHA 和 capability 已锁定。 + +在以上标准全部满足前,不得仅以“Agora #23 已关闭”或“MA PR #40 已存在”宣称 MA × Agora 产品体验完成。 + +## 21. 当前证据 + +MA 当前实现: + +- `src/provider/agora.ts` 的主内化路径仍是 `memory_intake_run`、`writable_patch_family`、`memory_lineage_advance`。 +- `src/cli/components/MemoryConsole.tsx` 仍以 Profile、writable family 和 overlay 为用户第一层心智。 +- `src/cli/App.tsx` 的自动内化要求唯一 writable family。 +- `src/cli/utils/commands.ts` 仍提供 `--into ` 单目标命令。 +- `StatusBar`、MCP subprocess、模型目录/下载、project/conversation binding、Context Usage 和后台 Activity 已存在,可继续复用。 + +Agora v2 当前证据: + +- Agora #23 已关闭,PR #24/#25/#26 已进入 Agora PR #22 集成分支。 +- 源码 MCP stdio 已验证具名 Memory、batch intake 和 v2 capability 全部存在。 +- 核心 registry/intake/runtime v2 测试已复跑 20/20。 +- Agora 提交的真实证据覆盖 35B 单基座、A/B/C Patch 热切换、双 session 无串扰、resident compile、旧库恢复和 packaged MCP。 +- 当前本机全局 Agora `0.2.0` 仍是 registry schema 2,缺少 v2 tools;不能作为本 issue 的验收 runtime。 + +## 22. 复用与替换计划 + +### 22.1 继续复用 + +- Agora MCP stdio 启动、进程退出和 progress 管道。 +- Model Picker、白名单目录、下载和状态查询。 +- MemoryProfile/binding 作为后台 scope 绑定机制。 +- `providerState` 持久化框架。 +- StatusBar 第一行 Context Usage。 +- Memory Console 的 modal、键盘导航和 Activity 区域。 +- 命令注册和 agent tool 暴露框架。 +- 现有 PTY、provider runtime、普通 Provider 和 Context Usage 测试基础。 + +### 22.2 必须替换 + +- 以 Profile 为中心的用户界面 → 以具名 Memory 为中心。 +- 唯一 writable family → 显式多目标 Memory 列表。 +- 单目标 intake + MA 手工 lineage advance → Agora batch intake + per-target result。 +- family 推断主记忆/overlay → Memory 当前 head + ordered mounted PatchSet。 +- 配置成功即近似成功 → 下一次 chat ids + revision 真实验证。 + +### 22.3 Redline + +完成后,v2 主路径不得再调用 `memory_intake_run`、依赖 `writable_patch_family` 或由 MA 调用 `memory_lineage_advance`。这些符号只允许存在于明确标记的 legacy compatibility adapter 和对应测试中。 + +## 23. 实施清单 + +### Contract / Controller + +- [ ] 增加 runtime_capabilities 握手和五项 v2 capability gate。 +- [ ] 增加 Memory、Patch version、batch、target、PatchSet revision 类型。 +- [ ] 增加 memories list/create/get/rename/rollback Controller 方法。 +- [ ] 增加 batch plan/start/get/retry/abandon 方法。 +- [ ] 增加 auto_intake_target_memory_ids 读写。 +- [ ] 将 chat metadata 统一映射到 requested/verified/revision 状态。 +- [ ] 保留 legacy adapter,但不能让 legacy 状态伪装成 v2。 + +### TUI / Commands + +- [ ] Memory Console 以具名 Memory 为第一层,Profile 移入高级详情。 +- [ ] 支持多选挂载、拔出和不同 conversation 组合。 +- [ ] 支持新建、重命名、版本历史和 CAS 回滚。 +- [ ] 支持新建/增量混合的多目标内化表单。 +- [ ] 名称冲突保留输入并允许原地重试。 +- [ ] StatusBar 显示 Memory 名称、版本、+N、verified/pending/stale 和 Activity。 +- [ ] 实现 mounted badge 流动高光及 NO_COLOR/reduced-motion 降级。 +- [ ] 重写 `/memory` 命令并确保与 TUI、agent tool 共用 Controller。 + +### Automatic intake + +- [ ] 以明确 auto target ids 替换 writable family。 +- [ ] 持久化 source range、batch 和 target 状态。 +- [ ] 实现 4 回合/约 2000 tokens + idle 60 秒触发。 +- [ ] 失败目标单独重试;未解决失败前不推进整轮 checkpoint。 +- [ ] 自动任务不阻塞输入、不写 transcript、不触发 context compact。 + +### Verification / Release + +- [ ] 使用 Agora v2 源码 MCP 完成确定性 contract E2E。 +- [ ] 使用最终 packaged Agora 完成真实 E2E。 +- [ ] 验证 Patch-only 切换期间 MA 不重启 subprocess、不显示 model reload。 +- [ ] 验证内化期间只存在一个 Agora resident base,不启动第二个 compiler runtime。 +- [ ] 执行全量单测、PTY、visual、窄终端和真实模型流程。 +- [ ] 执行 DeepSeek、LM Studio、OpenAI-compatible 回归。 +- [ ] 锁定最终 package version、integrity、manifest SHA 和 capability。 + +## 24. Parent Scope Status + +`zimoos/my-agent#39`: **partial**。 + +本 issue 完成后,#39 的 MA 产品交互和 Agora v2 接入部分才可视为完成。以下父范围仍不由本 issue 自动完成: + +- Agora PR #22 合并到 main。 +- Developer ID 签名、公证和 Gatekeeper 证据。 +- 最终 npm 平台包发布。 +- MA 最终 release 合并、发布和 #39 关闭。 + +现有 MA PR #40 不是废弃实现:MCP runtime、模型流程、Context Usage、binding、Activity 和基础 TUI 可复用;但它的 v1 memory contract 不能作为本 issue 已完成证据。 + +## 25. Acceptance Mapping + +| Acceptance criterion | Code or system path | Validation/proof | Old path status | Notes | +| --- | --- | --- | --- | --- | +| v2 能力准确启用/降级 | `src/provider/agora.ts`, MCP startup | schema 2 与 schema 3 双 runtime contract test | old version-only gate removed | 版本号不能替代 capability | +| 具名 Memory 成为用户主对象 | `MemoryConsole.tsx`, controller types, commands | PTY + visual + create/rename E2E | Profile-first UI removed | Profile 仅高级详情 | +| 不同会话挂载不同多 Memory | binding resolution, session meta, controller | 两会话交替真实 chat,ids/revision 无串扰 | single global selection removed | conversation override 优先 | +| Patch 请求边界热拔插 | mount flow, providerState verification | 无 Patch→A→A+B+C→B+C→无 Patch | config-only success removed | 不重启 subprocess | +| 唯一命名与重试 | intake form, typed MCP errors | Memory/Patch 重名 PTY + contract test | silent suffix unchanged by design: prohibited | 保留用户输入 | +| 新建/多目标增量内化 | batch controller, Activity, profile update | create A + increment B/C 单 snapshot E2E | `memory_intake_run` bypassed | 每目标独立状态 | +| 自动内化不重复不漏记 | scheduler, session meta, target retry | success/noop/review/conflict/failed matrix | writable-family scheduler removed | 放弃失败需显式操作 | +| 回滚可验证 | history UI, memories_rollback, chat verification | CAS conflict + rollback-and-mount E2E | family replacement rollback removed | head 与 mounted 分离 | +| mounted 真实性 | chat metadata mapper, StatusBar | requested 与 returned ids/revision 不一致测试 | profile-update-only proof removed | stale 不得显示 mounted | +| 动态高亮可访问 | StatusBar/MemoryConsole rendering | color、NO_COLOR、reduced-motion visual tests | unchanged by design | 只高亮 verified | +| Context Usage 独立保留 | `agent.getContextUsage()`, StatusBar | Agora/远程、宽/窄终端 PTY | unchanged by design | 内化不 compact | +| 普通 Provider 无 Agora 泄漏 | provider switch, state projection | DeepSeek/LM Studio/OpenAI-compatible 回归 | unchanged by design | 非 Agora 不模拟记忆 | +| 单基座与单 runtime | Agora provider lifecycle, batch path | subprocess count + progress + packaged real E2E | separate compiler runtime removed | runtime 真实性由 capability + evidence 保证 | +| 正式包可复现 | package resolver, manifest verifier | exact version/integrity/SHA clean install | dev override compat only | dev override 显示 unverified | diff --git a/package-lock.json b/package-lock.json index 5fad920..43c6ab0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,9 @@ "react": "^19.2.5", "string-width": "^8.2.1" }, + "optionalDependencies": { + "@zimoos/agora": "0.2.0" + }, "bin": { "ma": "bin/ma" }, @@ -46,6 +49,41 @@ "ws": "^8.20.0" } }, + "node_modules/@zimoos/agora": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@zimoos/agora/-/agora-0.2.0.tgz", + "integrity": "sha512-iUCroM4rOS5lXgRSKsXG/S5OyhHDKMssioEW3/fFYkqOptayeImZBMVngmFn1kipnaxe7mhlZ2U+v1Xl8dEEQQ==", + "cpu": [ + "arm64" + ], + "license": "UNLICENSED", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@zimoos/agora-darwin-arm64": "0.2.0" + }, + "bin": { + "agora": "bin/agora" + } + }, + "node_modules/@zimoos/agora-darwin-arm64": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@zimoos/agora-darwin-arm64/-/agora-darwin-arm64-0.2.0.tgz", + "integrity": "sha512-/CxLj4ZMjaSaLPGQ9o5OlrN8Yd55sxacvWD3o3djX0hHL8BXTnL3IPcAsJVpG7+yQCuG0i43z1vSBh25O26+8g==", + "cpu": [ + "arm64" + ], + "license": "UNLICENSED", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "agora": "bin/agora" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", diff --git a/package.json b/package.json index 9fb2b97..bd1a40c 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,9 @@ "react": "^19.2.5", "string-width": "^8.2.1" }, + "optionalDependencies": { + "@zimoos/agora": "0.2.0" + }, "devDependencies": { "@playwright/test": "^1.59.1", "@types/js-yaml": "^4.0.9", diff --git a/scripts/package-portable.mjs b/scripts/package-portable.mjs index 3a38066..8244643 100755 --- a/scripts/package-portable.mjs +++ b/scripts/package-portable.mjs @@ -46,8 +46,31 @@ function copyAgoraRuntime(appDir, target) { if (manifest.version !== '0.2.0' || manifest.host_protocol_major !== 1) { throw new Error(`unexpected Agora contract: ${manifest.version}/host-v${manifest.host_protocol_major}`); } - if (manifest.files?.['bin/agora'] !== sha256(binaryPath)) { - throw new Error('Agora binary SHA-256 does not match manifest'); + const requiredCapabilities = ['mcp-stdio', 'memory-profile-v2', 'memory-intake-v2']; + if (!requiredCapabilities.every((capability) => manifest.capabilities?.includes(capability))) { + throw new Error('Agora artifact is missing required Memory v2 capabilities'); + } + for (const [relative, expected] of Object.entries(manifest.files ?? {})) { + const target = path.resolve(source, relative); + if (!target.startsWith(`${source}${path.sep}`) || !fs.existsSync(target) || sha256(target) !== expected) { + throw new Error(`Agora artifact integrity mismatch: ${relative}`); + } + } + const sourceLike = []; + const visit = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) visit(target); + else if (/\.(py|pyc|js|map)$/i.test(entry.name)) sourceLike.push(path.relative(source, target)); + } + }; + visit(source); + if (sourceLike.length > 0) { + throw new Error(`Agora user artifact contains source-like files: ${sourceLike.join(', ')}`); + } + const packageIntegrity = process.env.MA_AGORA_PACKAGE_INTEGRITY?.trim(); + if (!packageIntegrity?.startsWith('sha512-')) { + throw new Error('macos-arm64 portable release requires MA_AGORA_PACKAGE_INTEGRITY from the exact npm tarball'); } const destination = path.join(appDir, 'resources', 'agora'); copyRequired(source, destination); @@ -59,6 +82,7 @@ function copyAgoraRuntime(appDir, target) { platform: 'darwin-arm64', host_protocol_major: 1, native_core_abi: 1, + package_integrity: packageIntegrity, manifest_sha256: sha256(manifestPath), }, null, 2) + '\n', 'utf8' diff --git a/src/agent.ts b/src/agent.ts index 2b79ce3..6d251ac 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -660,17 +660,18 @@ function registerAgoraMemoryTools( tools: Map, controller: AgoraMemoryController ): void { - const profilePatchParameters = { + const memoryMountParameters = { type: 'object', properties: { profile_id: { type: 'string', description: 'Agora MemoryProfile id' }, - patch_id: { type: 'string', description: 'MemoryPatch id to select' }, - active_memory_patch_ids: { + memory_ids: { type: 'array', items: { type: 'string' }, - description: 'MemoryPatch ids to select', + description: 'Named Memory ids to mount together', }, + scope: { type: 'string', enum: ['user', 'project', 'conversation'] }, }, + required: ['memory_ids'], additionalProperties: false, }; tools.set( @@ -682,12 +683,107 @@ function registerAgoraMemoryTools( (args) => controller.status(args) ) ); + if (!controller.getCapabilities().memoryV2) { + tools.set( + 'agora_memory_mount', + agoraMemoryTool( + 'agora_memory_mount', + 'Legacy Agora MemoryProfile/MemoryPatch 挂载;能力受限,成功后仍需下一次 chat metadata 验证。', + { + type: 'object', + properties: { + profile_id: { type: 'string' }, + active_memory_patch_ids: { type: 'array', items: { type: 'string' } }, + writable_patch_family: { type: 'string' }, + }, + required: ['profile_id', 'active_memory_patch_ids'], + additionalProperties: false, + }, + (args) => controller.mount(args) + ) + ); + tools.set( + 'agora_memory_internalize', + agoraMemoryTool( + 'agora_memory_internalize', + 'Legacy 单目标 Memory intake;必须显式提供 Profile 和 writable family。', + { + type: 'object', + properties: { profile_id: { type: 'string' }, into: { type: 'string' } }, + required: ['profile_id', 'into'], + additionalProperties: false, + }, + (args) => controller.internalize(args) + ) + ); + tools.set( + 'agora_memory_disable', + agoraMemoryTool( + 'agora_memory_disable', + 'Legacy MemoryProfile disable。', + { type: 'object', properties: { profile_id: { type: 'string' } }, required: ['profile_id'], additionalProperties: false }, + (args) => controller.disable(args) + ) + ); + tools.set( + 'agora_memory_rollback', + agoraMemoryTool( + 'agora_memory_rollback', + 'Legacy MemoryPatch rollback。', + { + type: 'object', + properties: { profile_id: { type: 'string' }, patch_id: { type: 'string' } }, + required: ['profile_id', 'patch_id'], + additionalProperties: false, + }, + (args) => controller.rollback(args) + ) + ); + return; + } + tools.set( + 'agora_memory_list', + agoraMemoryTool( + 'agora_memory_list', + '列出当前 Agora base model 下的具名 Memory 和 current head。', + { type: 'object', properties: {}, additionalProperties: false }, + async () => ({ content: JSON.stringify({ memories: await controller.listMemories() }, null, 2), isError: false }) + ) + ); + tools.set( + 'agora_memory_create', + agoraMemoryTool( + 'agora_memory_create', + '创建一个名称唯一的 Agora Memory。重名必须把冲突返回给用户,不得自动改名。', + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false, + }, + async (args) => ({ content: JSON.stringify({ memory: await controller.createMemory(String(args.name)) }, null, 2), isError: false }) + ) + ); + tools.set( + 'agora_memory_rename', + agoraMemoryTool( + 'agora_memory_rename', + '重命名具名 Agora Memory;名称冲突时保留用户选择并请求新名称。', + { + type: 'object', + properties: { memory_id: { type: 'string' }, name: { type: 'string' } }, + required: ['memory_id', 'name'], + additionalProperties: false, + }, + async (args) => ({ content: JSON.stringify({ memory: await controller.renameMemory(String(args.memory_id), String(args.name)) }, null, 2), isError: false }) + ) + ); tools.set( 'agora_memory_mount', agoraMemoryTool( 'agora_memory_mount', - '挂载或切换 Agora MemoryProfile/MemoryPatch,并通过一次 Agora chat_complete metadata 验证成功。', - profilePatchParameters, + '挂载 0 到多个具名 Agora Memory。配置后只可声称 pending;下一次 chat_complete 返回相同 ordered patch ids 和 revision 后才可声称 mounted。', + memoryMountParameters, (args) => controller.mount(args) ) ); @@ -710,29 +806,87 @@ function registerAgoraMemoryTools( 'agora_memory_internalize', agoraMemoryTool( 'agora_memory_internalize', - '将当前 Agora runtime session 的新增对话提交 memory_intake_run,产出 MemoryPatch 后挂载并验证。', + '把当前 Agora session 的一份增量 source 同时内化到一个或多个显式目标;可混合新建 Memory 和增量 Memory。', { type: 'object', properties: { - profile_id: { type: 'string', description: 'Agora MemoryProfile id' }, + targets: { + type: 'array', + minItems: 1, + items: { + type: 'object', + properties: { + mode: { type: 'string', enum: ['create', 'increment'] }, + name: { type: 'string' }, + memory_id: { type: 'string' }, + expected_parent_patch_id: { type: ['string', 'null'] }, + output_name: { type: 'string' }, + }, + required: ['mode', 'output_name'], + additionalProperties: false, + }, + }, + source_message_start: { type: 'integer', minimum: 0 }, + source_message_end: { type: 'integer', minimum: 1 }, }, + required: ['targets'], additionalProperties: false, }, (args) => controller.internalize(args) ) ); + tools.set( + 'agora_memory_batch_status', + agoraMemoryTool( + 'agora_memory_batch_status', + '查询多目标 Memory 内化 batch 的逐目标状态;completed/noop 与 review/conflict/failed 必须分别解释。', + { + type: 'object', + properties: { batch_id: { type: 'string' } }, + required: ['batch_id'], + additionalProperties: false, + }, + async (args) => ({ content: JSON.stringify(await controller.getBatchIntake(String(args.batch_id)), null, 2), isError: false }) + ) + ); + tools.set( + 'agora_memory_auto_policy', + agoraMemoryTool( + 'agora_memory_auto_policy', + '设置当前 Memory 组合的自动内化开关和显式具名 Memory 目标;开启时目标列表不能为空。', + { + type: 'object', + properties: { + profile_id: { type: 'string' }, + enabled: { type: 'boolean' }, + target_memory_ids: { type: 'array', items: { type: 'string' } }, + }, + required: ['profile_id', 'enabled', 'target_memory_ids'], + additionalProperties: false, + }, + async (args) => ({ + content: JSON.stringify(await controller.setAutoPolicy( + String(args.profile_id), + Boolean(args.enabled), + Array.isArray(args.target_memory_ids) ? args.target_memory_ids.map(String) : [] + ), null, 2), + isError: false, + }) + ) + ); tools.set( 'agora_memory_rollback', agoraMemoryTool( 'agora_memory_rollback', - '回滚 Agora MemoryProfile 到指定或唯一上一版 MemoryPatch,并通过 chat_complete metadata 验证。', + '用 expected current head 执行具名 Memory 的 CAS 回滚。回滚不会自动覆盖当前会话挂载组合。', { type: 'object', properties: { - profile_id: { type: 'string', description: 'Agora MemoryProfile id' }, - patch_id: { type: 'string', description: 'target MemoryPatch id' }, + memory_id: { type: 'string', description: 'Named Memory id' }, + expected_head_patch_id: { type: 'string', description: 'Current head used for CAS' }, target_patch_id: { type: 'string', description: 'target MemoryPatch id' }, }, + required: ['memory_id', 'expected_head_patch_id', 'target_patch_id'], additionalProperties: false, }, (args) => controller.rollback(args) @@ -849,8 +1003,10 @@ export async function createAgent( ? [ '', '# Agora Memory', - '- Agora memory is provider-scoped. Use agora_memory_status/mount/disable/internalize/rollback only when the user asks to inspect or change Agora MemoryPatch state.', - '- Treat Agora memory as mounted only when agora_memory_status or a memory action reports providerState from Agora chat_complete metadata.', + '- Agora memory is provider-scoped. The user-facing object is a uniquely named Memory; MemoryPatch is its immutable version. Use the agora_memory_* tools only when the user asks to inspect or change Agora memory.', + '- Mount can contain zero or multiple Memory ids. Internalization requires explicit targets and may mix create and increment in one batch; never guess the first mounted target.', + '- A mount action only means pending. Treat memory as mounted only after a later Agora chat_complete returns the requested ordered patch ids and PatchSet revision.', + '- Retry only review/conflict/failed batch targets; never resubmit completed/noop targets.', '- Do not simulate memory by inserting facts into the prompt.', ].join('\n') : [ diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 408974b..da8c5b2 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -25,7 +25,14 @@ import { } from './components/SessionPicker.js'; import { ModelPicker } from './components/ModelPicker.js'; import { MemoryConsole } from './components/MemoryConsole.js'; -import type { AgoraMemoryPatch, AgoraMemoryProfile } from '../provider/agora.js'; +import type { + AgoraMemory, + AgoraMemoryIntakeTarget, + AgoraMemoryPatch, + AgoraMemoryProfile, +} from '../provider/agora.js'; +import { agoraProjectProfileId } from '../provider/agora.js'; +import { classifyAgoraBatchTargets, planAgoraAutoIntake } from '../provider/agora-auto-intake.js'; import type { PendingConfirm } from './hooks/useAgent.js'; import { isCommand, executeCommand } from './utils/commands.js'; import { @@ -70,30 +77,38 @@ export function App({ config, connections, agent, sessionStore, currentSessionId const state = useSyncExternalStore(store.subscribe, store.getState); const { messages, thinking, inFlightText } = state; - const providerState = - agent.getProviderState?.() ?? - sessionStore.list(100).find((session) => session.id === currentSessionId)?.providerState ?? - null; + const persistedProviderState = sessionStore.list(100).find((session) => session.id === currentSessionId)?.providerState; + const providerState = agent.getProviderState?.() ?? persistedProviderState ?? null; const memoryController = agent.getMemoryController?.() ?? null; const [pendingImages, setPendingImages] = useState([]); const [sessionPickerSessions, setSessionPickerSessions] = useState(null); const [modelPickerModels, setModelPickerModels] = useState(null); const [memoryConsole, setMemoryConsole] = useState<{ + memories: AgoraMemory[]; profiles: AgoraMemoryProfile[]; patches: AgoraMemoryPatch[]; } | null>(null); const [memoryActivity, setMemoryActivity] = useState(''); + const activeMemoryProfile = memoryConsole?.profiles.find( + (profile) => profile.id === providerState?.memory?.profile_id + ); + const memoryProfileId = activeMemoryProfile?.id ?? agoraProjectProfileId(process.cwd()); const lastUserActivityRef = useRef(Date.now()); const intakeInFlightRef = useRef(false); const refreshMemoryConsole = useCallback(async () => { if (!memoryController) throw new Error('Agora memory controller is unavailable'); - const [profiles, patches] = await Promise.all([ + const capabilities = memoryController.getCapabilities(); + if (!capabilities.memoryV2) { + throw new Error(`Agora Memory v2 不可用(当前模式: ${capabilities.runtimeMode}),基础对话仍可使用。`); + } + const [memories, profiles, patches] = await Promise.all([ + memoryController.listMemories(), memoryController.listProfiles(), memoryController.listPatches(true), ]); - setMemoryConsole({ profiles, patches }); + setMemoryConsole({ memories, profiles, patches }); }, [memoryController]); const openMemoryConsole = useCallback(async () => { @@ -104,35 +119,111 @@ export function App({ config, connections, agent, sessionStore, currentSessionId } }, [refreshMemoryConsole, store]); - const runMemoryIntake = useCallback(async (profileId: string) => { + const persistMemoryRuntimeState = useCallback((memoryPatch: Record) => { + memoryController?.updateLocalMemoryState(memoryPatch); + const existing = agent.getProviderState?.() ?? + sessionStore.list(100).find((session) => session.id === currentSessionId)?.providerState ?? + { provider_id: 'agora' }; + sessionStore.updateProviderState(currentSessionId, { + ...existing, + memory: { ...(existing.memory ?? {}), ...memoryPatch }, + }); + }, [agent, currentSessionId, memoryController, sessionStore]); + + const monitorMemoryBatch = useCallback(async ( + batchId: string, + profileId: string, + runtimeSourceEnd: number, + localSourceEnd: number, + selectedPatchIds: string[], + automatic: boolean + ) => { if (!memoryController || intakeInFlightRef.current) return; intakeInFlightRef.current = true; try { - const submitted = await memoryController.startIntake({ profile_id: profileId }); - const jobId = String(submitted.job_id ?? submitted.job?.id ?? ''); - if (!jobId) throw new Error('Agora did not return an intake job id'); - setMemoryActivity(`queued · ${jobId}`); for (;;) { await new Promise((resolve) => setTimeout(resolve, 1000)); - const current = await memoryController.finalizeIntake(jobId, profileId); - const status = String(current.status ?? current.job?.status ?? ''); - const stage = String(current.stage ?? current.job?.stage ?? status); - setMemoryActivity(stage); - if (status === 'failed') throw new Error(current.error?.message ?? current.job?.error ?? stage); - if (status === 'completed' || current.outcome) { - setMemoryActivity(current.outcome === 'review_required' ? 'review required' : current.outcome ?? 'completed'); - const nextState = agent.getProviderState?.(); - if (nextState) sessionStore.updateProviderState(currentSessionId, nextState); - await refreshMemoryConsole().catch(() => undefined); - break; + const batch = await memoryController.getBatchIntake(batchId); + const classified = classifyAgoraBatchTargets(batch.targets); + const done = classified.completed.length; + setMemoryActivity(`内化中 ${done}/${batch.targets.length}`); + persistMemoryRuntimeState({ + active_batch: { batch_id: batchId, profile_id: profileId, source_end: runtimeSourceEnd, local_source_end: localSourceEnd, selected_patch_ids: selectedPatchIds, automatic, targets: batch.targets }, + }); + if (!classified.terminal) continue; + + const blockers = classified.blockers; + const profiles = await memoryController.listProfiles(); + const currentProfile = profiles.find((profile) => profile.id === profileId); + const selectionUnchanged = Boolean(currentProfile) && + selectedPatchIds.length === currentProfile?.active_memory_patch_ids.length && + selectedPatchIds.every((id, index) => id === currentProfile?.active_memory_patch_ids[index]); + if (selectionUnchanged) await memoryController.applyCompletedBatch(batch, profileId); + if (blockers.length === 0) { + persistMemoryRuntimeState({ + active_batch: undefined, + ...(automatic ? { + last_auto_intake_message_end: localSourceEnd, + last_auto_intake_runtime_message_end: runtimeSourceEnd, + } : {}), + }); + setMemoryActivity(`内化完成 ${done}/${batch.targets.length}${selectionUnchanged ? ' · 下一次对话验证挂载' : ' · 当前组合已变化,未自动覆盖'}`); + } else { + persistMemoryRuntimeState({ + active_batch: { batch_id: batchId, profile_id: profileId, source_end: runtimeSourceEnd, local_source_end: localSourceEnd, selected_patch_ids: selectedPatchIds, automatic, targets: batch.targets }, + }); + setMemoryActivity(`待处理 ${blockers.length}/${batch.targets.length} · 成功目标不会重复内化`); } + await refreshMemoryConsole().catch(() => undefined); + break; } } catch (err) { setMemoryActivity(`failed · ${(err as Error).message}`); } finally { intakeInFlightRef.current = false; } - }, [agent, currentSessionId, memoryController, refreshMemoryConsole, sessionStore]); + }, [memoryController, persistMemoryRuntimeState, refreshMemoryConsole]); + + const startMemoryBatch = useCallback(async ( + targets: AgoraMemoryIntakeTarget[], + profileId: string, + automatic = false, + sourceStart?: number, + sourceEnd?: number, + localSourceEnd?: number + ) => { + if (!memoryController || intakeInFlightRef.current) throw new Error('已有内化任务正在执行。'); + const profiles = await memoryController.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error('当前项目尚未建立 Memory 组合。'); + const localEnd = localSourceEnd ?? sessionStore.load(currentSessionId).length; + const persisted = sessionStore.list(100).find((session) => session.id === currentSessionId)?.providerState; + const end = sourceEnd ?? Number( + agent.getProviderState?.()?.memory?.runtime_message_count ?? + persisted?.memory?.runtime_message_count ?? + 0 + ); + if (!Number.isInteger(end) || end <= 0) throw new Error('Agora runtime 尚未确认可内化的 source range;请先完成一次对话。'); + const batch = await memoryController.startBatchIntake({ + targets, + ...(Number.isInteger(sourceStart) ? { source_message_start: sourceStart } : {}), + ...(Number.isInteger(end) && end > 0 ? { source_message_end: end } : {}), + }); + setMemoryActivity(`内化已提交 0/${batch.targets.length}`); + persistMemoryRuntimeState({ + active_batch: { + batch_id: batch.batch_id, + profile_id: profileId, + source_start: sourceStart ?? 0, + source_end: end, + local_source_end: localEnd, + selected_patch_ids: profile.active_memory_patch_ids, + automatic, + targets: batch.targets, + }, + }); + void monitorMemoryBatch(batch.batch_id, profileId, end, localEnd, [...profile.active_memory_patch_ids], automatic); + }, [agent, currentSessionId, memoryController, monitorMemoryBatch, persistMemoryRuntimeState, sessionStore]); const handleConfirm = useCallback( (approved: boolean) => { @@ -226,6 +317,7 @@ export function App({ config, connections, agent, sessionStore, currentSessionId revertLastTurn: handleRevertLastTurn, openModelPicker, openMemoryConsole, + startMemoryIntake: (targets) => startMemoryBatch(targets, memoryProfileId), switchModelChoice, }); if (text === '/clear') { @@ -251,34 +343,69 @@ export function App({ config, connections, agent, sessionStore, currentSessionId send(text); } }, - [agent, connections, app, store, send, log, pendingImages, handleRevertLastTurn, config, openModelPicker, openMemoryConsole, switchModelChoice] + [agent, connections, app, store, send, log, pendingImages, handleRevertLastTurn, config, memoryProfileId, openModelPicker, openMemoryConsole, startMemoryBatch, switchModelChoice] ); useEffect(() => { if (!memoryController || config.model.provider?.toLowerCase() !== 'agora') return; const timer = setInterval(async () => { if (intakeInFlightRef.current || thinkingRef.current || pendingConfirm || sessionPickerSessions || modelPickerModels || memoryConsole) return; - const profileId = agent.getProviderState?.()?.memory?.profile_id; + if (!memoryController.getCapabilities().memoryV2) return; + const persistedState = sessionStore.list(100).find((session) => session.id === currentSessionId)?.providerState; + const runtimeState = persistedState ?? agent.getProviderState?.(); + const activeBatch = runtimeState?.memory?.active_batch as Record | undefined; + if (activeBatch?.batch_id) { + const targets = Array.isArray(activeBatch.targets) ? activeBatch.targets : []; + const unresolved = targets.some((target: any) => !['completed', 'noop', 'review', 'conflict', 'failed'].includes(String(target?.status))); + if (unresolved) { + void monitorMemoryBatch( + String(activeBatch.batch_id), + String(activeBatch.profile_id), + Number(activeBatch.source_end ?? 0), + Number(activeBatch.local_source_end ?? 0), + Array.isArray(activeBatch.selected_patch_ids) ? activeBatch.selected_patch_ids.map(String) : [], + Boolean(activeBatch.automatic) + ); + } + return; + } + const profileId = runtimeState?.memory?.profile_id; if (!profileId) return; try { const profiles = await memoryController.listProfiles(); const profile = profiles.find((item) => item.id === profileId); const policy = profile?.auto_intake_policy; - if (!profile || !profile.writable_patch_family || !policy?.enabled) return; + const targetIds = profile?.auto_intake_target_memory_ids ?? []; + if (!profile || !policy?.enabled || targetIds.length === 0) return; const idleSeconds = policy.idle_seconds ?? 60; if (Date.now() - lastUserActivityRef.current < idleSeconds * 1000) return; - const status = await memoryController.getIntakeStatus(); - if (status.active_job) return; - const turnsReady = Number(status.pending_user_turns ?? 0) >= Number(policy.min_user_turns ?? 4); - const tokensReady = Number(status.pending_tokens ?? 0) >= Number(policy.min_pending_tokens ?? 2000); - if (!turnsReady && !tokensReady) return; - void runMemoryIntake(profileId); + const messages = sessionStore.load(currentSessionId); + const [memories, patches] = await Promise.all([ + memoryController.listMemories(), + memoryController.listPatches(true), + ]); + const plan = planAgoraAutoIntake({ + messages, + checkpointEnd: Number(runtimeState?.memory?.last_auto_intake_message_end ?? 0), + profile, + memories, + patches, + }); + if (!plan.ready) return; + const runtimeEnd = Number(runtimeState?.memory?.runtime_message_count ?? 0); + const runtimeStart = Number(runtimeState?.memory?.last_auto_intake_runtime_message_end ?? 0); + if (runtimeStart > 0 && runtimeStart >= runtimeEnd) { + setMemoryActivity('自动内化已暂停 · Agora source 窗口发生回退,请手动确认新范围,未重复内化'); + return; + } + if (runtimeEnd <= runtimeStart) return; + await startMemoryBatch(plan.targets, profileId, true, runtimeStart, runtimeEnd, plan.sourceEnd); } catch (err) { setMemoryActivity(`stale · ${(err as Error).message}`); } }, 5000); return () => clearInterval(timer); - }, [agent, config.model.provider, memoryConsole, memoryController, modelPickerModels, pendingConfirm, runMemoryIntake, sessionPickerSessions]); + }, [agent, config.model.provider, currentSessionId, memoryConsole, memoryController, modelPickerModels, monitorMemoryBatch, pendingConfirm, sessionPickerSessions, sessionStore, startMemoryBatch]); const openSessionPicker = useCallback(() => { const allSessions = sessionStore.list(50); @@ -447,33 +574,100 @@ export function App({ config, connections, agent, sessionStore, currentSessionId {memoryConsole ? ( { - void memoryController?.selectProfile(profileId).then(() => { - setMemoryActivity('已选择 · 下一次真实对话生效'); - return refreshMemoryConsole(); - }); + activeBatch={persistedProviderState?.memory?.active_batch as Record | undefined} + sourceRange={{ start: 0, end: Number(providerState?.memory?.runtime_message_count ?? 0) }} + busy={Boolean(thinking || pendingConfirm)} + onApply={async (memoryIds) => { + if (!memoryController) throw new Error('Agora Memory controller unavailable'); + await memoryController.mountMemories(memoryProfileId, memoryIds, 'project'); + setMemoryActivity('记忆组合已更新 · 下一次真实对话验证 · 基座无需重载'); + await refreshMemoryConsole(); }} - onApply={(profileId, patchIds, writableFamily) => { - void memoryController?.applyPatchSelection(profileId, patchIds, writableFamily).then(() => { - setMemoryActivity('Patch 已更新 · 下一次真实对话生效'); - return refreshMemoryConsole(); - }); + onCreate={async (name) => { + if (!memoryController) throw new Error('Agora Memory controller unavailable'); + await memoryController.createMemory(name); + await refreshMemoryConsole(); + }} + onRename={async (memoryId, name) => { + if (!memoryController) throw new Error('Agora Memory controller unavailable'); + await memoryController.renameMemory(memoryId, name); + await refreshMemoryConsole(); + }} + onAuto={async (enabled, targetIds) => { + if (!memoryController) throw new Error('Agora Memory controller unavailable'); + if (!activeMemoryProfile) { + await memoryController.mountMemories(memoryProfileId, [], 'project'); + } + if (enabled && targetIds.length === 0) throw new Error('开启自动内化前请先用 a 选择至少一个目标。'); + await memoryController.setAutoPolicy(memoryProfileId, enabled, targetIds); + setMemoryActivity(enabled ? `自动内化已开启 · ${targetIds.length} 个目标` : '自动内化已关闭'); + await refreshMemoryConsole(); }} - onCreate={(name) => { - const profileId = `ma-${name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-').replace(/^-|-$/g, '') || 'memory'}-${Date.now().toString(36)}`; - void memoryController?.createProfile({ profile_id: profileId, name }).then(() => refreshMemoryConsole()); + onInternalize={async (targets) => { + await startMemoryBatch(targets, memoryProfileId); }} - onRename={(profileId, name) => { - void memoryController?.renameProfile(profileId, name).then(() => refreshMemoryConsole()); + onRollback={async (memory, targetPatchId) => { + if (!memoryController || !memory.head_patch_id) throw new Error('Memory 没有可回滚的当前版本。'); + await memoryController.rollbackMemory(memory.id, memory.head_patch_id, targetPatchId); + setMemoryActivity('Memory head 已回滚 · 当前会话挂载未被自动覆盖'); + await refreshMemoryConsole(); }} - onAuto={(profileId, enabled) => { - void memoryController?.setAutoPolicy(profileId, enabled).then(() => refreshMemoryConsole()); + onRetryBlocked={async () => { + if (!memoryController) throw new Error('Agora Memory controller unavailable'); + const active = persistedProviderState?.memory?.active_batch as Record | undefined; + const rawTargets = Array.isArray(active?.targets) ? active.targets : []; + const retryable = rawTargets.filter((target: any) => + ['review', 'conflict'].includes(String(target?.status)) || + (target?.status === 'failed' && target?.error?.retryable !== false) + ); + if (retryable.length === 0) throw new Error('没有可重试的失败目标。'); + const targets: AgoraMemoryIntakeTarget[] = []; + for (const target of retryable) { + if (target.mode === 'create') { + targets.push({ + mode: 'create', + name: String(target.memory_name ?? target.name ?? ''), + memory_id: typeof target.memory_id === 'string' ? target.memory_id : undefined, + output_name: String(target.output_name), + }); + } else { + const memory = await memoryController.getMemory(String(target.memory_id)); + targets.push({ + mode: 'increment', + memory_id: memory.id, + expected_parent_patch_id: memory.head_patch_id ?? null, + output_name: String(target.output_name), + }); + } + } + await startMemoryBatch( + targets, + String(active?.profile_id ?? memoryProfileId), + Boolean(active?.automatic), + Number(active?.source_start ?? 0), + Number(active?.source_end ?? 0), + Number(active?.local_source_end ?? sessionStore.load(currentSessionId).length) + ); + }} + onAbandonBlocked={async () => { + const active = persistedProviderState?.memory?.active_batch as Record | undefined; + if (!active) throw new Error('没有待处理的内化目标。'); + persistMemoryRuntimeState({ + active_batch: undefined, + ...(active.automatic ? { + last_auto_intake_message_end: Number(active.local_source_end ?? 0), + last_auto_intake_runtime_message_end: Number(active.source_end ?? 0), + } : {}), + }); + setMemoryActivity('未完成目标已明确放弃;成功目标保留,自动 checkpoint 已按本轮范围推进'); + await refreshMemoryConsole(); }} - onInternalize={(profileId) => { void runMemoryIntake(profileId); }} onCancel={() => setMemoryConsole(null)} /> ) : null} @@ -497,6 +691,7 @@ export function App({ config, connections, agent, sessionStore, currentSessionId contextThreshold={contextUsage.compactThreshold} contextSource={contextUsage.source} memoryActivity={memoryActivity} + animateMemory={!thinking && !pendingConfirm} /> ); diff --git a/src/cli/components/MemoryConsole.tsx b/src/cli/components/MemoryConsole.tsx index 3d2f038..e190d5c 100644 --- a/src/cli/components/MemoryConsole.tsx +++ b/src/cli/components/MemoryConsole.tsx @@ -1,132 +1,299 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Box, Text, useInput } from 'ink'; import TextInput from 'ink-text-input'; -import type { AgoraMemoryPatch, AgoraMemoryProfile } from '../../provider/agora.js'; +import type { + AgoraMemory, + AgoraMemoryIntakeTarget, + AgoraMemoryPatch, + AgoraMemoryProfile, +} from '../../provider/agora.js'; export interface MemoryConsoleProps { project: string; - profiles: AgoraMemoryProfile[]; + memories: AgoraMemory[]; patches: AgoraMemoryPatch[]; - activeProfileId?: string; + profile?: AgoraMemoryProfile; + verifiedPatchIds?: string[]; + verificationStatus?: string; activity?: string; - onUse(profileId: string): void; - onApply(profileId: string, patchIds: string[], writableFamily: string | null): void; - onCreate(name: string): void; - onRename(profileId: string, name: string): void; - onAuto(profileId: string, enabled: boolean): void; - onInternalize(profileId: string): void; + activeBatch?: Record; + sourceRange?: { start: number; end: number }; + busy?: boolean; + onApply(memoryIds: string[]): Promise | void; + onCreate(name: string): Promise | void; + onRename(memoryId: string, name: string): Promise | void; + onAuto(enabled: boolean, targetMemoryIds: string[]): Promise | void; + onInternalize(targets: AgoraMemoryIntakeTarget[]): Promise | void; + onRollback(memory: AgoraMemory, targetPatchId: string): Promise | void; + onRetryBlocked(): Promise | void; + onAbandonBlocked(): Promise | void; onCancel(): void; } +type Mode = 'main' | 'intake' | 'history'; +type EditMode = 'create' | 'rename' | 'new-target' | 'output-name' | null; +const FLOW_COLORS = ['cyan', 'blue', 'magenta', 'blue'] as const; + +function nextOutputName(memory: AgoraMemory, patches: AgoraMemoryPatch[]): string { + const versions = patches.filter((patch) => patch.memory_id === memory.id); + return `${memory.name}@v${versions.length + 1}`; +} + export function MemoryConsole(props: MemoryConsoleProps) { - const initial = Math.max(0, props.profiles.findIndex((profile) => profile.id === props.activeProfileId)); - const [profileIndex, setProfileIndex] = useState(initial); - const [patchIndex, setPatchIndex] = useState(0); - const [focus, setFocus] = useState<'profiles' | 'patches'>('profiles'); + const [index, setIndex] = useState(0); + const [mode, setMode] = useState('main'); + const [historyMemoryId, setHistoryMemoryId] = useState(null); const [selected, setSelected] = useState([]); - const [writableFamily, setWritableFamily] = useState(null); - const [editMode, setEditMode] = useState<'create' | 'rename' | null>(null); + const [autoTargets, setAutoTargets] = useState([]); + const [intakeTargets, setIntakeTargets] = useState([]); + const [newTargets, setNewTargets] = useState>([]); + const [outputNames, setOutputNames] = useState>({}); + const [intakeConfirm, setIntakeConfirm] = useState(false); + const [editMode, setEditMode] = useState(null); const [editValue, setEditValue] = useState(''); - const profile = props.profiles[profileIndex]; - const compatiblePatches = useMemo( - () => props.patches.filter((patch) => !profile || patch.base_model_id === profile.base_model_id), - [profile, props.patches] + const [error, setError] = useState(''); + const [flowIndex, setFlowIndex] = useState(0); + + const memory = mode === 'history' + ? props.memories.find((item) => item.id === historyMemoryId) + : props.memories[index]; + const history = useMemo( + () => props.patches.filter((patch) => patch.memory_id === memory?.id), + [memory?.id, props.patches] ); + const mountedIds = useMemo(() => new Set( + props.profile?.active_memory_patch_ids + .map((patchId) => props.patches.find((patch) => patch.id === patchId)?.memory_id) + .filter((id): id is string => Boolean(id)) ?? [] + ), [props.patches, props.profile?.active_memory_patch_ids]); + const verifiedIds = useMemo(() => new Set(props.verifiedPatchIds ?? []), [props.verifiedPatchIds]); + const canAnimate = !props.busy && !process.env.NO_COLOR && !process.env.MA_REDUCED_MOTION && process.env.TERM !== 'dumb'; + + useEffect(() => { + setSelected([...mountedIds]); + setAutoTargets(props.profile?.auto_intake_target_memory_ids ?? []); + }, [mountedIds, props.profile?.auto_intake_target_memory_ids]); useEffect(() => { - setSelected(profile?.active_memory_patch_ids ?? []); - setWritableFamily(profile?.writable_patch_family ?? null); - }, [profile?.id]); + if (!canAnimate) return; + const timer = setInterval(() => setFlowIndex((value) => (value + 1) % FLOW_COLORS.length), 500); + return () => clearInterval(timer); + }, [canAnimate]); + + const run = async (action: () => Promise | void) => { + setError(''); + try { + await action(); + return true; + } catch (err) { + setError((err as Error).message); + return false; + } + }; useInput((input, key) => { if (editMode) return; - if (key.escape) return props.onCancel(); - if (key.tab) return setFocus((value) => value === 'profiles' ? 'patches' : 'profiles'); - if (key.upArrow) { - if (focus === 'profiles') setProfileIndex((value) => Math.max(0, value - 1)); - else setPatchIndex((value) => Math.max(0, value - 1)); + if (key.escape) { + if (mode !== 'main') { + setMode('main'); + setHistoryMemoryId(null); + setIndex(0); + } else props.onCancel(); + return; + } + const rowCount = mode === 'history' ? history.length : props.memories.length; + if (key.upArrow) return setIndex((value) => Math.max(0, value - 1)); + if (key.downArrow) return setIndex((value) => Math.min(Math.max(0, rowCount - 1), value + 1)); + + if (mode === 'history') { + if (input === 'r' && memory && history[index]) { + void run(() => props.onRollback(memory, history[index].id)); + } return; } - if (key.downArrow) { - if (focus === 'profiles') setProfileIndex((value) => Math.min(props.profiles.length - 1, value + 1)); - else setPatchIndex((value) => Math.min(compatiblePatches.length - 1, value + 1)); + + if (input === ' ' && memory) { + setIntakeConfirm(false); + const setter = mode === 'intake' ? setIntakeTargets : setSelected; + setter((value) => value.includes(memory.id) + ? value.filter((id) => id !== memory.id) + : [...value, memory.id]); return; } - if (input === 'n') { + if (mode === 'main' && input === 'n') { setEditValue(''); setEditMode('create'); return; } - if (input === 'e' && profile) { - setEditValue(profile.name); + if (mode === 'main' && input === 'e' && memory) { + setEditValue(memory.name); setEditMode('rename'); return; } - if (input === 'a' && profile) { - props.onAuto(profile.id, !profile.auto_intake_policy?.enabled); + if (mode === 'main' && input === 'a' && memory) { + setAutoTargets((value) => value.includes(memory.id) + ? value.filter((id) => id !== memory.id) + : [...value, memory.id]); + return; + } + if (mode === 'main' && input === 't' && props.profile) { + void run(() => props.onAuto(!props.profile?.auto_intake_policy?.enabled, autoTargets)); + return; + } + if (mode === 'main' && input === 'i') { + setIntakeTargets([]); + setNewTargets([]); + setIntakeConfirm(false); + setMode('intake'); + setIndex(0); + return; + } + if (mode === 'main' && input === 'h' && memory) { + setHistoryMemoryId(memory.id); + setMode('history'); + setIndex(0); + return; + } + if (mode === 'main' && input === 'R' && props.activeBatch) { + void run(() => props.onRetryBlocked()); return; } - if (input === 'i' && profile) { - props.onInternalize(profile.id); + if (mode === 'main' && input === 'D' && props.activeBatch) { + void run(() => props.onAbandonBlocked()); return; } - const patch = compatiblePatches[patchIndex]; - if (focus === 'patches' && input === ' ' && patch?.mountable) { - setSelected((value) => value.includes(patch.id) - ? value.filter((id) => id !== patch.id) - : [...value, patch.id]); + if (mode === 'intake' && input === 'n') { + setEditValue(''); + setEditMode('new-target'); return; } - if (focus === 'patches' && input === 'w' && patch?.mountable) { - setWritableFamily((value) => value === patch.family ? null : patch.family); - if (!selected.includes(patch.id)) setSelected((value) => [...value, patch.id]); + if (mode === 'intake' && input === 'v' && memory && intakeTargets.includes(memory.id)) { + setEditValue(outputNames[memory.id] ?? nextOutputName(memory, props.patches)); + setEditMode('output-name'); return; } - if (key.return && profile) { - if (focus === 'profiles') props.onUse(profile.id); - else props.onApply(profile.id, selected, writableFamily); + if (key.return) { + if (mode === 'main') { + void run(() => props.onApply(selected)); + return; + } + if (!intakeConfirm) { + setIntakeConfirm(true); + return; + } + const targets: AgoraMemoryIntakeTarget[] = [ + ...intakeTargets.flatMap((memoryId) => { + const targetMemory = props.memories.find((item) => item.id === memoryId); + if (!targetMemory) return []; + return [{ + mode: 'increment' as const, + memory_id: memoryId, + expected_parent_patch_id: targetMemory.head_patch_id ?? null, + output_name: outputNames[memoryId] ?? nextOutputName(targetMemory, props.patches), + }]; + }), + ...newTargets.map((target) => ({ mode: 'create' as const, ...target })), + ]; + if (targets.length === 0) { + setError('请至少选择一个已有 Memory,或按 n 新建目标。'); + return; + } + void run(async () => { + await props.onInternalize(targets); + setMode('main'); + props.onCancel(); + }); } }); - const submitEdit = (value: string) => { - const name = value.trim(); - if (name) { - if (editMode === 'create') props.onCreate(name); - if (editMode === 'rename' && profile) props.onRename(profile.id, name); - } - setEditMode(null); - setEditValue(''); + const submitEdit = async (raw: string) => { + const value = raw.trim(); + if (!value) return; + const succeeded = await run(async () => { + if (editMode === 'create') await props.onCreate(value); + if (editMode === 'rename' && memory) await props.onRename(memory.id, value); + if (editMode === 'output-name' && memory) { + setIntakeConfirm(false); + setOutputNames((current) => ({ ...current, [memory.id]: value })); + } + if (editMode === 'new-target') { + setIntakeConfirm(false); + const [name, output] = value.split('|').map((part) => part.trim()); + if (!name) throw new Error('请输入 Memory 名称。'); + setNewTargets((current) => [...current, { name, output_name: output || `${name}@v1` }]); + } + }); + if (succeeded) { + setEditMode(null); + setEditValue(''); + } }; + const flowColor = canAnimate ? FLOW_COLORS[flowIndex] : 'cyan'; return ( - Memory Console · 项目: {props.project} - Profiles {focus === 'profiles' ? '‹' : ''} - {props.profiles.length === 0 ? 没有 Profile,按 n 新建 : null} - {props.profiles.map((item, index) => ( - - {focus === 'profiles' && index === profileIndex ? '› ' : ' '} - {item.name} · {item.active_memory_patch_ids.length} patches · auto {item.auto_intake_policy?.enabled ? 'on' : 'off'} - {item.id === props.activeProfileId ? ' · verified' : ''} - - ))} - Patches {focus === 'patches' ? '‹' : ''} - {compatiblePatches.length === 0 ? 没有兼容 Patch : null} - {compatiblePatches.map((patch, index) => { - const checked = selected.includes(patch.id); - const writable = writableFamily === patch.family; - return ( - - {focus === 'patches' && index === patchIndex ? '› ' : ' '} - {checked ? '☑' : '☐'} {patch.name} · {patch.version} · {writable ? '主记忆/可写' : 'overlay'} · {patch.status} - - ); - })} + Memory · {props.project} + {mode === 'history' ? ( + <> + {memory?.name ?? 'Memory'} · 版本历史 + {history.length === 0 ? 暂无版本 : history.map((patch, row) => ( + + {row === index ? '› ' : ' '}{patch.name} · {patch.version} + {patch.id === memory?.head_patch_id ? ' · current head' : ''} + {verifiedIds.has(patch.id) ? ' · mounted' : ''} + + ))} + r CAS 回滚当前版本 · Esc 返回 + + ) : ( + <> + {mode === 'intake' ? '内化目标(同一份对话增量)' : '具名记忆'} + {mode === 'intake' && props.sourceRange ? ( + + Source: 本会话消息 {props.sourceRange.start}~{props.sourceRange.end} + {intakeConfirm ? ` · 确认一次提交 ${intakeTargets.length + newTargets.length} 个目标,再按 Enter` : ''} + + ) : null} + {props.memories.length === 0 ? 没有 Memory,按 n 新建 : null} + {props.memories.map((item, row) => { + const patch = props.patches.find((candidate) => candidate.id === item.head_patch_id); + const checked = mode === 'intake' ? intakeTargets.includes(item.id) : selected.includes(item.id); + const mounted = Boolean(item.head_patch_id && verifiedIds.has(item.head_patch_id)); + return ( + + {row === index ? '› ' : ' '}{checked ? '☑' : '☐'} {item.name} + {patch ? ` · ${patch.version}` : ' · 尚无版本'} + {mounted ? ' · verified' : mountedIds.has(item.id) ? ` · ${props.verificationStatus ?? 'pending'}` : ''} + {mode === 'main' && autoTargets.includes(item.id) ? ' · auto' : ''} + {mode === 'intake' && outputNames[item.id] ? ` → ${outputNames[item.id]}` : ''} + + ); + })} + {mode === 'intake' && newTargets.map((target) => ( + + {target.name} → {target.output_name} + ))} + {mode === 'main' && props.activeBatch ? ( + + 待处理 batch: {Array.isArray(props.activeBatch.targets) + ? props.activeBatch.targets.filter((target: any) => ['review', 'conflict', 'failed'].includes(String(target?.status))).length + : 0} 个目标 · Shift+R 仅重试未完成目标 · Shift+D 放弃并推进 checkpoint + + ) : null} + {mode === 'intake' ? ( + Space 多选增量目标 · n 新建目标(名称 | 版本名)· v 修改版本名 · Enter 预览/确认 · Esc 返回 + ) : ( + Space 挂载 · Enter 应用 · i 内化 · n 新建 · e 重命名 · h 历史 · a 自动目标 · t 自动开关 · Shift+R 重试 · Shift+D 放弃 · Esc 返回 + )} + + )} {editMode ? ( - {editMode === 'create' ? '新建名称: ' : '重命名: '} + + {editMode === 'new-target' ? 'Memory 名称 | 输出版本名: ' : editMode === 'output-name' ? '输出版本名: ' : editMode === 'create' ? '新建 Memory: ' : '重命名: '} + { void submitEdit(value); }} /> + ) : null} + {error ? {error} · 请修改后重试 : null} {props.activity ? Activity: {props.activity} : null} - Tab 切区 · Space 挂载 · w 设主记忆 · Enter 应用 · n 新建 · e 重命名 · a 自动 · i 内化 · Esc 返回 ); } diff --git a/src/cli/components/StatusBar.tsx b/src/cli/components/StatusBar.tsx index f8a8f70..6b8d1f2 100644 --- a/src/cli/components/StatusBar.tsx +++ b/src/cli/components/StatusBar.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, Text } from 'ink'; import type { ProviderSessionState } from '../../mcp/types.js'; @@ -13,6 +13,7 @@ interface StatusBarProps { contextThreshold?: number; contextSource?: string; memoryActivity?: string; + animateMemory?: boolean; } export function StatusBar({ @@ -26,6 +27,7 @@ export function StatusBar({ contextThreshold, contextSource, memoryActivity, + animateMemory = true, }: StatusBarProps) { const isAgora = provider?.toLowerCase() === 'agora'; let ctxLabel = ''; @@ -40,6 +42,27 @@ export function StatusBar({ ctxLabel = ` · ctx: ${formatK(contextUsed)}/${formatK(threshold)} trigger · win ${formatK(contextTotal)}${source}`; if (ctxColor) ctxLabel = ` · ${pct > 100 ? 'context risk' : 'context warning'}${ctxLabel}`; } + const [memoryColorIndex, setMemoryColorIndex] = useState(0); + const memoryColors = ['cyan', 'blue', 'magenta', 'blue'] as const; + const canAnimateMemory = animateMemory && !process.env.NO_COLOR && !process.env.MA_REDUCED_MOTION && process.env.TERM !== 'dumb'; + useEffect(() => { + if (!canAnimateMemory) return; + const timer = setInterval(() => setMemoryColorIndex((value) => (value + 1) % memoryColors.length), 500); + return () => clearInterval(timer); + }, [canAnimateMemory]); + + const memory = providerState?.memory; + const mounted = Array.isArray(memory?.mounted_memories) ? memory.mounted_memories : []; + const memoryStatus = typeof memory?.status === 'string' ? memory.status : 'unknown'; + const firstMounted = mounted[0]; + const memoryLabel = firstMounted + ? `${firstMounted.memory_name}@${firstMounted.version}${mounted.length > 1 ? ` +${mounted.length - 1}` : ''}` + : (memory?.active_memory_patch_ids?.length ?? 0) > 0 + ? `${memory?.active_memory_patch_ids?.length} 个记忆` + : '未挂载'; + const autoNames = mounted + .filter((item) => memory?.auto_target_memory_ids?.includes(item.memory_id)) + .map((item) => item.memory_name); return ( @@ -49,7 +72,15 @@ export function StatusBar({ {taskCount ? ` · tasks: ${taskCount}` : ''} {debug ? ' · 🔧 debug' : ''} - {isAgora ? {formatAgoraMemoryLine(providerState, memoryActivity)} : null} + {isAgora ? ( + + {' '}Memory: {shortText(memoryLabel, 42)} + {` · ${memoryStatus}`} + {autoNames.length > 0 ? ` · auto: ${autoNames.join(', ')}` : ''} + {providerState?.runtime_trust === 'unverified' ? ' · unverified runtime' : ''} + {memoryActivity ? ` · ${memoryActivity}` : ''} + + ) : null} {' '}Ctrl+V 图片 · ESC 中断 · 双击 ESC 切会话 · /memory 记忆 · /quit 退出 ); @@ -66,17 +97,3 @@ function shortText(value: string | undefined, max = 24): string { if (text.includes('/')) return text.split('/').slice(-1)[0].slice(0, max); return `${text.slice(0, max - 1)}…`; } - -function formatAgoraMemoryLine(state?: ProviderSessionState | null, activity?: string): string { - const memory = state?.memory; - const status = typeof memory?.status === 'string' && memory.status - ? memory.status - : 'unknown'; - const patches = Array.isArray(memory?.active_memory_patch_ids) - ? memory.active_memory_patch_ids.length - : 0; - const profile = typeof memory?.profile_name === 'string' - ? memory.profile_name - : typeof memory?.profile_id === 'string' ? memory.profile_id : '未选择 Profile'; - return ` Memory: ${shortText(profile, 32)} · ${patches} patches · ${status}${activity ? ` · ${activity}` : ''}`; -} diff --git a/src/cli/utils/commands.ts b/src/cli/utils/commands.ts index 1d7c05f..5c3316b 100644 --- a/src/cli/utils/commands.ts +++ b/src/cli/utils/commands.ts @@ -5,6 +5,7 @@ import { listModelChoices, type ModelChoice, } from './modelProfiles.js'; +import { agoraProjectProfileId, type AgoraMemoryIntakeTarget } from '../../provider/agora.js'; interface CommandContext { agent: Agent; @@ -14,6 +15,7 @@ interface CommandContext { setModel?: (model: string) => void; openModelPicker?: () => Promise | void; openMemoryConsole?: () => Promise | void; + startMemoryIntake?: (targets: AgoraMemoryIntakeTarget[]) => Promise | void; switchModelChoice?: (choice: ModelChoice) => void; revertLastTurn?: () => boolean; } @@ -251,7 +253,7 @@ commands.set('/model', { }); commands.set('/memory', { - description: 'Open Agora Memory console or manage profiles', + description: 'Open Agora Memory console or manage named Memories', suggest: true, handler: async (args, ctx) => { const controller = ctx.agent.getMemoryController?.(); @@ -262,70 +264,140 @@ commands.set('/memory', { return ctx.openMemoryConsole ? null : 'Memory console is unavailable in this UI.'; } try { + const capabilities = controller.getCapabilities(); + if (!capabilities.memoryV2) { + if (trimmed === 'status') return (await controller.status()).content; + const legacyIntake = trimmed.match(/^internalize(?:\s+--into\s+(\S+))?$/); + if (legacyIntake) { + const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; + if (!profileId) return 'Legacy Memory 需要先选择一个 Profile。'; + const result = await controller.startIntake({ profile_id: profileId, into: legacyIntake[1] }); + return `Legacy Memory intake queued: ${result.job_id ?? result.job?.id}`; + } + return `当前 Agora 仅支持 ${capabilities.runtimeMode} Memory;基础对话可用,具名 Memory v2 命令已禁用。`; + } + const [memories, patches] = await Promise.all([controller.listMemories(), controller.listPatches(true)]); + const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id ?? agoraProjectProfileId(process.cwd()); + const profiles = await controller.listProfiles(); + const profile = profiles.find((item) => item.id === profileId); + const resolveMemories = (raw: string) => { + const exact = memories.find((memory) => memory.name === raw.trim() || memory.id === raw.trim()); + if (exact) return [exact]; + const names = raw.includes(',') ? raw.split(',').map((name) => name.trim()).filter(Boolean) : []; + if (names.length > 0) return names.map((name) => { + const found = memories.find((item) => item.name === name || item.id === name); + if (!found) throw new Error(`Memory 不存在: ${name}`); + return found; + }); + const selected: typeof memories = []; + let rest = raw.trim(); + const candidates = [...memories].sort((left, right) => right.name.length - left.name.length); + while (rest) { + const found = candidates.find((item) => rest === item.name || rest.startsWith(`${item.name} `) || rest === item.id || rest.startsWith(`${item.id} `)); + if (!found) throw new Error(`无法识别 Memory 列表: ${rest}(名称含空格时也可用逗号分隔)`); + selected.push(found); + rest = rest.slice(rest.startsWith(found.name) ? found.name.length : found.id.length).trim(); + } + return selected; + }; if (trimmed === 'list') { - const profiles = await controller.listProfiles(); - if (profiles.length === 0) return 'No Agora MemoryProfiles.'; - return profiles.map((profile) => { - const writable = profile.writable_patch_family ? ` · writable=${profile.writable_patch_family}` : ''; - const auto = profile.auto_intake_policy?.enabled ? ' · auto=on' : ' · auto=off'; - return `${profile.id} ${profile.name} · ${profile.active_memory_patch_ids.length} patches${writable}${auto}`; + if (memories.length === 0) return '还没有 Agora Memory。'; + return memories.map((memory) => { + const head = patches.find((patch) => patch.id === memory.head_patch_id); + const mounted = head && profile?.active_memory_patch_ids.includes(head.id) ? ' · mounted/pending' : ''; + return `${memory.name} · ${head?.version ?? '尚无版本'}${mounted}`; }).join('\n'); } if (trimmed === 'status') return (await controller.status()).content; const newMatch = trimmed.match(/^new\s+(.+)$/); if (newMatch) { - const name = newMatch[1].trim(); - const id = `ma-${name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-').replace(/^-|-$/g, '') || 'memory'}-${Date.now().toString(36)}`; - return JSON.stringify(await controller.createProfile({ profile_id: id, name }), null, 2); + return JSON.stringify(await controller.createMemory(newMatch[1].trim()), null, 2); + } + const renameInput = trimmed.match(/^rename\s+(.+)$/)?.[1]; + if (renameInput) { + const explicit = renameInput.match(/^(.+?)\s+--to\s+(.+)$/); + const candidate = explicit + ? resolveMemories(explicit[1])[0] + : [...memories].sort((left, right) => right.name.length - left.name.length) + .find((item) => renameInput.startsWith(`${item.name} `) || renameInput.startsWith(`${item.id} `)); + if (!candidate) throw new Error('重命名格式: /memory rename '); + const prefix = explicit ? explicit[1] : renameInput.startsWith(candidate.name) ? candidate.name : candidate.id; + const nextName = explicit ? explicit[2].trim() : renameInput.slice(prefix.length).trim(); + return JSON.stringify(await controller.renameMemory(candidate.id, nextName), null, 2); + } + const mountMatch = trimmed.match(/^mount\s+(.+?)(?:\s+(--session|--user))?$/); + if (mountMatch) { + const selected = resolveMemories(mountMatch[1]); + const scope = mountMatch[2] === '--session' ? 'conversation' : mountMatch[2] === '--user' ? 'user' : 'project'; + return JSON.stringify(await controller.mountMemories(profileId, selected.map((memory) => memory.id), scope), null, 2); } - const renameMatch = trimmed.match(/^rename\s+(\S+)\s+(.+)$/); - if (renameMatch) { - return JSON.stringify(await controller.renameProfile(renameMatch[1], renameMatch[2].trim()), null, 2); + const unmountMatch = trimmed.match(/^unmount\s+(.+)$/); + if (unmountMatch) { + const remaining = unmountMatch[1].trim() === 'all' + ? [] + : memories.filter((memory) => { + const head = patches.find((patch) => patch.id === memory.head_patch_id); + return Boolean(head && profile?.active_memory_patch_ids.includes(head.id)) && + !resolveMemories(unmountMatch[1]).some((remove) => remove.id === memory.id); + }).map((memory) => memory.id); + return JSON.stringify(await controller.mountMemories(profileId, remaining), null, 2); } - const useMatch = trimmed.match(/^use\s+(\S+)(?:\s+(--session))?$/); - if (useMatch) { - return JSON.stringify( - await controller.selectProfile(useMatch[1], useMatch[2] ? 'conversation' : 'project'), - null, - 2 - ); + if (trimmed === 'internalize') { + await ctx.openMemoryConsole?.(); + return ctx.openMemoryConsole ? null : '请使用 /memory internalize --new 或 --into 。'; } - const intakeMatch = trimmed.match(/^internalize(?:\s+--into\s+(\S+))?$/); - if (intakeMatch) { - const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; - if (!profileId) return 'No verified MemoryProfile is selected.'; - const result = await controller.startIntake({ profile_id: profileId, into: intakeMatch[1] }); - return `Memory intake queued: ${result.job_id ?? result.job?.id}`; + const newIntake = trimmed.match(/^internalize\s+--new\s+(.+)$/); + const intoIntake = trimmed.match(/^internalize\s+--into\s+(.+)$/); + if (newIntake || intoIntake) { + if (!profile) return '请先挂载一个 Memory 组合并完成一次 Agora 对话。'; + const targets = newIntake + ? [{ mode: 'create' as const, name: newIntake[1].trim(), output_name: `${newIntake[1].trim()}@v1` }] + : resolveMemories(intoIntake![1]).map((memory) => ({ + mode: 'increment' as const, + memory_id: memory.id, + expected_parent_patch_id: memory.head_patch_id ?? undefined, + output_name: `${memory.name}@v${patches.filter((patch) => patch.memory_id === memory.id).length + 1}`, + })); + if (ctx.startMemoryIntake) { + await ctx.startMemoryIntake(targets); + return `Memory intake queued · ${targets.length} targets`; + } + const result = await controller.startBatchIntake({ targets }); + return `Memory intake queued: ${result.batch_id} · ${targets.length} targets`; } - const autoMatch = trimmed.match(/^auto\s+(on|off)$/); + const autoMatch = trimmed.match(/^auto\s+(on|off)(?:\s+--targets\s+(.+))?$/); if (autoMatch) { - const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; - if (!profileId) return 'No verified MemoryProfile is selected.'; - return JSON.stringify(await controller.setAutoPolicy(profileId, autoMatch[1] === 'on'), null, 2); + if (!profile) return '请先挂载一个 Memory 组合。'; + const targets = autoMatch[2] ? resolveMemories(autoMatch[2]).map((memory) => memory.id) : profile.auto_intake_target_memory_ids ?? []; + if (autoMatch[1] === 'on' && targets.length === 0) return '开启自动内化必须指定 --targets 。'; + return JSON.stringify(await controller.setAutoPolicy(profileId, autoMatch[1] === 'on', targets), null, 2); } - if (trimmed === 'history') { - const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; - const profiles = await controller.listProfiles(); - const profile = profiles.find((item) => item.id === profileId); - if (!profile?.writable_patch_family) return 'Current profile has no writable memory family.'; - const patches = await controller.listPatches(true); + const historyMatch = trimmed.match(/^history\s+(.+)$/); + if (historyMatch) { + const memory = resolveMemories(historyMatch[1])[0]; return patches - .filter((patch) => patch.family === profile.writable_patch_family) + .filter((patch) => patch.memory_id === memory.id) .map((patch) => `${patch.id} ${patch.name} · ${patch.version} · ${patch.status}`) .join('\n') || 'No versions found.'; } - const rollbackMatch = trimmed.match(/^rollback\s+(\S+)$/); - if (rollbackMatch) { - const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; - if (!profileId) return 'No verified MemoryProfile is selected.'; - return (await controller.rollback({ profile_id: profileId, patch_id: rollbackMatch[1] })).content; + const rollbackInput = trimmed.match(/^rollback\s+(.+)$/)?.[1]; + if (rollbackInput) { + const memory = [...memories].sort((left, right) => right.name.length - left.name.length) + .find((item) => rollbackInput.startsWith(`${item.name} `) || rollbackInput.startsWith(`${item.id} `)); + if (!memory) return '回滚格式: /memory rollback '; + const prefix = rollbackInput.startsWith(memory.name) ? memory.name : memory.id; + const version = rollbackInput.slice(prefix.length).trim(); + if (!memory.head_patch_id) return '该 Memory 还没有版本。'; + const target = patches.find((patch) => patch.memory_id === memory.id && + [patch.id, patch.name, patch.version].includes(version)); + if (!target) return `找不到版本: ${version}`; + return JSON.stringify(await controller.rollbackMemory(memory.id, memory.head_patch_id, target.id), null, 2); } if (trimmed === 'disable') { - const profileId = ctx.agent.getProviderState?.()?.memory?.profile_id; - if (!profileId) return 'No verified MemoryProfile is selected.'; + if (!profile) return '当前没有 Memory 组合。'; return (await controller.disable({ profile_id: profileId })).content; } - return 'usage: /memory | list | new | rename | use [--session] | status | internalize [--into ] | auto on|off | history | rollback | disable'; + return 'usage: /memory | list | mount [--session|--user] | unmount | new | rename --to | internalize [--new |--into ] | auto on --targets | auto off | history | rollback | status | disable'; } catch (err) { return `Memory error: ${(err as Error).message}`; } diff --git a/src/mcp/types.ts b/src/mcp/types.ts index ae8c7ea..29efc18 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -122,6 +122,20 @@ export interface ProviderSessionState { profile_id?: string; binding_id?: string; active_memory_patch_ids?: string[]; + requested_memory_patch_ids?: string[]; + patchset_revision?: number; + mounted_memories?: Array<{ + memory_id: string; + memory_name: string; + patch_id: string; + patch_name: string; + version: string; + }>; + auto_target_memory_ids?: string[]; + active_batch?: Record; + last_auto_intake_message_end?: number; + last_auto_intake_runtime_message_end?: number; + runtime_message_count?: number; last_verified_at?: string; [key: string]: unknown; }; diff --git a/src/provider/agora-auto-intake.ts b/src/provider/agora-auto-intake.ts new file mode 100644 index 0000000..8e4217d --- /dev/null +++ b/src/provider/agora-auto-intake.ts @@ -0,0 +1,74 @@ +import type { + AgoraMemory, + AgoraMemoryIntakeTarget, + AgoraMemoryIntakeTargetResult, + AgoraMemoryPatch, + AgoraMemoryProfile, +} from './agora.js'; + +export interface AgoraAutoIntakePlan { + ready: boolean; + reason: string; + sourceStart: number; + sourceEnd: number; + pendingUserTurns: number; + pendingTokens: number; + targets: AgoraMemoryIntakeTarget[]; +} + +export function planAgoraAutoIntake(args: { + messages: any[]; + checkpointEnd: number; + profile: AgoraMemoryProfile; + memories: AgoraMemory[]; + patches: AgoraMemoryPatch[]; +}): AgoraAutoIntakePlan { + const sourceStart = Math.max(0, Math.min(args.messages.length, Math.floor(args.checkpointEnd))); + const pending = args.messages.slice(sourceStart); + const pendingUserTurns = pending.filter((message) => message?.role === 'user').length; + const pendingTokens = Math.ceil(JSON.stringify(pending).length / 4); + const minTurns = Number(args.profile.auto_intake_policy?.min_user_turns ?? 4); + const minTokens = Number(args.profile.auto_intake_policy?.min_pending_tokens ?? 2000); + const thresholdReached = pendingUserTurns >= minTurns || pendingTokens >= minTokens; + const targetIds = args.profile.auto_intake_target_memory_ids ?? []; + const targets = targetIds.map((memoryId) => { + const memory = args.memories.find((item) => item.id === memoryId); + if (!memory) throw new Error(`自动内化目标不存在: ${memoryId}`); + if (!memory.head_patch_id) throw new Error(`自动内化目标没有可增量版本: ${memory.name}`); + return { + mode: 'increment' as const, + memory_id: memory.id, + expected_parent_patch_id: memory.head_patch_id, + output_name: `${memory.name}@v${args.patches.filter((patch) => patch.memory_id === memory.id).length + 1}`, + }; + }); + const reason = targetIds.length === 0 + ? 'no_explicit_targets' + : pending.length === 0 + ? 'no_pending_messages' + : thresholdReached ? 'ready' : 'below_threshold'; + return { + ready: reason === 'ready', + reason, + sourceStart, + sourceEnd: args.messages.length, + pendingUserTurns, + pendingTokens, + targets, + }; +} + +export function classifyAgoraBatchTargets(targets: AgoraMemoryIntakeTargetResult[]): { + terminal: boolean; + completed: AgoraMemoryIntakeTargetResult[]; + blockers: AgoraMemoryIntakeTargetResult[]; + retryable: AgoraMemoryIntakeTargetResult[]; +} { + const terminalStatuses = new Set(['completed', 'noop', 'review', 'conflict', 'failed']); + return { + terminal: targets.length > 0 && targets.every((target) => terminalStatuses.has(target.status)), + completed: targets.filter((target) => target.status === 'completed' || target.status === 'noop'), + blockers: targets.filter((target) => target.status === 'review' || target.status === 'conflict' || target.status === 'failed'), + retryable: targets.filter((target) => target.status === 'review' || target.status === 'conflict' || (target.status === 'failed' && target.error?.retryable !== false)), + }; +} diff --git a/src/provider/agora.ts b/src/provider/agora.ts index 04f6f7a..f27e851 100644 --- a/src/provider/agora.ts +++ b/src/provider/agora.ts @@ -1,8 +1,9 @@ -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; import { createHash } from 'node:crypto'; import type { ChatCompletion, @@ -44,6 +45,33 @@ export interface AgoraCapabilities { lineageCas: boolean; rollback: boolean; progress: boolean; + memoryV2: boolean; + namedMemories: boolean; + multiTargetIntake: boolean; + incrementalSegments: boolean; + multiPatchMount: boolean; + requestBoundaryHotSwap: boolean; + runtimeMode: 'v2' | 'legacy' | 'unavailable'; +} + +export interface AgoraRuntimeContract { + runtime_version?: string; + host_protocol?: string; + host_protocol_major?: number; + registry_schema_version?: number; + native_core_abi?: number; + capabilities?: Record; + memory_runtime_v2?: { enabled?: boolean; rollback?: string }; +} + +export interface AgoraMemory { + id: string; + name: string; + normalized_name?: string; + base_model_id: string; + head_patch_id?: string | null; + status: string; + metadata?: Record; } export interface AgoraMemoryProfile { @@ -51,6 +79,7 @@ export interface AgoraMemoryProfile { name: string; base_model_id: string; active_memory_patch_ids: string[]; + auto_intake_target_memory_ids?: string[]; writable_patch_family?: string | null; auto_intake_policy?: { enabled?: boolean; @@ -71,20 +100,96 @@ export interface AgoraMemoryPatch { version: string; mountable: boolean; status: string; + memory_id?: string | null; + normalized_name?: string | null; + parent_patch_id?: string | null; + segment_ids?: string[]; +} + +export type AgoraIntakeTargetStatus = + | 'queued' + | 'compiling' + | 'completed' + | 'noop' + | 'review' + | 'conflict' + | 'failed'; + +export interface AgoraMemoryIntakeTarget { + mode: 'create' | 'increment'; + name?: string; + memory_id?: string; + expected_parent_patch_id?: string | null; + output_name: string; +} + +export interface AgoraMemoryIntakeTargetResult { + id: string; + batch_id: string; + mode: 'create' | 'increment'; + memory_id?: string | null; + memory_name?: string | null; + output_name: string; + expected_parent_patch_id?: string | null; + status: AgoraIntakeTargetStatus; + output_patch_id?: string | null; + error?: { code?: string; message?: string; retryable?: boolean } | null; + result?: Record | null; +} + +export interface AgoraMemoryIntakeBatchResult { + status: string; + batch_id: string; + batch?: Record; + targets: AgoraMemoryIntakeTargetResult[]; +} + +export class AgoraMcpError extends Error { + readonly code?: string; + readonly field?: string; + readonly retryable?: boolean; + readonly payload: Record; + + constructor(message: string, payload: Record) { + super(message); + this.name = 'AgoraMcpError'; + const error = asRecord(payload.error) ?? {}; + this.code = typeof error.code === 'string' + ? error.code + : typeof error.type === 'string' ? error.type : undefined; + this.field = typeof error.field === 'string' ? error.field : undefined; + this.retryable = typeof error.retryable === 'boolean' ? error.retryable : undefined; + this.payload = payload; + } } export interface AgoraMemoryController { getCapabilities(): AgoraCapabilities; + getRuntimeContract(): AgoraRuntimeContract | null; + updateLocalMemoryState(patch: Record): void; + listMemories(): Promise; + getMemory(memoryId: string): Promise; + createMemory(name: string, id?: string): Promise; + renameMemory(memoryId: string, name: string): Promise; listProfiles(): Promise; listPatches(includeDisabled?: boolean): Promise; createProfile(args: Record): Promise>; renameProfile(profileId: string, name: string): Promise>; - selectProfile(profileId: string, scope?: 'project' | 'conversation'): Promise>; + selectProfile(profileId: string, scope?: 'user' | 'project' | 'conversation'): Promise>; applyPatchSelection(profileId: string, patchIds: string[], writableFamily?: string | null): Promise>; + mountMemories(profileId: string, memoryIds: string[], scope?: 'user' | 'project' | 'conversation'): Promise>; getIntakeStatus(): Promise>; startIntake(args?: Record): Promise>; finalizeIntake(jobId: string, profileId: string): Promise>; - setAutoPolicy(profileId: string, enabled: boolean): Promise>; + startBatchIntake(args: { + targets: AgoraMemoryIntakeTarget[]; + source_message_start?: number; + source_message_end?: number; + }): Promise; + getBatchIntake(batchId: string): Promise; + applyCompletedBatch(batch: AgoraMemoryIntakeBatchResult, profileId: string): Promise>; + setAutoPolicy(profileId: string, enabled: boolean, targetMemoryIds?: string[]): Promise>; + rollbackMemory(memoryId: string, expectedHeadPatchId: string, targetPatchId: string): Promise; listModels(): Promise[]>; downloadModel(modelId: string, onProgress?: (event: McpProgressEvent) => void): Promise>; status(args?: Record): Promise; @@ -158,14 +263,31 @@ interface ResolvedAgoraCommand { command: string; args: string[]; trust: 'verified' | 'unverified'; - source: 'configured' | 'override' | 'bundled' | 'path'; + source: 'configured' | 'override' | 'bundled' | 'npm' | 'path'; lock?: Record; } +function verifyNativeSignature(command: string): boolean { + if (process.platform !== 'darwin') return true; + return spawnSync('/usr/bin/codesign', ['--verify', '--strict', '--verbose=2', command], { stdio: 'ignore' }).status === 0; +} + function sha256File(file: string): string { return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } +export function verifyAgoraManifestFiles(root: string, manifest: Record): boolean { + const files = asRecord(manifest.files); + if (!files || Object.keys(files).length === 0) return false; + const canonicalRoot = path.resolve(root); + return Object.entries(files).every(([relative, expected]) => { + if (typeof expected !== 'string' || path.isAbsolute(relative)) return false; + const target = path.resolve(canonicalRoot, relative); + if (!target.startsWith(`${canonicalRoot}${path.sep}`) || !fs.statSync(target, { throwIfNoEntry: false })?.isFile()) return false; + return sha256File(target) === expected; + }); +} + function verifyBundledAgora(command: string): Record | null { const root = path.dirname(path.dirname(command)); const manifestPath = path.join(root, 'manifest.json'); @@ -174,16 +296,48 @@ function verifyBundledAgora(command: string): Record | null { try { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); - const expected = manifest?.files?.['bin/agora']; - if (typeof expected !== 'string' || sha256File(command) !== expected) return null; + if (!verifyAgoraManifestFiles(root, manifest)) return null; if (manifest.version !== lock.version || manifest.host_protocol_major !== lock.host_protocol_major) return null; + if (lock.native_core_abi !== undefined && manifest.native_core_abi !== lock.native_core_abi) return null; if (lock.manifest_sha256 && sha256File(manifestPath) !== lock.manifest_sha256) return null; + if (!verifyNativeSignature(command)) return null; return lock; } catch { return null; } } +function resolveInstalledAgoraPackage(): ResolvedAgoraCommand | null { + try { + const require = createRequire(import.meta.url); + const packageJsonPath = require.resolve('@zimoos/agora/package.json'); + const packageRoot = path.dirname(packageJsonPath); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, 'manifest.json'), 'utf8')); + const command = path.join(packageRoot, 'bin', 'agora'); + const requiredCapabilities = ['mcp-stdio', 'memory-profile-v2', 'memory-intake-v2']; + if ( + packageJson.version !== '0.2.0' || + packageJson.dependencies?.['@zimoos/agora-darwin-arm64'] !== '0.2.0' || + manifest.version !== '0.2.0' || + manifest.host_protocol_major !== 1 || + manifest.native_core_abi !== 1 || + !requiredCapabilities.every((capability) => manifest.capabilities?.includes(capability)) || + !commandExists(command) || + !verifyNativeSignature(command) + ) return null; + return { + command, + args: ['mcp', 'serve'], + trust: 'verified', + source: 'npm', + lock: { version: '0.2.0', host_protocol_major: 1, native_core_abi: 1 }, + }; + } catch { + return null; + } +} + function resolveAgoraCommand(runtime?: AgoraRuntimeConfig): ResolvedAgoraCommand { const configured = runtime?.command?.trim(); if (configured) return { command: configured, args: runtime?.args ?? ['mcp', 'serve'], trust: 'unverified', source: 'configured' }; @@ -198,6 +352,9 @@ function resolveAgoraCommand(runtime?: AgoraRuntimeConfig): ResolvedAgoraCommand return { command: candidate, args: ['mcp', 'serve'], trust: 'verified', source: 'bundled', lock }; } + const installed = resolveInstalledAgoraPackage(); + if (installed) return installed; + if (process.env.MA_AGORA_ALLOW_UNVERIFIED === '1') { const pathAgora = findOnPath('agora'); if (pathAgora) return { command: pathAgora, args: ['mcp', 'serve'], trust: 'unverified', source: 'path' }; @@ -237,6 +394,10 @@ function defaultProjectId(cwd?: string): string { return `${base}-${suffix}`; } +export function agoraProjectProfileId(cwd?: string): string { + return `ma-project-${defaultProjectId(cwd).toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}`; +} + function toStringList(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0); @@ -262,7 +423,10 @@ function normalizeToolCalls(value: unknown): ChatCompletionMessageToolCall[] | u return out.length > 0 ? out : undefined; } -function stateFromChatPayload(payload: Record): ProviderSessionState { +function stateFromChatPayload( + payload: Record, + requestedPatchIds?: string[] | null +): ProviderSessionState { const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}; const memory = payload.memory && typeof payload.memory === 'object' @@ -284,8 +448,17 @@ function stateFromChatPayload(payload: Record): ProviderSessionStat const bindingId = typeof memory.binding_id === 'string' ? memory.binding_id : undefined; const enabled = typeof memory.enabled === 'boolean' ? memory.enabled : undefined; const reason = typeof memory.reason === 'string' ? memory.reason : undefined; + const runtime = asRecord(payload.memory_runtime) ?? asRecord(metadata.memory_runtime) ?? {}; + const revision = Number(runtime.patchset_revision); + const requested = requestedPatchIds ? [...requestedPatchIds] : undefined; + const matchesRequest = requested === undefined || ( + requested.length === activePatchIds.length && + requested.every((id, index) => id === activePatchIds[index]) + ); const status = enabled === false ? 'disabled' + : !matchesRequest + ? 'stale' : activePatchIds.length > 0 ? 'mounted' : profileId @@ -305,6 +478,8 @@ function stateFromChatPayload(payload: Record): ProviderSessionStat profile_id: profileId, binding_id: bindingId, active_memory_patch_ids: activePatchIds, + ...(requested ? { requested_memory_patch_ids: requested } : {}), + ...(Number.isFinite(revision) ? { patchset_revision: revision } : {}), enabled, reason, last_verified_at: verifiedAt, @@ -431,6 +606,9 @@ export class AgoraProviderRuntime implements AgoraMemoryController { private selectedProfileId: string | null = null; private doctorPayload: Record | null = null; private modelsPayload: Record | null = null; + private runtimeContract: AgoraRuntimeContract | null = null; + private requestedPatchIds: string[] | null = null; + private requestedAfterRevision: number | null = null; private runtimeTrust: 'verified' | 'unverified' = 'unverified'; private runtimeSource = 'unknown'; @@ -455,6 +633,14 @@ export class AgoraProviderRuntime implements AgoraMemoryController { getCapabilities(): AgoraCapabilities { const has = (name: string) => this.toolNames.has(name); + const contract = this.runtimeContract?.capabilities ?? {}; + const enabled = (name: string) => contract[name] === 1 || contract[name] === true; + const namedMemories = has('memories_create') && has('memories_list') && has('memories_rename') && enabled('named_memories'); + const multiTargetIntake = has('memory_intake_batch_run') && has('memory_intake_batch_get') && enabled('multi_target_intake'); + const incrementalSegments = enabled('incremental_segments'); + const multiPatchMount = enabled('multi_model_delta_mount'); + const requestBoundaryHotSwap = enabled('request_boundary_hot_swap'); + const memoryV2 = namedMemories && multiTargetIntake && incrementalSegments && multiPatchMount && requestBoundaryHotSwap && has('memories_rollback'); return { chat: has('chat_complete'), modelCatalog: has('models_list') && has('models_status'), @@ -467,6 +653,28 @@ export class AgoraProviderRuntime implements AgoraMemoryController { lineageCas: has('memory_lineage_advance'), rollback: has('memory_patch_versions'), progress: true, + memoryV2, + namedMemories, + multiTargetIntake, + incrementalSegments, + multiPatchMount, + requestBoundaryHotSwap, + runtimeMode: memoryV2 + ? 'v2' + : has('memory_intake_run') && has('memory_profiles_update') ? 'legacy' : 'unavailable', + }; + } + + getRuntimeContract(): AgoraRuntimeContract | null { + return this.runtimeContract ? { ...this.runtimeContract } : null; + } + + updateLocalMemoryState(patch: Record): void { + this.lastState = { + ...(this.lastState ?? { provider_id: 'agora' }), + memory: { ...(this.lastState?.memory ?? {}), ...patch }, + runtime_trust: this.runtimeTrust, + runtime_source: this.runtimeSource, }; } @@ -478,6 +686,40 @@ export class AgoraProviderRuntime implements AgoraMemoryController { return this.toolNames.has('chat_complete') ? this : null; } + async listMemories(): Promise { + this.requireV2(); + const payload = await this.callJsonTool('memories_list', { base_model_id: this.model.model }); + return Array.isArray(payload.memories) ? payload.memories as AgoraMemory[] : []; + } + + async getMemory(memoryId: string): Promise { + this.requireV2(); + const payload = await this.callJsonTool('memories_get', { memory_id: memoryId }); + const memory = asRecord(payload.memory); + if (!memory) throw new Error(`Agora did not return Memory: ${memoryId}`); + return memory as unknown as AgoraMemory; + } + + async createMemory(name: string, id?: string): Promise { + this.requireV2(); + const payload = await this.callJsonTool('memories_create', { + ...(id ? { id } : {}), + name, + base_model_id: this.model.model, + }); + const memory = asRecord(payload.memory); + if (!memory) throw new Error('Agora did not return the created Memory'); + return memory as unknown as AgoraMemory; + } + + async renameMemory(memoryId: string, name: string): Promise { + this.requireV2(); + const payload = await this.callJsonTool('memories_rename', { memory_id: memoryId, name }); + const memory = asRecord(payload.memory); + if (!memory) throw new Error(`Agora did not return renamed Memory: ${memoryId}`); + return memory as unknown as AgoraMemory; + } + async listProfiles(): Promise { this.requireTools('memory_profiles_list'); const payload = await this.callJsonTool('memory_profiles_list', { base_model_id: this.model.model }); @@ -501,11 +743,12 @@ export class AgoraProviderRuntime implements AgoraMemoryController { name: typeof args.name === 'string' && args.name.trim() ? args.name.trim() : profileId, base_model_id: this.model.model, active_memory_patch_ids: this.patchIdsFromArgs(args), + auto_intake_target_memory_ids: toStringList(args.auto_intake_target_memory_ids), writable_patch_family: typeof args.writable_patch_family === 'string' ? args.writable_patch_family : undefined, auto_intake_policy: args.auto_intake_policy ?? { enabled: false }, memory_enabled: args.memory_enabled !== false, }); - await this.ensureBinding(profileId, args.scope === 'conversation' ? 'conversation' : 'project'); + await this.ensureBinding(profileId, args.scope === 'conversation' ? 'conversation' : args.scope === 'user' ? 'user' : 'project'); this.selectedProfileId = profileId; return payload; } @@ -517,7 +760,7 @@ export class AgoraProviderRuntime implements AgoraMemoryController { async selectProfile( profileId: string, - scope: 'project' | 'conversation' = 'project' + scope: 'user' | 'project' | 'conversation' = 'project' ): Promise> { const profiles = await this.listProfiles(); const profile = profiles.find((item) => item.id === profileId); @@ -536,14 +779,48 @@ export class AgoraProviderRuntime implements AgoraMemoryController { const updated = await this.callJsonTool('memory_profiles_update', { profile_id: profileId, active_memory_patch_ids: patchIds, - writable_patch_family: writableFamily ?? null, + ...(this.getCapabilities().memoryV2 ? {} : { writable_patch_family: writableFamily ?? null }), memory_enabled: true, }); await this.ensureBinding(profileId, 'project'); this.selectedProfileId = profileId; + this.markPatchSelectionPending(profileId, patchIds); return { ...updated, mount_status: 'pending_next_chat' }; } + async mountMemories( + profileId: string, + memoryIds: string[], + scope: 'user' | 'project' | 'conversation' = 'project' + ): Promise> { + this.requireV2(); + const baseProfileId = profileId.split('--conversation-')[0].replace(/--user-default$/, ''); + const effectiveProfileId = scope === 'conversation' + ? `${baseProfileId}--conversation-${this.safeScopeId(this.model.agoraMemory?.conversationId || this.context.sessionId || 'default')}` + : scope === 'user' ? `${baseProfileId}--user-default` : baseProfileId; + const [memories, patches] = await Promise.all([this.listMemories(), this.listPatches(true)]); + const selected = memoryIds.map((memoryId) => { + const memory = memories.find((item) => item.id === memoryId); + if (!memory) throw new Error(`Memory not found: ${memoryId}`); + if (!memory.head_patch_id) throw new Error(`Memory has no compiled version yet: ${memory.name}`); + const patch = patches.find((item) => item.id === memory.head_patch_id); + if (!patch?.mountable) throw new Error(`Memory version is not mountable: ${memory.name}`); + if (patch.base_model_id !== this.model.model) throw new Error(`Memory is incompatible with model ${this.model.model}: ${memory.name}`); + return patch.id; + }); + await this.upsertProfile(effectiveProfileId, selected, true); + const bindingId = await this.ensureBinding(effectiveProfileId, scope); + this.selectedProfileId = effectiveProfileId; + this.markPatchSelectionPending(effectiveProfileId, selected, bindingId); + return { + profile_id: effectiveProfileId, + binding_id: bindingId, + memory_ids: memoryIds, + active_memory_patch_ids: selected, + mount_status: 'pending_next_chat', + }; + } + async getIntakeStatus(): Promise> { this.requireTools('memory_intake_status'); const sessionId = this.requireSessionId(); @@ -604,17 +881,89 @@ export class AgoraProviderRuntime implements AgoraMemoryController { return { ...advanced, outcome: 'activated', mount_status: 'pending_next_chat' }; } - async setAutoPolicy(profileId: string, enabled: boolean): Promise> { + async startBatchIntake(args: { + targets: AgoraMemoryIntakeTarget[]; + source_message_start?: number; + source_message_end?: number; + }): Promise { + this.requireV2(); + if (!Array.isArray(args.targets) || args.targets.length === 0) { + throw new Error('Memory intake requires at least one explicit target'); + } + const payload = await this.callJsonTool('memory_intake_batch_run', { + session_id: this.requireSessionId(), + targets: args.targets, + ...(Number.isInteger(args.source_message_start) ? { source_message_start: args.source_message_start } : {}), + ...(Number.isInteger(args.source_message_end) ? { source_message_end: args.source_message_end } : {}), + }); + return this.normalizeBatch(payload); + } + + async getBatchIntake(batchId: string): Promise { + this.requireV2(); + return this.normalizeBatch(await this.callJsonTool('memory_intake_batch_get', { batch_id: batchId })); + } + + async applyCompletedBatch( + batch: AgoraMemoryIntakeBatchResult, + profileId: string + ): Promise> { + this.requireV2(); + const completed = batch.targets.filter((target) => target.status === 'completed' && target.output_patch_id); + if (completed.length === 0) { + return { outcome: 'no_completed_targets', targets: batch.targets }; + } + const [profiles, patches] = await Promise.all([this.listProfiles(), this.listPatches(true)]); + const profile = profiles.find((item) => item.id === profileId); + if (!profile) throw new Error(`memory profile not found: ${profileId}`); + const completedByMemory = new Map(); + for (const target of completed) { + if (target.memory_id && target.output_patch_id) completedByMemory.set(target.memory_id, target.output_patch_id); + } + const nextIds: string[] = []; + for (const patchId of profile.active_memory_patch_ids) { + const patch = patches.find((item) => item.id === patchId); + const replacement = patch?.memory_id ? completedByMemory.get(patch.memory_id) : undefined; + nextIds.push(replacement ?? patchId); + if (patch?.memory_id) completedByMemory.delete(patch.memory_id); + } + nextIds.push(...completedByMemory.values()); + const mounted = await this.applyPatchSelection(profileId, Array.from(new Set(nextIds))); + return { ...mounted, outcome: 'completed_targets_selected', targets: batch.targets }; + } + + async setAutoPolicy( + profileId: string, + enabled: boolean, + targetMemoryIds?: string[] + ): Promise> { this.requireTools('memory_profiles_update'); const profiles = await this.listProfiles(); const profile = profiles.find((item) => item.id === profileId); if (!profile) throw new Error(`memory profile not found: ${profileId}`); return this.callJsonTool('memory_profiles_update', { profile_id: profileId, + ...(targetMemoryIds ? { auto_intake_target_memory_ids: targetMemoryIds } : {}), auto_intake_policy: { ...profile.auto_intake_policy, enabled }, }); } + async rollbackMemory( + memoryId: string, + expectedHeadPatchId: string, + targetPatchId: string + ): Promise { + this.requireV2(); + const payload = await this.callJsonTool('memories_rollback', { + memory_id: memoryId, + expected_head_patch_id: expectedHeadPatchId, + target_patch_id: targetPatchId, + }); + const memory = asRecord(payload.memory); + if (!memory) throw new Error(`Agora did not return rolled back Memory: ${memoryId}`); + return memory as unknown as AgoraMemory; + } + async listModels(): Promise[]> { this.requireTools('models_list'); const payload = await this.callJsonTool('models_list', {}); @@ -668,6 +1017,7 @@ export class AgoraProviderRuntime implements AgoraMemoryController { models: this.modelsPayload, runtime_trust: this.runtimeTrust, runtime_source: this.runtimeSource, + runtime_contract: this.runtimeContract, }, null, 2 @@ -680,6 +1030,15 @@ export class AgoraProviderRuntime implements AgoraMemoryController { try { this.ensureMemoryReady(); const profileId = this.resolveProfileId(args); + const memoryIds = toStringList(args.memory_ids); + if (this.getCapabilities().memoryV2 && Array.isArray(args.memory_ids)) { + const mounted = await this.mountMemories( + profileId, + memoryIds, + args.scope === 'conversation' ? 'conversation' : args.scope === 'user' ? 'user' : 'project' + ); + return this.ok({ action: 'mount', ...mounted }); + } const patchIds = this.patchIdsFromArgs(args); await this.upsertProfile( profileId, @@ -697,6 +1056,7 @@ export class AgoraProviderRuntime implements AgoraMemoryController { args.scope === 'conversation' ? 'conversation' : 'project' ); this.selectedProfileId = profileId; + this.markPatchSelectionPending(profileId, patchIds, bindingId); return this.ok({ action: 'mount', profile_id: profileId, @@ -714,6 +1074,7 @@ export class AgoraProviderRuntime implements AgoraMemoryController { const profileId = this.resolveProfileId(args); await this.upsertProfile(profileId, undefined, false); this.selectedProfileId = profileId; + this.markPatchSelectionPending(profileId, []); return this.ok({ action: 'disable', profile_id: profileId, @@ -726,6 +1087,21 @@ export class AgoraProviderRuntime implements AgoraMemoryController { async internalize(args: Record): Promise { try { + if (this.getCapabilities().memoryV2) { + const targets = Array.isArray(args.targets) ? args.targets as AgoraMemoryIntakeTarget[] : []; + const batch = await this.startBatchIntake({ + targets, + ...(Number.isInteger(args.source_message_start) ? { source_message_start: args.source_message_start } : {}), + ...(Number.isInteger(args.source_message_end) ? { source_message_end: args.source_message_end } : {}), + }); + return this.ok({ + action: 'internalize', + status: batch.status, + batch_id: batch.batch_id, + targets: batch.targets, + message: 'Memory intake is running in the background; use /memory status to inspect progress.', + }); + } const submitted = await this.startIntake(args); const jobId = typeof submitted.job_id === 'string' ? submitted.job_id : submitted.job?.id; if (typeof jobId !== 'string' || !jobId) throw new Error('memory_intake_run did not return job_id'); @@ -746,6 +1122,16 @@ export class AgoraProviderRuntime implements AgoraMemoryController { async rollback(args: Record): Promise { try { this.ensureMemoryReady(); + if (this.getCapabilities().memoryV2) { + const memoryId = String(args.memory_id ?? '').trim(); + const expectedHeadPatchId = String(args.expected_head_patch_id ?? '').trim(); + const targetPatchId = String(args.target_patch_id ?? args.patch_id ?? '').trim(); + if (!memoryId || !expectedHeadPatchId || !targetPatchId) { + throw new Error('rollback requires memory_id, expected_head_patch_id, and target_patch_id'); + } + const memory = await this.rollbackMemory(memoryId, expectedHeadPatchId, targetPatchId); + return this.ok({ action: 'rollback', memory, mount_status: 'pending_explicit_mount' }); + } const profileId = this.resolveProfileId(args); const targetPatchId = typeof args.patch_id === 'string' && args.patch_id.trim() ? args.patch_id.trim() @@ -805,10 +1191,16 @@ export class AgoraProviderRuntime implements AgoraMemoryController { this.resources = []; } this.doctorPayload = await this.callJsonTool('doctor', {}, undefined, false); + if (this.toolNames.has('runtime_capabilities')) { + const capabilityPayload = await this.callJsonTool('runtime_capabilities', {}, undefined, false); + this.runtimeContract = (asRecord(capabilityPayload.contract) ?? capabilityPayload) as AgoraRuntimeContract; + } else { + this.runtimeContract = asRecord(this.doctorPayload.contract) as AgoraRuntimeContract | null; + } if (command.trust === 'verified') { const expectedVersion = command.lock?.version; - const actualVersion = this.doctorPayload.version; - const actualMajor = this.doctorPayload.contract?.host_protocol_major; + const actualVersion = this.runtimeContract?.runtime_version ?? this.doctorPayload.version; + const actualMajor = this.runtimeContract?.host_protocol_major; if (actualVersion !== expectedVersion || actualMajor !== command.lock?.host_protocol_major) { throw new Error( `Agora runtime contract mismatch: expected ${expectedVersion}/host-v${command.lock?.host_protocol_major}, ` + @@ -836,6 +1228,16 @@ export class AgoraProviderRuntime implements AgoraMemoryController { } } + private requireV2(): void { + const capabilities = this.getCapabilities(); + if (!capabilities.memoryV2) { + throw new Error( + `Agora Memory Runtime v2 is unavailable (runtime mode: ${capabilities.runtimeMode}). ` + + 'Named Memory, multi-target intake, incremental segments, multi-patch mount, hot swap, and rollback are required.' + ); + } + } + private requireSessionId(): string { const sessionId = this.lastState?.agora_session_id; if (!sessionId) { @@ -867,7 +1269,7 @@ export class AgoraProviderRuntime implements AgoraMemoryController { throw new Error(`Agora MCP tool ${toolName} returned invalid JSON payload`); } const record = payload as Record; - if (isErrorPayload(record)) throw new Error(errorText(record)); + if (isErrorPayload(record)) throw new AgoraMcpError(errorText(record), record); return record; } @@ -898,7 +1300,38 @@ export class AgoraProviderRuntime implements AgoraMemoryController { ? (event) => options.onEvent?.(providerProgressFromMcp(event)) : undefined ); - this.lastState = stateFromChatPayload(payload); + const requestedPatchIds = this.requestedPatchIds; + const localMemoryState = this.lastState?.memory; + this.lastState = stateFromChatPayload(payload, requestedPatchIds); + this.lastState.memory = { + ...(localMemoryState?.active_batch !== undefined ? { active_batch: localMemoryState.active_batch } : {}), + ...(localMemoryState?.last_auto_intake_message_end !== undefined + ? { last_auto_intake_message_end: localMemoryState.last_auto_intake_message_end } : {}), + ...(localMemoryState?.last_auto_intake_runtime_message_end !== undefined + ? { last_auto_intake_runtime_message_end: localMemoryState.last_auto_intake_runtime_message_end } : {}), + ...(this.lastState.memory ?? {}), + runtime_message_count: Array.isArray(rawRequest.messages) ? rawRequest.messages.length : 0, + }; + const activePatchIds = this.lastState.memory?.active_memory_patch_ids ?? []; + const revision = this.lastState.memory?.patchset_revision; + const revisionAdvanced = this.requestedAfterRevision === null || ( + typeof revision === 'number' && revision > this.requestedAfterRevision + ); + if ( + requestedPatchIds && + requestedPatchIds.length === activePatchIds.length && + requestedPatchIds.every((id, index) => id === activePatchIds[index]) && + revisionAdvanced + ) { + this.requestedPatchIds = null; + this.requestedAfterRevision = null; + delete this.lastState.memory?.requested_memory_patch_ids; + } else if (requestedPatchIds) { + this.lastState.memory = { ...(this.lastState.memory ?? {}), status: 'stale' }; + } + if (this.getCapabilities().memoryV2 && activePatchIds.length > 0) { + await this.hydrateMountedMemories(activePatchIds); + } this.lastState.runtime_trust = this.runtimeTrust; this.lastState.runtime_source = this.runtimeSource; return payload; @@ -1040,29 +1473,113 @@ export class AgoraProviderRuntime implements AgoraMemoryController { }); } + private markPatchSelectionPending( + profileId: string, + patchIds: string[], + bindingId?: string + ): void { + const previousActive = this.lastState?.memory?.active_memory_patch_ids ?? []; + const changed = previousActive.length !== patchIds.length || previousActive.some((id, index) => id !== patchIds[index]); + this.requestedPatchIds = [...patchIds]; + this.requestedAfterRevision = changed && typeof this.lastState?.memory?.patchset_revision === 'number' + ? this.lastState.memory.patchset_revision + : null; + const previous = this.lastState; + this.lastState = { + provider_id: 'agora', + ...(previous?.agora_session_id ? { agora_session_id: previous.agora_session_id } : {}), + memory: { + ...(previous?.memory ?? {}), + status: 'pending', + profile_id: profileId, + ...(bindingId ? { binding_id: bindingId } : {}), + active_memory_patch_ids: previous?.memory?.active_memory_patch_ids ?? [], + requested_memory_patch_ids: [...patchIds], + }, + runtime_trust: this.runtimeTrust, + runtime_source: this.runtimeSource, + }; + } + + private async hydrateMountedMemories(activePatchIds: string[]): Promise { + try { + const [memories, patches, profiles] = await Promise.all([ + this.listMemories(), + this.listPatches(true), + this.listProfiles(), + ]); + const mounted = activePatchIds.flatMap((patchId) => { + const patch = patches.find((item) => item.id === patchId); + const memory = patch?.memory_id ? memories.find((item) => item.id === patch.memory_id) : undefined; + if (!patch || !memory) return []; + return [{ + memory_id: memory.id, + memory_name: memory.name, + patch_id: patch.id, + patch_name: patch.name, + version: patch.version, + }]; + }); + const profileId = this.lastState?.memory?.profile_id; + const profile = profiles.find((item) => item.id === profileId); + if (this.lastState?.memory) { + this.lastState.memory.mounted_memories = mounted; + this.lastState.memory.auto_target_memory_ids = profile?.auto_intake_target_memory_ids ?? []; + } + } catch { + // Chat truth remains valid even if the user-facing catalog cannot be hydrated. + } + } + + private normalizeBatch(payload: Record): AgoraMemoryIntakeBatchResult { + const batch = asRecord(payload.batch) ?? {}; + const batchId = String(payload.batch_id ?? batch.id ?? '').trim(); + if (!batchId) throw new Error('Agora did not return a Memory intake batch id'); + const rawTargets = Array.isArray(payload.targets) ? payload.targets : []; + return { + status: String(payload.status ?? batch.status ?? 'unknown'), + batch_id: batchId, + batch, + targets: rawTargets + .filter((target): target is Record => Boolean(asRecord(target))) + .map((target) => ({ + ...target, + id: String(target.id ?? ''), + batch_id: String(target.batch_id ?? batchId), + mode: target.mode === 'create' ? 'create' : 'increment', + output_name: String(target.output_name ?? target.memory_name ?? ''), + status: String(target.status ?? 'queued') as AgoraIntakeTargetStatus, + })), + }; + } + private async ensureBinding( profileId: string, - scope: 'project' | 'conversation' = 'project' + scope: 'user' | 'project' | 'conversation' = 'project' ): Promise { const metadata = this.buildMetadata(undefined, { memory_profile: profileId }); const listed = await this.callJsonTool('memory_profile_bindings_list', { profile_id: profileId }); const bindings = Array.isArray(listed.bindings) ? listed.bindings : []; const match = bindings.find((binding: any) => { if (binding?.scope_type !== scope || binding?.user_id !== metadata.user_id) return false; - if (binding?.project_id !== metadata.project_id) return false; - return scope === 'project' || binding?.conversation_id === metadata.conversation_id; + if (scope !== 'user' && binding?.project_id !== metadata.project_id) return false; + return scope !== 'conversation' || binding?.conversation_id === metadata.conversation_id; }); if (match?.id) return String(match.id); const created = await this.callJsonTool('memory_profile_bindings_create', { profile_id: profileId, scope_type: scope, user_id: metadata.user_id, - project_id: metadata.project_id, + ...(scope !== 'user' ? { project_id: metadata.project_id } : {}), ...(scope === 'conversation' ? { conversation_id: metadata.conversation_id } : {}), }); return typeof created.binding?.id === 'string' ? created.binding.id : undefined; } + private safeScopeId(value: string): string { + return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'default'; + } + private async resolveRollbackPatchId(): Promise { const current = this.lastState?.memory?.active_memory_patch_ids?.[0]; if (!current) return null; diff --git a/src/session/store.ts b/src/session/store.ts index 5a90fd3..6487821 100644 --- a/src/session/store.ts +++ b/src/session/store.ts @@ -108,7 +108,13 @@ export function createSessionStore(sessionDir?: string): SessionStore { function updateProviderState(sessionId: string, providerState: ProviderSessionState): void { const meta = readMeta(metaPath(sessionId)); if (!meta) return; - meta.providerState = providerState; + meta.providerState = { + ...(meta.providerState ?? {}), + ...providerState, + ...(meta.providerState?.memory || providerState.memory + ? { memory: { ...(meta.providerState?.memory ?? {}), ...(providerState.memory ?? {}) } } + : {}), + }; writeMeta(metaPath(sessionId), meta); } diff --git a/test/agora-auto-intake.test.ts b/test/agora-auto-intake.test.ts new file mode 100644 index 0000000..80b559b --- /dev/null +++ b/test/agora-auto-intake.test.ts @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { classifyAgoraBatchTargets, planAgoraAutoIntake } from '../src/provider/agora-auto-intake.js'; + +const memory = { id: 'memory-a', name: '产品记忆', base_model_id: 'base-a', head_patch_id: 'patch-a', status: 'available' }; +const patch = { id: 'patch-a', name: '产品记忆@v1', base_model_id: 'base-a', family: 'memory-a', version: 'v1', mountable: true, status: 'available', memory_id: 'memory-a' }; + +test('auto intake plans only the uncheckpointed range after four user turns', () => { + const messages = [ + { role: 'user', content: 'old' }, + { role: 'assistant', content: 'old answer' }, + ...Array.from({ length: 4 }, (_, index) => [ + { role: 'user', content: `fact ${index}` }, + { role: 'assistant', content: `answer ${index}` }, + ]).flat(), + ]; + const plan = planAgoraAutoIntake({ + messages, + checkpointEnd: 2, + profile: { + id: 'profile-a', + name: 'project', + base_model_id: 'base-a', + active_memory_patch_ids: ['patch-a'], + auto_intake_target_memory_ids: ['memory-a'], + auto_intake_policy: { enabled: true, min_user_turns: 4, min_pending_tokens: 2000 }, + status: 'available', + }, + memories: [memory], + patches: [patch], + }); + assert.equal(plan.ready, true); + assert.equal(plan.sourceStart, 2); + assert.equal(plan.sourceEnd, messages.length); + assert.equal(plan.pendingUserTurns, 4); + assert.deepEqual(plan.targets, [{ + mode: 'increment', + memory_id: 'memory-a', + expected_parent_patch_id: 'patch-a', + output_name: '产品记忆@v2', + }]); +}); + +test('auto intake never guesses a target and can trigger on pending token threshold', () => { + const base = { + id: 'profile-a', + name: 'project', + base_model_id: 'base-a', + active_memory_patch_ids: ['patch-a'], + auto_intake_policy: { enabled: true, min_user_turns: 4, min_pending_tokens: 100 }, + status: 'available', + }; + const noTarget = planAgoraAutoIntake({ + messages: [{ role: 'user', content: 'x'.repeat(1000) }], + checkpointEnd: 0, + profile: base, + memories: [memory], + patches: [patch], + }); + assert.equal(noTarget.ready, false); + assert.equal(noTarget.reason, 'no_explicit_targets'); + const tokenReady = planAgoraAutoIntake({ + messages: [{ role: 'user', content: 'x'.repeat(1000) }], + checkpointEnd: 0, + profile: { ...base, auto_intake_target_memory_ids: ['memory-a'] }, + memories: [memory], + patches: [patch], + }); + assert.equal(tokenReady.ready, true); + assert.ok(tokenReady.pendingTokens >= 100); +}); + +test('auto checkpoint can advance only when every target is completed or noop', () => { + const successful = classifyAgoraBatchTargets([ + { id: 'a', batch_id: 'b', mode: 'increment', output_name: 'a', status: 'completed' }, + { id: 'c', batch_id: 'b', mode: 'increment', output_name: 'c', status: 'noop' }, + ]); + assert.equal(successful.terminal, true); + assert.equal(successful.blockers.length, 0); + const partial = classifyAgoraBatchTargets([ + { id: 'a', batch_id: 'b', mode: 'increment', output_name: 'a', status: 'completed' }, + { id: 'c', batch_id: 'b', mode: 'increment', output_name: 'c', status: 'conflict', error: { retryable: true } }, + { id: 'd', batch_id: 'b', mode: 'increment', output_name: 'd', status: 'failed', error: { retryable: false } }, + ]); + assert.equal(partial.terminal, true); + assert.equal(partial.blockers.length, 2); + assert.deepEqual(partial.retryable.map((target) => target.id), ['c']); +}); diff --git a/test/agora-integrity.test.ts b/test/agora-integrity.test.ts new file mode 100644 index 0000000..f64dd1d --- /dev/null +++ b/test/agora-integrity.test.ts @@ -0,0 +1,31 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { verifyAgoraManifestFiles } from '../src/provider/agora.js'; + +const sha256 = (value: string) => createHash('sha256').update(value).digest('hex'); + +test('Agora manifest verifies every packaged runtime file and rejects tampering', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agora-integrity-')); + try { + fs.mkdirSync(path.join(root, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(root, 'lib'), { recursive: true }); + fs.writeFileSync(path.join(root, 'bin', 'agora'), 'native-binary'); + fs.writeFileSync(path.join(root, 'lib', 'agora-core.dylib'), 'native-core'); + const manifest = { + files: { + 'bin/agora': sha256('native-binary'), + 'lib/agora-core.dylib': sha256('native-core'), + }, + }; + assert.equal(verifyAgoraManifestFiles(root, manifest), true); + fs.writeFileSync(path.join(root, 'lib', 'agora-core.dylib'), 'tampered'); + assert.equal(verifyAgoraManifestFiles(root, manifest), false); + assert.equal(verifyAgoraManifestFiles(root, { files: { '../escape': sha256('x') } }), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/agora-provider-runtime.test.ts b/test/agora-provider-runtime.test.ts index 70c5d93..957adee 100644 --- a/test/agora-provider-runtime.test.ts +++ b/test/agora-provider-runtime.test.ts @@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { AgoraProviderRuntime } from '../src/provider/agora.js'; +import { AgoraMcpError, AgoraProviderRuntime } from '../src/provider/agora.js'; import type { ModelConfig } from '../src/mcp/types.js'; const AGORA_DEV_ROOT = process.env.MA_TEST_AGORA_ROOT || ''; @@ -32,6 +32,18 @@ const MEMORY_TOOLS = [ 'memory_patch_versions', ]; +const MEMORY_V2_TOOLS = [ + ...MEMORY_TOOLS, + 'runtime_capabilities', + 'memories_create', + 'memories_get', + 'memories_list', + 'memories_rename', + 'memories_rollback', + 'memory_intake_batch_run', + 'memory_intake_batch_get', +]; + function model(): ModelConfig { return { provider: 'agora', @@ -60,6 +72,20 @@ function runtimeWithFakeCalls( (runtime as any).resources = ['agora://doctor']; (runtime as any).doctorPayload = { service: 'agora', status: 'ok' }; (runtime as any).modelsPayload = { service: 'agora', models: [{ id: 'base-a' }] }; + if (tools.includes('memory_intake_batch_run')) { + (runtime as any).runtimeContract = { + runtime_version: '0.2.0-dev', + registry_schema_version: 3, + capabilities: { + named_memories: 1, + multi_target_intake: 1, + incremental_segments: 1, + multi_model_delta_mount: 1, + request_boundary_hot_swap: 1, + memory_runtime_v2: 1, + }, + }; + } (runtime as any).ready = async () => {}; (runtime as any).callJsonTool = async (name: string, args: Record) => { calls.push({ name, args }); @@ -98,6 +124,7 @@ function runtimeWithFakeCalls( binding_id: 'binding-a', active_memory_patch_ids: patchIds, }, + memory_runtime: { patchset_revision: 2 }, }, memory: { enabled: true, @@ -117,6 +144,7 @@ function runtimeWithFakeCalls( name: 'Profile A', base_model_id: 'base-a', active_memory_patch_ids: ['patch-a'], + auto_intake_target_memory_ids: ['memory-a'], writable_patch_family: 'project-memory', auto_intake_policy: { enabled: true, activation_mode: 'auto' }, }], @@ -132,8 +160,18 @@ function runtimeWithFakeCalls( version: 'v1', mountable: true, status: 'available', + memory_id: 'memory-a', }], }; + if (name === 'memories_list') return { + service: 'agora', + status: 'ok', + memories: [{ id: 'memory-a', name: '产品记忆', base_model_id: 'base-a', head_patch_id: 'patch-a', status: 'available' }], + }; + if (name === 'memories_get') return { service: 'agora', status: 'ok', memory: { id: args.memory_id, name: '产品记忆', base_model_id: 'base-a', head_patch_id: 'patch-a', status: 'available' } }; + if (name === 'memories_create') return { service: 'agora', status: 'ok', memory: { id: 'memory-new', name: args.name, base_model_id: args.base_model_id, head_patch_id: null, status: 'available' } }; + if (name === 'memories_rename') return { service: 'agora', status: 'ok', memory: { id: args.memory_id, name: args.name, base_model_id: 'base-a', head_patch_id: 'patch-a', status: 'available' } }; + if (name === 'memories_rollback') return { service: 'agora', status: 'ok', memory: { id: args.memory_id, name: '产品记忆', base_model_id: 'base-a', head_patch_id: args.target_patch_id, status: 'available' } }; if (name === 'memory_profiles_create') return { service: 'agora', status: 'available', profile: { id: args.id } }; if (name === 'memory_profiles_update') return { service: 'agora', status: 'available', profile: { id: args.profile_id } }; if (name === 'memory_profile_bindings_list') return { service: 'agora', status: 'ok', bindings: [] }; @@ -166,6 +204,17 @@ function runtimeWithFakeCalls( }, }; } + if (name === 'memory_intake_batch_run' || name === 'memory_intake_batch_get') { + const targets = (name === 'memory_intake_batch_run' ? args.targets : [{ mode: 'increment', memory_id: 'memory-a', output_name: '产品记忆@v2' }]) + .map((target: any, index: number) => ({ + id: `target-${index}`, + batch_id: 'batch-a', + ...target, + status: 'completed', + output_patch_id: 'patch-a', + })); + return { service: 'agora', status: 'completed', batch_id: 'batch-a', batch: { id: 'batch-a', status: 'completed' }, targets }; + } if (name === 'memory_lineage_advance') { return { service: 'agora', status: 'activated', active_memory_patch_ids: ['patch-a'] }; } @@ -204,7 +253,8 @@ class FakeModel: def __call__(self, tokens, *args, **kwargs): import mlx.core as mx - hidden = mx.ones((1, 1, 8), dtype=mx.float32) + scale = mx.sum(tokens.astype(mx.float32)) * 0.001 + hidden = mx.sin(mx.arange(1, 9, dtype=mx.float32) * scale)[None, None, :] for layer in self.layers: hidden = layer(hidden) return hidden @@ -311,7 +361,7 @@ from pathlib import Path from agora_lab.paths import DB_PATH from agora_lab.registry import Registry -from agora_lab.schemas import AdapterRecord, BaseModelRecord, MemoryPatchRecord +from agora_lab.schemas import BaseModelRecord root = Path(DB_PATH).parent @@ -335,56 +385,6 @@ registry.upsert_base_model( architecture="dense", ) ) -adapter_dir = root / "adapter-old" -adapter_dir.mkdir(parents=True, exist_ok=True) -(adapter_dir / "adapters.safetensors").write_text("old adapter weights", encoding="utf-8") -adapter_manifest = adapter_dir / "manifest.json" -adapter_manifest.write_text(json.dumps({"adapter_id": "adapter-old"}), encoding="utf-8") -adapter_eval = adapter_dir / "eval_report.json" -adapter_eval.write_text(json.dumps({"status": "seeded"}), encoding="utf-8") -registry.upsert_adapter( - AdapterRecord( - id="adapter-old", - name="adapter-old", - base_model_id="qwen2.5-7b-fp16", - method="lora", - artifact_path=str(adapter_dir), - manifest_path=str(adapter_manifest), - training_job_id="train-adapter-old", - dataset_id="dataset-old", - training_profile={"rank": 4}, - eval_report_path=str(adapter_eval), - status="experimental", - family="ma-e2e-memory", - version="0.0.0-old", - base_architecture="dense", - weights_file="adapters.safetensors", - ) -) -patch_dir = root / "patch-old" -patch_dir.mkdir(parents=True, exist_ok=True) -patch_manifest = patch_dir / "manifest.json" -patch_manifest.write_text(json.dumps({"kind": "lora_adapter_memory_patch_v0"}), encoding="utf-8") -patch_eval = patch_dir / "eval_report.json" -patch_eval.write_text(json.dumps({"status": "seeded"}), encoding="utf-8") -registry.upsert_memory_patch( - MemoryPatchRecord( - id="patch-old", - name="patch-old", - base_model_id="qwen2.5-7b-fp16", - patch_type="lora_adapter", - compiler_backend="qlora_train", - artifact_path=str(patch_dir), - manifest_path=str(patch_manifest), - eval_report_path=str(patch_eval), - status="experimental", - family="ma-e2e-memory", - version="0.0.0-old", - source_ids=["seed-source"], - adapter_id="adapter-old", - mountable=True, - ) -) `; const pythonPath = [AGORA_DEV_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter); execFileSync(AGORA_DEV_PYTHON, ['-c', seedCode], { @@ -481,6 +481,7 @@ test('agora provider runtime wraps non-streaming chat_complete as streaming chun assert.equal(chunks.some((chunk) => chunk.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name === 'test__ping'), true); assert.equal(runtime.getProviderState()?.agora_session_id, 'agora-session-a'); assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-a']); + assert.equal(runtime.getProviderState()?.memory?.runtime_message_count, 1); }); test('agora provider runtime exposes granular capability gaps without disabling chat', async () => { @@ -544,6 +545,99 @@ test('agora profile switch persists project/session scope and waits for a real c ); }); +test('agora memory v2 requires both tools and granular runtime capabilities', () => { + const runtime = runtimeWithFakeCalls([], MEMORY_V2_TOOLS); + assert.equal(runtime.getCapabilities().memoryV2, true); + assert.equal(runtime.getCapabilities().runtimeMode, 'v2'); + (runtime as any).runtimeContract.capabilities.request_boundary_hot_swap = 0; + assert.equal(runtime.getCapabilities().memoryV2, false); + assert.equal(runtime.getCapabilities().runtimeMode, 'legacy'); +}); + +test('agora memory v2 mounts named memories without writable family and verifies on chat boundary', async () => { + const calls: Array<{ name: string; args: Record }> = []; + const runtime = runtimeWithFakeCalls(calls, MEMORY_V2_TOOLS); + + const mounted = await runtime.mountMemories('profile-a', ['memory-a'], 'project'); + assert.equal(mounted.mount_status, 'pending_next_chat'); + assert.equal(runtime.getProviderState()?.memory?.status, 'pending'); + const update = calls.find((call) => call.name === 'memory_profiles_update'); + assert.deepEqual(update?.args.active_memory_patch_ids, ['patch-a']); + assert.equal('writable_patch_family' in (update?.args ?? {}), false); + assert.equal(calls.some((call) => call.name === 'chat_complete'), false); + + await runtime.createChatCompletion({ + model: 'base-a', + messages: [{ role: 'user', content: 'verify mount' }], + stream: false, + }); + assert.equal(runtime.getProviderState()?.memory?.status, 'mounted'); + assert.equal(runtime.getProviderState()?.memory?.patchset_revision, 2); + assert.equal(runtime.getProviderState()?.memory?.mounted_memories?.[0]?.memory_name, '产品记忆'); +}); + +test('agora memory v2 conversation override uses an isolated profile and does not overwrite project default', async () => { + const calls: Array<{ name: string; args: Record }> = []; + const runtime = runtimeWithFakeCalls(calls, MEMORY_V2_TOOLS); + const result = await runtime.mountMemories('profile-a', ['memory-a'], 'conversation'); + assert.equal(result.profile_id, 'profile-a--conversation-conv-a'); + const created = calls.find((call) => call.name === 'memory_profiles_create'); + assert.equal(created?.args.id, 'profile-a--conversation-conv-a'); + const binding = calls.find((call) => call.name === 'memory_profile_bindings_create'); + assert.equal(binding?.args.scope_type, 'conversation'); + assert.equal(binding?.args.conversation_id, 'conv-a'); + assert.equal(calls.some((call) => call.name === 'memory_profiles_update' && call.args.profile_id === 'profile-a'), false); +}); + +test('agora memory v2 submits one mixed batch and never calls legacy intake or lineage advance', async () => { + const calls: Array<{ name: string; args: Record }> = []; + const runtime = runtimeWithFakeCalls(calls, MEMORY_V2_TOOLS); + await runtime.createChatCompletion({ + model: 'base-a', + messages: [{ role: 'user', content: 'source' }], + stream: false, + }); + calls.length = 0; + const result = await runtime.internalize({ + source_message_start: 0, + source_message_end: 1, + targets: [ + { mode: 'create', name: '新记忆', output_name: '新记忆@v1' }, + { mode: 'increment', memory_id: 'memory-a', expected_parent_patch_id: 'patch-a', output_name: '产品记忆@v2' }, + ], + }); + assert.equal(result.isError, false, result.content); + assert.equal(JSON.parse(result.content).batch_id, 'batch-a'); + assert.deepEqual(calls.map((call) => call.name), ['memory_intake_batch_run']); + assert.equal(calls.some((call) => ['memory_intake_run', 'memory_lineage_advance'].includes(call.name)), false); + assert.equal(calls[0].args.targets.length, 2); +}); + +test('agora memory v2 preserves explicit auto targets on the profile', async () => { + const calls: Array<{ name: string; args: Record }> = []; + const runtime = runtimeWithFakeCalls(calls, MEMORY_V2_TOOLS); + await runtime.setAutoPolicy('profile-a', true, ['memory-a']); + const update = calls.find((call) => call.name === 'memory_profiles_update'); + assert.deepEqual(update?.args.auto_intake_target_memory_ids, ['memory-a']); + assert.equal(update?.args.auto_intake_policy.enabled, true); +}); + +test('agora memory v2 does not claim mounted when PatchSet revision fails to advance', async () => { + const runtime = runtimeWithFakeCalls([], MEMORY_V2_TOOLS); + (runtime as any).lastState = { + provider_id: 'agora', + memory: { status: 'mounted', active_memory_patch_ids: ['patch-old'], patchset_revision: 2 }, + }; + (runtime as any).markPatchSelectionPending('profile-a', ['patch-a']); + await runtime.createChatCompletion({ + model: 'base-a', + messages: [{ role: 'user', content: 'stale revision' }], + stream: false, + }); + assert.equal(runtime.getProviderState()?.memory?.status, 'stale'); + assert.deepEqual(runtime.getProviderState()?.memory?.requested_memory_patch_ids, ['patch-a']); +}); + const agoraDevE2eTest = fs.existsSync(AGORA_DEV_COMMAND) && fs.existsSync(AGORA_DEV_PYTHON) ? test : test.skip; @@ -582,21 +676,47 @@ agoraPackagedE2eTest('packaged Agora serves a real whitelist model over MCP stdi ); await runtime.ready(); assert.ok((await runtime.listModels()).some((item) => item.id === 'qwen2.5-7b-fp16')); - await runtime.createChatCompletion({ + assert.equal(runtime.getCapabilities().memoryV2, true); + const before = await (runtime as any).chatCompletePayload({ model: 'qwen2.5-7b-fp16', - messages: [{ role: 'user', content: '只回答OK' }], + messages: [{ role: 'user', content: '利博是我的同事,请记住。' }], stream: false, max_tokens: 2, temperature: 0, } as any); assert.ok(runtime.getProviderState()?.agora_session_id); + const controller = runtime.getMemoryController(); + assert.ok(controller); + const submitted = await controller.startBatchIntake({ + targets: [{ mode: 'create', name: 'Packaged E2E Memory', output_name: 'Packaged E2E Memory@v1' }], + }); + let batch = submitted; + for (let index = 0; index < 300; index++) { + batch = await controller.getBatchIntake(submitted.batch_id); + if (batch.targets.every((target) => ['completed', 'noop', 'review', 'conflict', 'failed'].includes(target.status))) break; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + assert.equal(batch.targets[0]?.status, 'completed', JSON.stringify(batch)); + const memory = (await controller.listMemories()).find((item) => item.name === 'Packaged E2E Memory'); + assert.ok(memory); + await controller.mountMemories('profile-packaged-e2e', [memory.id]); + const after = await (runtime as any).chatCompletePayload({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '我的同事是谁?' }], + stream: false, + max_tokens: 8, + temperature: 0, + } as any); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [memory.head_patch_id]); + assert.equal(after.metadata?.memory_runtime?.base_model_object_id, before.metadata?.memory_runtime?.base_model_object_id); + assert.equal(after.metadata?.memory_runtime?.model_load_count, before.metadata?.memory_runtime?.model_load_count); } finally { await runtime?.close(); fs.rmSync(tmp, { recursive: true, force: true }); } }); -agoraDevE2eTest('agora provider runtime uses real MCP stdio for mount, internalize, rollback, and disable', async () => { +agoraDevE2eTest('agora provider runtime uses real v2 MCP stdio for named Memory, batch intake, hot mount, rollback, and disable', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-agora-provider-e2e-')); const dataRoot = path.join(tmp, 'agora-data'); const fakeModules = path.join(tmp, 'fake-model-runtime'); @@ -633,15 +753,12 @@ agoraDevE2eTest('agora provider runtime uses real MCP stdio for mount, internali await runtime.ready(); const controller = runtime.getMemoryController(); assert.ok(controller, 'real Agora MCP runtime must expose memory controller'); - - const mounted = await controller.mount({ - profile_id: 'profile-e2e', - active_memory_patch_ids: ['patch-old'], - writable_patch_family: 'ma-e2e-memory', - }); - assert.equal(mounted.isError, false, mounted.content); - assert.equal(JSON.parse(mounted.content).mount_status, 'pending_next_chat'); - assert.equal(runtime.getProviderState(), null, 'profile selection must not inject a synthetic verification turn'); + assert.equal(controller.getCapabilities().memoryV2, true); + await controller.createMemory('Unique Name Guard'); + await assert.rejects( + () => controller.createMemory('Unique Name Guard'), + (err: unknown) => err instanceof AgoraMcpError && err.code === 'memory_name_conflict' && err.field === 'name' + ); await runtime.createChatCompletion({ model: 'qwen2.5-7b-fp16', @@ -650,40 +767,113 @@ agoraDevE2eTest('agora provider runtime uses real MCP stdio for mount, internali max_tokens: 8, } as any); assert.ok(runtime.getProviderState()?.agora_session_id, 'chat turn must establish Agora session id'); + await assert.rejects( + () => controller.startBatchIntake({ + targets: [ + { mode: 'create', name: 'Conflict A', output_name: 'Duplicate Output' }, + { mode: 'create', name: 'Conflict B', output_name: 'Duplicate Output' }, + ], + }), + (err: unknown) => err instanceof AgoraMcpError && err.code === 'memory_patch_name_conflict' && err.field === 'output_name' + ); - const internalized = await controller.internalize({ profile_id: 'profile-e2e' }); - assert.equal(internalized.isError, false, internalized.content); - const internalizedPayload = JSON.parse(internalized.content); - assert.equal(internalizedPayload.status, 'queued'); - let finalized: Record = {}; + const submitted = await controller.startBatchIntake({ + targets: [ + { mode: 'create', name: 'MA E2E Memory', output_name: 'MA E2E Memory@v1' }, + { mode: 'create', name: 'MA E2E Overlay', output_name: 'MA E2E Overlay@v1' }, + { mode: 'create', name: 'MA E2E Preferences', output_name: 'MA E2E Preferences@v1' }, + ], + }); + let finalized = submitted; for (let i = 0; i < 120; i++) { - finalized = await controller.finalizeIntake(internalizedPayload.job_id, 'profile-e2e'); - if (finalized.outcome) break; + finalized = await controller.getBatchIntake(submitted.batch_id); + if (finalized.targets.every((target) => ['completed', 'noop', 'review', 'conflict', 'failed'].includes(target.status))) break; await new Promise((resolve) => setTimeout(resolve, 100)); } - const patchId = finalized.active_memory_patch_ids?.find((id: string) => id !== 'patch-old') - ?? finalized.profile?.active_memory_patch_ids?.find((id: string) => id !== 'patch-old'); - assert.match(patchId, /^memory-intake-/); - assert.equal(finalized.mount_status, 'pending_next_chat'); - assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-old']); + assert.deepEqual(finalized.targets.map((target) => target.status), ['completed', 'completed', 'completed'], JSON.stringify(finalized)); + const patchV1 = String(finalized.targets.find((target) => target.memory_name === 'MA E2E Memory')?.output_patch_id); + const overlayPatch = String(finalized.targets.find((target) => target.memory_name === 'MA E2E Overlay')?.output_patch_id); + const preferencesPatch = String(finalized.targets.find((target) => target.memory_name === 'MA E2E Preferences')?.output_patch_id); + const listedMemories = await controller.listMemories(); + const memory = listedMemories.find((item) => item.name === 'MA E2E Memory'); + const overlay = listedMemories.find((item) => item.name === 'MA E2E Overlay'); + const preferences = listedMemories.find((item) => item.name === 'MA E2E Preferences'); + assert.ok(memory); + assert.ok(overlay); + assert.ok(preferences); + + const mounted = await controller.mountMemories('profile-e2e', [memory.id]); + assert.equal(mounted.mount_status, 'pending_next_chat'); + assert.equal(runtime.getProviderState()?.memory?.status, 'pending'); + const mcpInstance = (runtime as any).mcp; + const verifiedA = await (runtime as any).chatCompletePayload({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '先验证单记忆挂载。' }], + stream: false, + max_tokens: 8, + } as any); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [patchV1]); + assert.equal(runtime.getProviderState()?.memory?.mounted_memories?.[0]?.memory_name, 'MA E2E Memory'); + assert.equal((runtime as any).mcp, mcpInstance, 'patch mount must not restart Agora subprocess'); + const baseObjectId = verifiedA.metadata?.memory_runtime?.base_model_object_id; + const loadCount = verifiedA.metadata?.memory_runtime?.model_load_count; + + await controller.mountMemories('profile-e2e', [memory.id, overlay.id, preferences.id]); + const verifiedABC = await (runtime as any).chatCompletePayload({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '小明的新项目代号是海鸥,请记住。' }], + stream: false, + max_tokens: 8, + } as any); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [patchV1, overlayPatch, preferencesPatch]); + assert.equal(verifiedABC.metadata?.memory_runtime?.base_model_object_id, baseObjectId); + assert.equal(verifiedABC.metadata?.memory_runtime?.model_load_count, loadCount); + + const secondBatch = await controller.startBatchIntake({ + targets: [{ + mode: 'increment', + memory_id: memory.id, + expected_parent_patch_id: patchV1, + output_name: 'MA E2E Memory@v2', + }], + }); + let second = secondBatch; + for (let i = 0; i < 120; i++) { + second = await controller.getBatchIntake(secondBatch.batch_id); + if (second.targets.every((target) => ['completed', 'noop', 'review', 'conflict', 'failed'].includes(target.status))) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.equal(second.targets[0]?.status, 'completed', JSON.stringify(second)); + const patchV2 = String(second.targets[0]?.output_patch_id); + await controller.applyCompletedBatch(second, 'profile-e2e'); + const verifiedV2 = await (runtime as any).chatCompletePayload({ + model: 'qwen2.5-7b-fp16', + messages: [{ role: 'user', content: '增量后继续。' }], + stream: false, + max_tokens: 8, + } as any); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [patchV2, overlayPatch, preferencesPatch]); + assert.equal(verifiedV2.metadata?.memory_runtime?.base_model_object_id, baseObjectId); + assert.equal(verifiedV2.metadata?.memory_runtime?.model_load_count, loadCount); + + await controller.mountMemories('profile-e2e', [overlay.id, preferences.id]); await runtime.createChatCompletion({ model: 'qwen2.5-7b-fp16', - messages: [{ role: 'user', content: '使用内化后的记忆继续。' }], + messages: [{ role: 'user', content: '拔出主记忆后继续。' }], stream: false, max_tokens: 8, } as any); - assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [patchId]); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [overlayPatch, preferencesPatch]); - const rolledBack = await controller.rollback({ profile_id: 'profile-e2e', patch_id: 'patch-old' }); - assert.equal(rolledBack.isError, false, rolledBack.content); - assert.equal(JSON.parse(rolledBack.content).mount_status, 'pending_next_chat'); + await controller.rollbackMemory(memory.id, patchV2, patchV1); + await controller.mountMemories('profile-e2e', [memory.id, overlay.id]); await runtime.createChatCompletion({ model: 'qwen2.5-7b-fp16', messages: [{ role: 'user', content: '回滚后继续。' }], stream: false, max_tokens: 8, } as any); - assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, ['patch-old']); + assert.deepEqual(runtime.getProviderState()?.memory?.active_memory_patch_ids, [patchV1, overlayPatch]); const disabled = await controller.disable({ profile_id: 'profile-e2e' }); assert.equal(disabled.isError, false, disabled.content); diff --git a/test/cli-ux.test.ts b/test/cli-ux.test.ts index 7ea819b..e059d3d 100644 --- a/test/cli-ux.test.ts +++ b/test/cli-ux.test.ts @@ -98,17 +98,112 @@ test('CLI UX PTY: Agora keeps context usage and memory activity on separate read let output = ''; child.onData((data) => { output += data; }); try { - await waitFor(() => /Memory:\s+未选择 Profile/.test(stripAnsi(output)), 10_000); + await waitFor(() => /Memory:\s+未挂载/.test(stripAnsi(output)), 10_000); const plain = stripAnsi(output); assert.match(plain, /ctx:/); assert.match(plain, /trigger/); - assert.match(plain, /Memory:\s+未选择 Profile/); + assert.match(plain, /Memory:\s+未挂载/); + assert.match(plain, /unknown/); assert.doesNotMatch(plain, /sess\s/); } finally { child.kill('SIGINT'); } }); +test('CLI UX PTY: Agora Memory v2 console is name-first and readable at 80 columns', async (t) => { + if (process.env.MA_RUN_PTY_TESTS !== '1') { + t.skip('PTY UI verification runs only in the explicit MA_RUN_PTY_TESTS E2E lane'); + return; + } + if (!canSpawnPty()) { + t.skip('node-pty cannot spawn a basic /bin/echo process in this environment'); + return; + } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ma-cli-agora-memory-v2-')); + const fakeAgora = path.join(tmp, 'fake-agora-v2.mjs'); + const configPath = path.join(tmp, 'config.json'); + fs.writeFileSync(fakeAgora, fakeAgoraV2Source(), 'utf8'); + fs.writeFileSync(configPath, JSON.stringify({ + defaultProfile: '', + model: { + provider: 'agora', + baseURL: 'mcp-stdio://agora', + model: 'base-a', + apiKey: 'agora-mcp', + agoraRuntime: { command: process.execPath, args: [fakeAgora] }, + }, + mcpServers: {}, + }, null, 2)); + const tsxCli = path.join(repoRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'); + const child = pty.spawn(process.execPath, [tsxCli, 'src/cli/index.tsx', 'chat', '--config', configPath], { + name: 'xterm-256color', + cols: 80, + rows: 30, + cwd: repoRoot, + env: { ...process.env, NO_COLOR: '1', MA_REDUCED_MOTION: '1' }, + }); + let output = ''; + child.onData((data) => { output += data; }); + try { + await waitFor(() => /Memory:\s+未挂载/.test(stripAnsi(output)), 10_000); + child.write('/memory'); + await new Promise((resolve) => setTimeout(resolve, 200)); + child.write('\r'); + try { + await waitFor(() => /具名记忆/.test(stripAnsi(output)) && /产品记忆/.test(stripAnsi(output)), 10_000); + } catch (err) { + throw new Error(`${(err as Error).message}\nPTY output:\n${stripAnsi(output).slice(-4000)}`); + } + const plain = stripAnsi(output); + assert.match(plain, /Memory ·/); + assert.match(plain, /产品记忆 · v1/); + assert.match(plain, /Space 挂载/); + assert.doesNotMatch(plain, /writable_patch_family|主记忆\/可写|Profiles/); + } finally { + child.kill('SIGINT'); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +function fakeAgoraV2Source(): string { + return `let buffer = ''; +const names = ${JSON.stringify([ + 'doctor', 'runtime_capabilities', 'models_list', 'models_status', 'models_download', 'chat_complete', + 'memory_profiles_list', 'memory_profiles_create', 'memory_profiles_update', + 'memory_profile_bindings_list', 'memory_profile_bindings_create', 'memory_patches_list', 'memory_patch_versions', + 'memories_create', 'memories_get', 'memories_list', 'memories_rename', 'memories_rollback', + 'memory_intake_batch_run', 'memory_intake_batch_get', + ])}; +const payloads = { + doctor: { status: 'ok', version: '0.2.0', contract: { host_protocol_major: 1 } }, + runtime_capabilities: { status: 'ok', contract: { runtime_version: '0.2.0', host_protocol_major: 1, registry_schema_version: 3, capabilities: { named_memories: 1, multi_target_intake: 1, incremental_segments: 1, multi_model_delta_mount: 1, request_boundary_hot_swap: 1, memory_runtime_v2: 1 } } }, + models_list: { models: [{ id: 'base-a', name: 'Base A', status: 'available' }] }, + memories_list: { memories: [{ id: 'memory-a', name: '产品记忆', base_model_id: 'base-a', head_patch_id: 'patch-a', status: 'available' }] }, + memory_profiles_list: { profiles: [{ id: 'profile-a', name: 'project', base_model_id: 'base-a', active_memory_patch_ids: [], auto_intake_target_memory_ids: [], auto_intake_policy: { enabled: false }, status: 'available' }] }, + memory_patches_list: { patches: [{ id: 'patch-a', name: '产品记忆@v1', base_model_id: 'base-a', family: 'memory-a', version: 'v1', mountable: true, status: 'available', memory_id: 'memory-a' }] }, + chat_complete: { status: 'completed', id: 'chat-a', session_id: 'session-a', message: { role: 'assistant', content: 'ok' }, output_text: 'ok', finish_reason: 'stop', active_memory_patch_ids: ['patch-a'], metadata: { session_id: 'session-a', memory: { enabled: true, profile_id: 'profile-a', active_memory_patch_ids: ['patch-a'] }, memory_runtime: { patchset_revision: 2 } }, memory: { enabled: true, profile_id: 'profile-a', active_memory_patch_ids: ['patch-a'] } }, +}; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + while (true) { + const idx = buffer.indexOf('\\n'); + if (idx < 0) return; + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + const req = JSON.parse(line); + if (req.method === 'notifications/initialized') continue; + let result = {}; + if (req.method === 'tools/list') result = { tools: names.map((name) => ({ name, inputSchema: { type: 'object' } })) }; + else if (req.method === 'resources/list') result = { resources: [] }; + else if (req.method === 'tools/call') result = { content: [{ type: 'text', text: JSON.stringify(payloads[req.params.name] || { status: 'ok' }) }] }; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); + } +}); +`; +} + async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { diff --git a/test/commands.test.ts b/test/commands.test.ts index 7a3102e..6dac298 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -67,25 +67,48 @@ test('/model opens the model picker when no args are provided', async () => { }); }); -test('/memory opens the console and lists named writable profiles', async () => { +test('/memory opens the console and manages named Memory v2 objects', async () => { await withTempCwd(async () => { let opened = 0; + const calls: Array<{ name: string; args: unknown[] }> = []; const controller = { + getCapabilities: () => ({ memoryV2: true, runtimeMode: 'v2' }), + listMemories: async () => [{ + id: 'memory-a', + name: 'MA 核心记忆', + base_model_id: 'base-a', + head_patch_id: 'patch-a', + status: 'available', + }], + listPatches: async () => [{ + id: 'patch-a', + name: 'MA 核心记忆@v1', + version: 'v1', + memory_id: 'memory-a', + }], listProfiles: async () => [{ id: 'profile-a', - name: 'MA 核心记忆', - active_memory_patch_ids: ['patch-a', 'overlay-a'], - writable_patch_family: 'project-memory', + name: 'project binding', + active_memory_patch_ids: ['patch-a'], + auto_intake_target_memory_ids: ['memory-a'], auto_intake_policy: { enabled: true }, }], + createMemory: async (name: string) => { calls.push({ name: 'create', args: [name] }); return { id: 'memory-new', name }; }, + mountMemories: async (...args: unknown[]) => { calls.push({ name: 'mount', args }); return { mount_status: 'pending_next_chat' }; }, + }; + const agent = { + getMemoryController: () => controller, + getProviderState: () => ({ memory: { profile_id: 'profile-a' } }), }; - const agent = { getMemoryController: () => controller }; assert.equal(await executeCommand('/memory', baseContext({ agent, openMemoryConsole: () => { opened++; } })), null); assert.equal(opened, 1); const listed = await executeCommand('/memory list', baseContext({ agent })); assert.match(String(listed), /MA 核心记忆/); - assert.match(String(listed), /writable=project-memory/); - assert.match(String(listed), /auto=on/); + assert.match(String(listed), /v1/); + await executeCommand('/memory new 工程记忆', baseContext({ agent })); + await executeCommand('/memory mount MA 核心记忆', baseContext({ agent })); + assert.deepEqual(calls[0], { name: 'create', args: ['工程记忆'] }); + assert.deepEqual(calls[1], { name: 'mount', args: ['profile-a', ['memory-a'], 'project'] }); }); }); diff --git a/test/fixtures/portable-agora-config.json b/test/fixtures/portable-agora-config.json new file mode 100644 index 0000000..7165a19 --- /dev/null +++ b/test/fixtures/portable-agora-config.json @@ -0,0 +1,12 @@ +{ + "defaultProfile": "", + "model": { + "provider": "agora", + "baseURL": "mcp-stdio://agora", + "model": "qwen2.5-7b-fp16", + "apiKey": "agora-mcp", + "maxTokens": 8, + "temperature": 0 + }, + "mcpServers": {} +} diff --git a/test/session-store.test.ts b/test/session-store.test.ts index 0eb715f..064a637 100644 --- a/test/session-store.test.ts +++ b/test/session-store.test.ts @@ -37,6 +37,35 @@ test('session store: append writes one JSON line per call and updates messageCou assert.equal(meta.messageCount, 2); }); +test('session store: provider chat truth preserves MA-local Memory batch checkpoint state', () => { + const dir = mktmp('sess-'); + const store = createSessionStore(dir); + try { + const id = store.create({ createdAt: Date.now(), cwd: '/tmp/project', model: 'agora' }); + store.updateProviderState(id, { + provider_id: 'agora', + memory: { + status: 'pending', + active_batch: { batch_id: 'batch-a' }, + last_auto_intake_message_end: 12, + last_auto_intake_runtime_message_end: 13, + }, + }); + store.updateProviderState(id, { + provider_id: 'agora', + agora_session_id: 'chat-a', + memory: { status: 'mounted', active_memory_patch_ids: ['patch-a'] }, + }); + const state = store.list().find((meta) => meta.id === id)?.providerState; + assert.equal(state?.memory?.status, 'mounted'); + assert.deepEqual(state?.memory?.active_batch, { batch_id: 'batch-a' }); + assert.equal(state?.memory?.last_auto_intake_message_end, 12); + assert.equal(state?.memory?.last_auto_intake_runtime_message_end, 13); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('session store: load round-trips all appended messages', () => { const dir = mktmp('sess-'); const store = createSessionStore(dir); From 6f6d6d3b36b97ad823d20a4a631e15cf89c0a31d Mon Sep 17 00:00:00 2001 From: zhuqingyv Date: Sun, 12 Jul 2026 20:34:08 +0800 Subject: [PATCH 4/5] fix: require notarized Agora release evidence --- README.md | 3 + README.zh-CN.md | 3 + package.json | 1 + scripts/package-portable.mjs | 17 +++++- scripts/sync-agora-release.mjs | 84 ++++++++++++++++++++++++++++ src/provider/agora-runtime-lock.json | 22 ++++++++ src/provider/agora.ts | 39 +++++++++++-- test/agora-release-lock.test.ts | 40 +++++++++++++ 8 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 scripts/sync-agora-release.mjs create mode 100644 src/provider/agora-runtime-lock.json create mode 100644 test/agora-release-lock.test.ts diff --git a/README.md b/README.md index 54584ed..15347fb 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,9 @@ When the active provider is Agora, users operate uniquely named, independently v Context Usage remains independent from MemoryPatch state: the TUI continues to show used/trigger/window/source from `agent.getContextUsage()`. Internalization never clears context, and compaction never claims to internalize memory. The MA portable release pins the exact Agora `0.2.0` Mach-O artifact. Agora npm user artifacts contain no `.py`, `.pyc`, `.js`, or source maps and require no login or device activation. +MA marks an npm/bundled runtime as verified only when its Developer ID signature, +published npm integrity, accepted notarization evidence, and platform manifest SHA +match `src/provider/agora-runtime-lock.json`; ad-hoc candidates remain development-only. ## Built-In Tools diff --git a/README.zh-CN.md b/README.zh-CN.md index 7b01c20..c2caefd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -190,6 +190,9 @@ MA 可以把 Agora 作为 provider 自己管理的 MCP stdio 子进程运行, Context Usage 与 MemoryPatch 是两条独立状态:底栏始终从 `agent.getContextUsage()` 显示 used/trigger/window/source;内化不会清空 context,compact 也不会冒充记忆内化。 正式 MA portable 包锁定 Agora `0.2.0` 的 Mach-O 平台制品。Agora npm 用户制品不包含 `.py/.pyc/.js/.map`;用户无需登录或机器授权即可运行。 +MA 只有在 Developer ID 签名、已发布 npm integrity、Apple 公证证据和平台 +manifest SHA 均与 `src/provider/agora-runtime-lock.json` 一致时才显示 verified; +ad-hoc 候选包只能用于开发联调。 ## 内置工具 diff --git a/package.json b/package.json index bd1a40c..0d9fa5d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "start": "tsx src/cli/index.tsx", "build": "tsc && chmod +x dist/src/cli/index.js bin/ma scripts/package-portable.mjs", "package:portable": "npm run build && node scripts/package-portable.mjs --out release", + "sync:agora-release": "node scripts/sync-agora-release.mjs", "release:check": "npm test && npm run package:portable", "dev": "tsx watch src/cli/index.tsx", "test": "tsx --test test/*.test.ts", diff --git a/scripts/package-portable.mjs b/scripts/package-portable.mjs index 8244643..2265fcb 100755 --- a/scripts/package-portable.mjs +++ b/scripts/package-portable.mjs @@ -7,6 +7,7 @@ import { createHash } from 'node:crypto'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')); +const agoraLock = JSON.parse(fs.readFileSync(path.join(root, 'src', 'provider', 'agora-runtime-lock.json'), 'utf-8')); function arg(name, fallback) { const idx = process.argv.indexOf(name); @@ -43,6 +44,9 @@ function copyAgoraRuntime(appDir, target) { throw new Error('macos-arm64 portable release requires MA_AGORA_ARTIFACT_DIR with Agora 0.2.0 native artifact'); } const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (agoraLock.published !== true) { + throw new Error('macos-arm64 portable release requires a published, notarized Agora release lock'); + } if (manifest.version !== '0.2.0' || manifest.host_protocol_major !== 1) { throw new Error(`unexpected Agora contract: ${manifest.version}/host-v${manifest.host_protocol_major}`); } @@ -69,8 +73,13 @@ function copyAgoraRuntime(appDir, target) { throw new Error(`Agora user artifact contains source-like files: ${sourceLike.join(', ')}`); } const packageIntegrity = process.env.MA_AGORA_PACKAGE_INTEGRITY?.trim(); - if (!packageIntegrity?.startsWith('sha512-')) { - throw new Error('macos-arm64 portable release requires MA_AGORA_PACKAGE_INTEGRITY from the exact npm tarball'); + const expectedIntegrity = agoraLock.packages?.['@zimoos/agora-darwin-arm64']?.integrity; + if (packageIntegrity !== expectedIntegrity) { + throw new Error('macos-arm64 portable release package integrity does not match the pinned Agora release'); + } + const manifestSha = sha256(manifestPath); + if (manifestSha !== agoraLock.manifest_sha256) { + throw new Error('macos-arm64 portable release manifest SHA does not match the pinned Agora release'); } const destination = path.join(appDir, 'resources', 'agora'); copyRequired(source, destination); @@ -82,8 +91,10 @@ function copyAgoraRuntime(appDir, target) { platform: 'darwin-arm64', host_protocol_major: 1, native_core_abi: 1, + published: true, + notarization_id: agoraLock.notarization_id, package_integrity: packageIntegrity, - manifest_sha256: sha256(manifestPath), + manifest_sha256: manifestSha, }, null, 2) + '\n', 'utf8' ); diff --git a/scripts/sync-agora-release.mjs b/scripts/sync-agora-release.mjs new file mode 100644 index 0000000..f6865e8 --- /dev/null +++ b/scripts/sync-agora-release.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const VERSION = '0.2.0'; +const PACKAGE_NAMES = ['@zimoos/agora', '@zimoos/agora-darwin-arm64']; + +function fail(message) { + throw new Error(`Agora release evidence rejected: ${message}`); +} + +export function buildReleaseLock(evidence) { + if (evidence?.version !== VERSION || evidence?.published !== true) { + fail(`expected published Agora ${VERSION}`); + } + if (evidence?.notarization?.status !== 'Accepted' || typeof evidence?.notarization?.id !== 'string') { + fail('Apple notarization must be Accepted and include a submission id'); + } + if (!/^[a-f0-9]{64}$/.test(String(evidence.manifest_sha256 ?? ''))) { + fail('manifest_sha256 is missing or malformed'); + } + const signatures = Object.values(evidence.signatures ?? {}); + if (signatures.length < 5 || signatures.some((item) => ( + !String(item?.authority ?? '').startsWith('Developer ID Application:') || !String(item?.team_id ?? '') + ))) { + fail('all native files must carry a Developer ID Application signature and Team ID'); + } + const packages = Object.fromEntries((evidence.packages ?? []).map((item) => [item.name, item])); + for (const name of PACKAGE_NAMES) { + const item = packages[name]; + if (item?.version !== VERSION || !String(item?.integrity ?? '').startsWith('sha512-')) { + fail(`missing exact npm integrity for ${name}@${VERSION}`); + } + const forbidden = (item.files ?? []).filter((file) => /\.(py|pyc|js|map|pem|key|p12)$/i.test(file)); + if (forbidden.length > 0) fail(`${name} contains forbidden files: ${forbidden.join(', ')}`); + } + const teamIds = new Set(signatures.map((item) => item.team_id)); + if (teamIds.size !== 1) fail('native files are not signed by one Apple team'); + return { + version: VERSION, + platform: 'darwin-arm64', + host_protocol_major: 1, + native_core_abi: 1, + published: true, + notarization_id: evidence.notarization.id, + manifest_sha256: evidence.manifest_sha256, + capabilities: ['mcp-stdio', 'memory-profile-v2', 'memory-intake-v2'], + packages: Object.fromEntries(PACKAGE_NAMES.map((name) => [name, { integrity: packages[name].integrity }])), + }; +} + +export function syncReleaseEvidence(evidencePath, workspaceRoot = root) { + const evidence = JSON.parse(fs.readFileSync(evidencePath, 'utf8')); + const lock = buildReleaseLock(evidence); + const lockPath = path.join(workspaceRoot, 'src', 'provider', 'agora-runtime-lock.json'); + const packageLockPath = path.join(workspaceRoot, 'package-lock.json'); + const packageLock = JSON.parse(fs.readFileSync(packageLockPath, 'utf8')); + for (const name of PACKAGE_NAMES) { + const key = `node_modules/${name}`; + if (packageLock.packages?.[key]?.version !== VERSION) { + fail(`package-lock is missing exact ${name}@${VERSION}`); + } + packageLock.packages[key].integrity = lock.packages[name].integrity; + } + fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`, 'utf8'); + fs.writeFileSync(packageLockPath, `${JSON.stringify(packageLock, null, 2)}\n`, 'utf8'); + return lock; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const evidencePath = process.argv[2] ? path.resolve(process.argv[2]) : null; + if (!evidencePath) { + console.error('usage: node scripts/sync-agora-release.mjs /path/to/release-evidence.json'); + process.exit(2); + } + try { + console.log(JSON.stringify(syncReleaseEvidence(evidencePath), null, 2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/src/provider/agora-runtime-lock.json b/src/provider/agora-runtime-lock.json new file mode 100644 index 0000000..aca148b --- /dev/null +++ b/src/provider/agora-runtime-lock.json @@ -0,0 +1,22 @@ +{ + "version": "0.2.0", + "platform": "darwin-arm64", + "host_protocol_major": 1, + "native_core_abi": 1, + "published": false, + "notarization_id": null, + "manifest_sha256": "5af44c6ed85e3bafc55c1ef83f574fb40aae4cdc5a6e5920841411808cdd35dc", + "capabilities": [ + "mcp-stdio", + "memory-profile-v2", + "memory-intake-v2" + ], + "packages": { + "@zimoos/agora": { + "integrity": "sha512-iUCroM4rOS5lXgRSKsXG/S5OyhHDKMssioEW3/fFYkqOptayeImZBMVngmFn1kipnaxe7mhlZ2U+v1Xl8dEEQQ==" + }, + "@zimoos/agora-darwin-arm64": { + "integrity": "sha512-/CxLj4ZMjaSaLPGQ9o5OlrN8Yd55sxacvWD3o3djX0hHL8BXTnL3IPcAsJVpG7+yQCuG0i43z1vSBh25O26+8g==" + } + } +} diff --git a/src/provider/agora.ts b/src/provider/agora.ts index f27e851..458ef0a 100644 --- a/src/provider/agora.ts +++ b/src/provider/agora.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; import { createHash } from 'node:crypto'; +import agoraReleaseLock from './agora-runtime-lock.json' with { type: 'json' }; import type { ChatCompletion, ChatCompletionCreateParamsNonStreaming, @@ -267,9 +268,18 @@ interface ResolvedAgoraCommand { lock?: Record; } +export function parseDeveloperIdSignature(details: string): boolean { + return details.split(/\r?\n/).some((line) => line.startsWith('Authority=Developer ID Application:')) && + details.split(/\r?\n/).some((line) => line.startsWith('TeamIdentifier=') && line !== 'TeamIdentifier=not set'); +} + function verifyNativeSignature(command: string): boolean { if (process.platform !== 'darwin') return true; - return spawnSync('/usr/bin/codesign', ['--verify', '--strict', '--verbose=2', command], { stdio: 'ignore' }).status === 0; + if (spawnSync('/usr/bin/codesign', ['--verify', '--strict', '--verbose=2', command], { stdio: 'ignore' }).status !== 0) { + return false; + } + const details = spawnSync('/usr/bin/codesign', ['-d', '--verbose=4', command], { encoding: 'utf8' }); + return details.status === 0 && parseDeveloperIdSignature(`${details.stdout ?? ''}\n${details.stderr ?? ''}`); } function sha256File(file: string): string { @@ -296,10 +306,14 @@ function verifyBundledAgora(command: string): Record | null { try { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + if (agoraReleaseLock.published !== true || lock.published !== true) return null; if (!verifyAgoraManifestFiles(root, manifest)) return null; if (manifest.version !== lock.version || manifest.host_protocol_major !== lock.host_protocol_major) return null; if (lock.native_core_abi !== undefined && manifest.native_core_abi !== lock.native_core_abi) return null; if (lock.manifest_sha256 && sha256File(manifestPath) !== lock.manifest_sha256) return null; + if (lock.manifest_sha256 !== agoraReleaseLock.manifest_sha256) return null; + if (lock.package_integrity !== agoraReleaseLock.packages['@zimoos/agora-darwin-arm64'].integrity) return null; + if (lock.notarization_id !== agoraReleaseLock.notarization_id) return null; if (!verifyNativeSignature(command)) return null; return lock; } catch { @@ -309,29 +323,46 @@ function verifyBundledAgora(command: string): Record | null { function resolveInstalledAgoraPackage(): ResolvedAgoraCommand | null { try { + if (agoraReleaseLock.published !== true) return null; const require = createRequire(import.meta.url); const packageJsonPath = require.resolve('@zimoos/agora/package.json'); const packageRoot = path.dirname(packageJsonPath); + const platformPackageJsonPath = require.resolve('@zimoos/agora-darwin-arm64/package.json'); + const platformRoot = path.dirname(platformPackageJsonPath); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, 'manifest.json'), 'utf8')); + const platformPackageJson = JSON.parse(fs.readFileSync(platformPackageJsonPath, 'utf8')); + const manifestPath = path.join(platformRoot, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const command = path.join(packageRoot, 'bin', 'agora'); + const platformCommand = path.join(platformRoot, 'bin', 'agora'); const requiredCapabilities = ['mcp-stdio', 'memory-profile-v2', 'memory-intake-v2']; if ( packageJson.version !== '0.2.0' || packageJson.dependencies?.['@zimoos/agora-darwin-arm64'] !== '0.2.0' || + platformPackageJson.version !== '0.2.0' || manifest.version !== '0.2.0' || manifest.host_protocol_major !== 1 || manifest.native_core_abi !== 1 || !requiredCapabilities.every((capability) => manifest.capabilities?.includes(capability)) || + sha256File(manifestPath) !== agoraReleaseLock.manifest_sha256 || + !verifyAgoraManifestFiles(platformRoot, manifest) || !commandExists(command) || - !verifyNativeSignature(command) + !commandExists(platformCommand) || + !verifyNativeSignature(command) || + !verifyNativeSignature(platformCommand) ) return null; return { command, args: ['mcp', 'serve'], trust: 'verified', source: 'npm', - lock: { version: '0.2.0', host_protocol_major: 1, native_core_abi: 1 }, + lock: { + version: '0.2.0', + host_protocol_major: 1, + native_core_abi: 1, + manifest_sha256: agoraReleaseLock.manifest_sha256, + notarization_id: agoraReleaseLock.notarization_id, + }, }; } catch { return null; diff --git a/test/agora-release-lock.test.ts b/test/agora-release-lock.test.ts new file mode 100644 index 0000000..c17a623 --- /dev/null +++ b/test/agora-release-lock.test.ts @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildReleaseLock } from '../scripts/sync-agora-release.mjs'; +import { parseDeveloperIdSignature } from '../src/provider/agora.js'; + +const evidence = () => ({ + version: '0.2.0', + published: true, + notarization: { id: 'notary-id', status: 'Accepted' }, + manifest_sha256: 'a'.repeat(64), + signatures: Object.fromEntries(Array.from({ length: 5 }, (_, index) => [ + `bin-${index}`, + { authority: 'Developer ID Application: ZimoOS (TEAM123)', team_id: 'TEAM123' }, + ])), + packages: [ + { name: '@zimoos/agora', version: '0.2.0', integrity: 'sha512-launcher', files: ['package/bin/agora'] }, + { name: '@zimoos/agora-darwin-arm64', version: '0.2.0', integrity: 'sha512-platform', files: ['package/bin/agora'] }, + ], +}); + +test('release lock accepts only published, notarized, Developer ID signed evidence', () => { + const lock = buildReleaseLock(evidence()); + assert.equal(lock.published, true); + assert.equal(lock.notarization_id, 'notary-id'); + assert.equal(lock.packages['@zimoos/agora-darwin-arm64'].integrity, 'sha512-platform'); + + assert.throws(() => buildReleaseLock({ ...evidence(), published: false }), /expected published/); + assert.throws(() => buildReleaseLock({ ...evidence(), notarization: { status: 'Invalid' } }), /notarization/); + const adHoc = evidence(); + adHoc.signatures['bin-0'] = { authority: '', team_id: '' }; + assert.throws(() => buildReleaseLock(adHoc), /Developer ID/); +}); + +test('Developer ID parser rejects ad-hoc signatures', () => { + assert.equal(parseDeveloperIdSignature('Signature=adhoc\nTeamIdentifier=not set'), false); + assert.equal( + parseDeveloperIdSignature('Authority=Developer ID Application: ZimoOS (TEAM123)\nTeamIdentifier=TEAM123'), + true + ); +}); From 8e110a5f99f91270483a3afbb074cf15f8182f8d Mon Sep 17 00:00:00 2001 From: zhuqingyv Date: Mon, 13 Jul 2026 18:10:15 +0800 Subject: [PATCH 5/5] fix: display the actual MA package version --- src/cli/components/Banner.tsx | 3 ++- src/cli/version.ts | 21 +-------------------- src/mcp/client.ts | 3 ++- src/version.ts | 18 ++++++++++++++++++ test/cli-ux.test.ts | 1 + test/client.test.ts | 23 +++++++++++++++++++++++ 6 files changed, 47 insertions(+), 22 deletions(-) create mode 100644 src/version.ts diff --git a/src/cli/components/Banner.tsx b/src/cli/components/Banner.tsx index 139dd22..683a533 100644 --- a/src/cli/components/Banner.tsx +++ b/src/cli/components/Banner.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Box, Text } from 'ink'; +import { VERSION } from '../../version.js'; interface BannerProps { model: string; @@ -26,7 +27,7 @@ export function Banner({ model, baseURL, mcp }: BannerProps) { ))} {LOGO[LOGO.length - 1]} - v1.0.0 + v{VERSION} diff --git a/src/cli/version.ts b/src/cli/version.ts index ea1dfa3..47c8f5f 100644 --- a/src/cli/version.ts +++ b/src/cli/version.ts @@ -1,20 +1 @@ -import fs from 'node:fs'; - -export const VERSION = readPackageVersion(); - -function readPackageVersion(): string { - for (const packageUrl of [ - new URL('../../package.json', import.meta.url), - new URL('../../../package.json', import.meta.url), - ]) { - try { - const pkg = JSON.parse(fs.readFileSync(packageUrl, 'utf-8')) as { version?: unknown }; - if (typeof pkg.version === 'string' && pkg.version.length > 0) { - return pkg.version; - } - } catch { - // Source execution and compiled execution need different relative package paths. - } - } - return '0.0.0-unknown'; -} +export { VERSION } from '../version.js'; diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 56db118..76c0419 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { setMaxListeners } from 'node:events'; import * as fs from 'node:fs'; +import { VERSION } from '../version.js'; import type { McpConnection, McpServerConfig, @@ -268,7 +269,7 @@ export class McpClient implements McpConnection { await this.request('initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, - clientInfo: { name: 'my-agent', version: '1.0.0' }, + clientInfo: { name: 'my-agent', version: VERSION }, }); try { this.notify('notifications/initialized'); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..9e1dc55 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,18 @@ +import fs from 'node:fs'; + +export const VERSION = readPackageVersion(); + +function readPackageVersion(): string { + for (const packageUrl of [ + new URL('../package.json', import.meta.url), + new URL('../../package.json', import.meta.url), + ]) { + try { + const pkg = JSON.parse(fs.readFileSync(packageUrl, 'utf-8')) as { version?: unknown }; + if (typeof pkg.version === 'string' && pkg.version.length > 0) return pkg.version; + } catch { + // Source and compiled execution have different package-relative paths. + } + } + return '0.0.0-unknown'; +} diff --git a/test/cli-ux.test.ts b/test/cli-ux.test.ts index e059d3d..419542e 100644 --- a/test/cli-ux.test.ts +++ b/test/cli-ux.test.ts @@ -57,6 +57,7 @@ test('CLI UX PTY: DeepSeek status bar uses provider capability instead of 33k fa try { await waitFor(() => /win\s+1m\s+registry/.test(stripAnsi(output)), 10_000); const plain = stripAnsi(output); + assert.match(plain, /v0\.3\.0/); assert.match(plain, /ctx:/); assert.match(plain, /trigger/); assert.match(plain, /win 1m registry/); diff --git a/test/client.test.ts b/test/client.test.ts index eb173f2..ac5ef44 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -6,6 +6,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { PassThrough } from 'node:stream'; import { buildMcpEnv, McpClient } from '../src/mcp/client.js'; +import { VERSION } from '../src/version.js'; function fakeProc() { const stdin = new PassThrough(); @@ -85,6 +86,28 @@ test('McpClient.request: round-trip via id matching', async () => { assert.deepEqual(result, { pong: true }); }); +test('McpClient.initialize advertises the package version', async () => { + const proc = fakeProc(); + const client = new McpClient('exec', proc); + const seen: any[] = []; + proc.stdin.on('data', (chunk: Buffer) => { + for (const line of chunk.toString('utf-8').split('\n')) { + if (!line.trim()) continue; + const message = JSON.parse(line); + seen.push(message); + if (message.method === 'initialize') { + proc.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} })}\n`); + } + } + }); + + await client.initialize(); + + const initialize = seen.find((message) => message.method === 'initialize'); + assert.deepEqual(initialize.params.clientInfo, { name: 'my-agent', version: VERSION }); + assert.ok(seen.some((message) => message.method === 'notifications/initialized')); +}); + test('McpClient.request: error response rejects', async () => { const proc = fakeProc(); const client = new McpClient('exec', proc);