From dd24a39c293da9f70715fa7de263840ef6d7854d Mon Sep 17 00:00:00 2001 From: mcell Date: Sun, 2 Aug 2026 14:54:23 +0800 Subject: [PATCH 01/13] fix(site): repair site:dev and allowBuilds placeholders The root site:* scripts filtered a workspace package (@memo-code/site) that does not exist - site is a standalone pnpm project. Switch to pnpm --dir site, matching the deploy workflow. Also fill in the allowBuilds placeholders in site/pnpm-workspace.yaml that pnpm 11 rejected (native binaries for next dev/build). --- package.json | 6 +- .../docs/en/{soul.mdx => project-guide.mdx} | 0 site/content/docs/en/web-console.mdx | 65 ------------------- .../docs/zh/{soul.mdx => project-guide.mdx} | 0 site/content/docs/zh/web-console.mdx | 65 ------------------- site/pnpm-lock.yaml | 10 --- site/pnpm-workspace.yaml | 8 +-- 7 files changed, 7 insertions(+), 147 deletions(-) rename site/content/docs/en/{soul.mdx => project-guide.mdx} (100%) delete mode 100644 site/content/docs/en/web-console.mdx rename site/content/docs/zh/{soul.mdx => project-guide.mdx} (100%) delete mode 100644 site/content/docs/zh/web-console.mdx diff --git a/package.json b/package.json index df224cc..8e5f699 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,9 @@ "start": "tsx packages/tui/src/cli.tsx", "build": "tsup", "dev": "tsup --watch", - "site:dev": "pnpm --filter @memo-code/site dev", - "site:build": "pnpm --filter @memo-code/site build", - "site:start": "pnpm --filter @memo-code/site start", + "site:dev": "pnpm --dir site dev", + "site:build": "pnpm --dir site build", + "site:start": "pnpm --dir site start", "format": "oxfmt packages site package.json pnpm-workspace.yaml tsconfig.json tsup.config.ts vitest.config.ts vitest.setup.ts .oxfmtrc.json", "format:check": "oxfmt --check packages site package.json pnpm-workspace.yaml tsconfig.json tsup.config.ts vitest.config.ts vitest.setup.ts .oxfmtrc.json", "lint": "oxlint packages site", diff --git a/site/content/docs/en/soul.mdx b/site/content/docs/en/project-guide.mdx similarity index 100% rename from site/content/docs/en/soul.mdx rename to site/content/docs/en/project-guide.mdx diff --git a/site/content/docs/en/web-console.mdx b/site/content/docs/en/web-console.mdx deleted file mode 100644 index b86e0ea..0000000 --- a/site/content/docs/en/web-console.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: 'Web Console' -description: 'Run memo web, sign in, and manage chat, MCP, and skills in the browser.' -order: 7 -category: 'Core Features' ---- - -# Web Console - -Memo includes a browser-based console for chat and runtime management. -It supports multi-workspace project management and concurrent live sessions (up to 20 per server process). - -## Start Web Mode - -```bash -memo web --host 127.0.0.1 --port 5494 --open -``` - -Useful options: - -- `--host ` -- `--port ` -- `--open` / `--no-open` -- `--static-dir ` (custom web-ui static assets) - -If running from source checkout, build first: - -```bash -pnpm run build -``` - -npm distribution already bundles the required web assets. - -## Login and Auth Config - -- Default auth config path: `~/.memo/server.yaml` -- Override path: `MEMO_SERVER_CONFIG` -- Login page reads: - - `auth.username` - - `auth.password` - -On first `memo web` startup, Memo creates `server.yaml` with generated secrets and a random password. - -## Main Areas - -- `Chat`: session timeline, tool actions, approvals, and turn state. -- `Projects`: add project, browse directories, create/delete sessions. -- `MCP Servers` (sidebar): add/edit/remove/login/logout and active toggle. -- `Skills` (sidebar): create/delete skills, view details, and active toggle. -- `Settings`: general web preferences and account details. - -## Activation State Persistence - -Web-side activation changes are persisted to Memo config files: - -- MCP active set: `active_mcp_servers` in `~/.memo/config.toml` -- Skills active set: `active_skills` in `~/.memo/config.toml` (absolute `SKILL.md` paths) - -Skill activation directly affects system-prompt skill injection for new runtime sessions. - -## Operational Notes - -- `memo web` auto-picks the next available port if preferred port is occupied. -- Changing server auth in `server.yaml` requires restarting the web server process. -- MCP/skill config updates are reflected in new sessions immediately after refresh/reload flows. diff --git a/site/content/docs/zh/soul.mdx b/site/content/docs/zh/project-guide.mdx similarity index 100% rename from site/content/docs/zh/soul.mdx rename to site/content/docs/zh/project-guide.mdx diff --git a/site/content/docs/zh/web-console.mdx b/site/content/docs/zh/web-console.mdx deleted file mode 100644 index f56c201..0000000 --- a/site/content/docs/zh/web-console.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: 'Web 控制台' -description: '通过 memo web 在浏览器中管理聊天、MCP 与 Skills。' -order: 7 -category: 'Core Features' ---- - -# Web 控制台 - -Memo 提供浏览器端控制台,用于聊天和运行时管理。 -它支持多 workspace 项目管理和并发实时会话(单个 server 进程上限 20)。 - -## 启动 Web 模式 - -```bash -memo web --host 127.0.0.1 --port 5494 --open -``` - -常用参数: - -- `--host ` -- `--port ` -- `--open` / `--no-open` -- `--static-dir `(自定义 web-ui 静态资源目录) - -如果从源码仓库运行,请先构建: - -```bash -pnpm run build -``` - -npm 分发包已包含所需 web 资源。 - -## 登录与认证配置 - -- 默认认证配置路径:`~/.memo/server.yaml` -- 自定义路径:`MEMO_SERVER_CONFIG` -- 登录页读取: - - `auth.username` - - `auth.password` - -首次运行 `memo web` 时,会自动创建 `server.yaml`,包含随机初始密码和认证密钥。 - -## 主要区域 - -- `Chat`:会话时间线、工具动作、审批与轮次状态。 -- `Projects`:添加项目、浏览目录、创建/删除会话。 -- `MCP Servers`(侧边栏):新增/编辑/删除/登录/登出与激活开关。 -- `Skills`(侧边栏):创建/删除 skill、查看详情与激活开关。 -- `Settings`:Web 偏好设置与账户信息。 - -## 激活态持久化 - -Web 端的激活状态变更会写入 Memo 配置: - -- MCP 激活集合:`~/.memo/config.toml` 中的 `active_mcp_servers` -- Skills 激活集合:`~/.memo/config.toml` 中的 `active_skills`(`SKILL.md` 绝对路径) - -Skill 激活状态会直接影响新会话系统提示中的 skill 注入结果。 - -## 运行说明 - -- 当首选端口被占用时,`memo web` 会自动尝试下一个可用端口。 -- 修改 `server.yaml` 认证信息后,需要重启 web server 进程。 -- MCP/skills 配置更新后,通过刷新/重载并新建会话可立即生效。 diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 009a1b4..3ff5000 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -775,18 +775,15 @@ packages: acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} - hasBin: true astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} - hasBin: true bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} - hasBin: true caniuse-lite@1.0.30001768: resolution: {integrity: sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==} @@ -939,7 +936,6 @@ packages: jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1198,7 +1194,6 @@ packages: nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} @@ -1226,7 +1221,6 @@ packages: next@16.1.6: resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} engines: {node: '>=20.9.0'} - hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 '@playwright/test': ^1.51.1 @@ -1250,7 +1244,6 @@ packages: oxlint@1.76.0: resolution: {integrity: sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==} engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true peerDependencies: oxlint-tsgolint: '>=7.0.2001' vite-plus: '*' @@ -1340,7 +1333,6 @@ packages: semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} - hasBin: true sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} @@ -1398,7 +1390,6 @@ packages: typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} - hasBin: true undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -1447,7 +1438,6 @@ packages: yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} - hasBin: true zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} diff --git a/site/pnpm-workspace.yaml b/site/pnpm-workspace.yaml index 435e4d5..94cadb6 100644 --- a/site/pnpm-workspace.yaml +++ b/site/pnpm-workspace.yaml @@ -2,10 +2,10 @@ packages: - '.' allowBuilds: - '@parcel/watcher': set this to true or false - '@swc/core': set this to true or false - sharp: set this to true or false + # Native binaries used by next dev/build: allow their postinstall scripts. + '@parcel/watcher': true + '@swc/core': true + sharp: true ignoredBuiltDependencies: - - sharp - unrs-resolver From bcf1ebe865c96de9dc75eaa71682eb25e270b48a Mon Sep 17 00:00:00 2001 From: mcell Date: Sun, 2 Aug 2026 14:56:36 +0800 Subject: [PATCH 02/13] feat(core): raise default auto-compact threshold to 90% Align with Codex: compact at 90% of the context window instead of 80%, so the effective context before compaction grows while the hard limit still sits at 100%. --- packages/core/src/agent/loop.ts | 2 +- packages/core/src/config/config.test.ts | 6 +++--- packages/core/src/config/config.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index 91e624d..3360f65 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -58,7 +58,7 @@ import { toToolHistoryMessage, } from './messages' -const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT = 80 +const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT = 90 const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000 export type SessionOperationKind = 'turn' | 'compact' diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index e62029b..024e5ec 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -142,7 +142,7 @@ describe('mcp config serialization', () => { expect(text).toContain('active_skills = ["/tmp/.memo/skills/doc-writing/SKILL.md"]') expect(text).toContain('mcp_oauth_credentials_store_mode = "file"') expect(text).toContain('mcp_oauth_callback_port = 8765') - expect(text).toContain('auto_compact_threshold_percent = 80') + expect(text).toContain('auto_compact_threshold_percent = 90') expect(text).toContain('[model_profiles.gpt-5]') expect(text).toContain('supports_parallel_tool_calls = true') expect(text).toContain('context_window = 272000') @@ -287,7 +287,7 @@ model = "deepseek-chat" const loaded = await loadMemoConfig() expect(loaded.config.mcp_oauth_credentials_store_mode).toBe('auto') - expect(loaded.config.auto_compact_threshold_percent).toBe(80) + expect(loaded.config.auto_compact_threshold_percent).toBe(90) }) test('loadMemoConfig reads custom auto_compact_threshold_percent', async () => { @@ -325,7 +325,7 @@ model = "deepseek-chat" await writeFile(join(home, 'config.toml'), configText, 'utf-8') const loaded = await loadMemoConfig() - expect(loaded.config.auto_compact_threshold_percent).toBe(80) + expect(loaded.config.auto_compact_threshold_percent).toBe(90) }) test('loadMemoConfig ignores legacy max_prompt_tokens', async () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c99d1c5..f504480 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -68,7 +68,7 @@ type ParsedMemoConfig = Omit, 'providers'> & { providers?: u const DEFAULT_MEMO_HOME = join(homedir(), '.memo') const DEFAULT_SESSIONS_DIR = 'sessions' const DEFAULT_CONTEXT_WINDOW = 120000 -const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT = 80 +const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT = 90 const DEFAULT_CONFIG: MemoConfig = { current_provider: 'deepseek', From 3bb8421d64e4309139acd313aedd461ab61d152c Mon Sep 17 00:00:00 2001 From: mcell Date: Sun, 2 Aug 2026 15:00:20 +0800 Subject: [PATCH 03/13] fix(core): stop truncating every message in compaction requests The per-message 4k-char truncation cut most tool outputs (file contents, search results, command output) out of the compaction request, so the summary lost the actual work done - plans, decisions, and context. After compaction the model had to re-explore from scratch. Now only the budget selector limits the request: it drops the oldest messages to fit the window, and only truncates a single message when it alone exceeds the budget (tail kept). Normal transcripts reach the compaction model in full, including update_plan tool results, so the plan survives into the summary. --- .../core/src/agent/compact_prompt.test.ts | 76 ++++++++++++++++++- packages/core/src/agent/compact_prompt.ts | 50 +++++++++--- 2 files changed, 110 insertions(+), 16 deletions(-) diff --git a/packages/core/src/agent/compact_prompt.test.ts b/packages/core/src/agent/compact_prompt.test.ts index 7f9fafa..b7abf82 100644 --- a/packages/core/src/agent/compact_prompt.test.ts +++ b/packages/core/src/agent/compact_prompt.test.ts @@ -35,8 +35,9 @@ describe('compact_prompt', () => { const prompt = buildCompactionUserPrompt(messages) assert.ok(prompt.includes('[0] ASSISTANT (tool_calls: exec_command)')) assert.ok(prompt.includes('[1] TOOL (exec_command)')) - // Truncation keeps the tail (tool result/error at the end carries the info). - assert.ok(prompt.includes(`...${'x'.repeat(4_000)}`)) + // Long tool output is NOT truncated per-message: the budget selector is + // the only truncation point, so the compaction model sees full outputs. + assert.ok(prompt.includes(longToolOutput)) assert.ok(prompt.includes('Return only the summary body in plain text. Do not add markdown fences.')) }) @@ -93,13 +94,80 @@ describe('compact_prompt', () => { ) }) - test('selectCompactionMessages keeps the newest message even when it exceeds the budget', () => { + test('selectCompactionMessages truncates a newest message that alone exceeds the budget', () => { const messages: ChatMessage[] = [ { role: 'user', content: 'old' }, { role: 'assistant', content: 'x'.repeat(500) }, ] const selected = selectCompactionMessages(messages, 10, (text) => text.length) - assert.deepStrictEqual(selected, [messages[1]]) + assert.strictEqual(selected.length, 1) + const content = selected[0]?.content + assert.strictEqual(typeof content, 'string') + // Tail kept (with the truncation marker), head dropped. + assert.ok(String(content).startsWith('...')) + assert.ok(String(content).endsWith('x')) + assert.ok(String(content).length < 500) + }) + + test('plan from update_plan tool results survives into the compaction prompt', () => { + const planJson = JSON.stringify({ + message: 'Plan updated', + plan: [ + { step: 'Implement the parser', status: 'in_progress' }, + { step: 'Wire up the CLI flag', status: 'pending' }, + { step: 'Add tests for edge cases', status: 'pending' }, + ], + }) + const messages: ChatMessage[] = [ + { role: 'user', content: 'Refactor the parser' }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Let me update the plan' }], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'plan-1', + toolName: 'update_plan', + output: { type: 'text', value: planJson }, + }, + ], + }, + { role: 'user', content: 'Continue with step two' }, + ] + + const selected = selectCompactionMessages(messages, 10_000, (text) => text.length) + const prompt = buildCompactionUserPrompt(selected) + assert.ok(prompt.includes('Implement the parser'), 'plan steps must reach the compaction model') + assert.ok(prompt.includes('Wire up the CLI flag')) + assert.ok(prompt.includes('update_plan')) + }) + + test('selectCompactionMessages truncates oversized tool results keeping the tail', () => { + const messages: ChatMessage[] = [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-1', + toolName: 'exec_command', + output: { type: 'text', value: `head-noise\n${'y'.repeat(1_000)}` }, + }, + ], + }, + ] + const selected = selectCompactionMessages(messages, 20, (text) => text.length) + assert.strictEqual(selected.length, 1) + const part = selected[0]?.content + assert.ok(Array.isArray(part)) + const value = part?.[0]?.type === 'tool-result' ? part[0].output.value : '' + assert.ok(String(value).startsWith('...')) + assert.ok(String(value).endsWith('y')) + assert.ok(String(value).length < 1_000) + assert.ok(!String(value).includes('head-noise'), 'tool output head is dropped') }) test('selectCompactionMessages returns empty for an empty array', () => { diff --git a/packages/core/src/agent/compact_prompt.ts b/packages/core/src/agent/compact_prompt.ts index e7555d2..2f34a9c 100644 --- a/packages/core/src/agent/compact_prompt.ts +++ b/packages/core/src/agent/compact_prompt.ts @@ -1,7 +1,5 @@ import type { ChatMessage } from '@memo/core/types' -const MAX_MESSAGE_CONTENT_CHARS = 4_000 - export const CONTEXT_COMPACTION_SYSTEM_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. Include: @@ -16,13 +14,29 @@ export const CONTEXT_SUMMARY_PREFIX = 'Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:' function normalizeContent(content: string): string { - const compact = content.replace(/\r\n/g, '\n').trim() - if (compact.length <= MAX_MESSAGE_CONTENT_CHARS) { - return compact + return content.replace(/\r\n/g, '\n').trim() +} + +/** + * Truncate a single message to maxChars, keeping the tail (tool output carries + * its result/error at the end, while the head is usually echoes and noise). + * Only applied by selectCompactionMessages when one message alone exceeds the + * budget - regular transcripts are never truncated per-message. + */ +function truncateMessage(message: ChatMessage, maxChars: number): ChatMessage { + if (typeof message.content === 'string') { + if (message.content.length <= maxChars) return message + return { ...message, content: `...${message.content.slice(-maxChars)}` } + } + return { + ...message, + content: message.content.map((part) => { + if (part.type === 'tool-result' && part.output.type === 'text' && part.output.value.length > maxChars) { + return { ...part, output: { ...part.output, value: `...${part.output.value.slice(-maxChars)}` } } + } + return part + }), } - // Keep the tail: tool output carries its result/error at the end, while the - // head is usually command echoes and noise. - return `...${compact.slice(-MAX_MESSAGE_CONTENT_CHARS)}` } function messageToTranscriptLine(message: ChatMessage, index: number): string { @@ -60,9 +74,11 @@ export function isContextSummaryMessage(message: ChatMessage): boolean { /** * Drop the oldest messages so the serialized transcript fits within - * budgetTokens, keeping the newest message unconditionally. Returns the - * selected messages in their original order (indices are preserved — gaps - * mark the dropped messages). + * budgetTokens, keeping the newest message unconditionally. A single message + * that alone exceeds the budget is truncated to fit (tail kept), so the + * compaction request can never overflow the window. Returns the selected + * messages in their original order (indices are preserved — gaps mark the + * dropped messages). */ export function selectCompactionMessages( messages: ChatMessage[], @@ -80,10 +96,20 @@ export function selectCompactionMessages( if (!message) { continue } - const tokens = countTokens(messageToTranscriptLine(message, i)) + 1 // +1 for the '\n\n' separator + const line = messageToTranscriptLine(message, i) + const tokens = countTokens(line) + 1 // +1 for the '\n\n' separator if (selected.length > 0 && used + tokens > budgetTokens) { break } + if (selected.length === 0 && used + tokens > budgetTokens) { + // Newest message alone exceeds the budget: truncate it to fit. + // Linear scale from the estimated tokens gives a close-enough + // character budget for the tail. + const ratio = Math.min(1, Math.max(0, budgetTokens - used) / Math.max(1, tokens - 1)) + const maxChars = Math.max(1, Math.floor(line.length * ratio)) + selected.push(truncateMessage(message, maxChars)) + break + } selected.push(message) used += tokens if (used >= budgetTokens) { From 28843bf6ff543754cfb44822ee7d2e4e7304be58 Mon Sep 17 00:00:00 2001 From: mcell Date: Sun, 2 Aug 2026 15:05:59 +0800 Subject: [PATCH 04/13] feat(core): default context window to 1M tokens DeepSeek-V4 (pro/flash) supports a 1M-token context window, which the 120k default underused by 8x. Models with a smaller real window can still override via model_profiles context_window; the hard limit check and 90% auto-compact threshold scale with the configured window. --- packages/core/src/agent/constants.ts | 2 +- packages/core/src/config/config.test.ts | 2 +- packages/core/src/config/config.ts | 2 +- site/content/docs/en/configuration.mdx | 4 ++-- site/content/docs/zh/configuration.mdx | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/agent/constants.ts b/packages/core/src/agent/constants.ts index acfde3b..ba26e79 100644 --- a/packages/core/src/agent/constants.ts +++ b/packages/core/src/agent/constants.ts @@ -3,7 +3,7 @@ import type { SessionMode } from '@memo/core/types' import type { ToolActionStatus } from '@memo/core/tools/approval' export const DEFAULT_SESSION_MODE: SessionMode = 'interactive' -export const DEFAULT_CONTEXT_WINDOW = 120_000 +export const DEFAULT_CONTEXT_WINDOW = 1_000_000 export const TOOL_ACTION_SUCCESS_STATUS: ToolActionStatus = 'success' export const TOOL_DISABLED_ERROR_MESSAGE = 'Tool usage is disabled in the current permission mode. Switch to /core/tools once or /core/tools full to enable tools.' diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 024e5ec..c7fc39a 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -408,7 +408,7 @@ describe('resolveContextWindowForProvider', () => { { name: 'openai', model: 'gpt-5', env_api_key: 'OPENAI_API_KEY' }, ) - expect(contextWindow).toBe(120000) + expect(contextWindow).toBe(1_000_000) }) test('matches keys case-insensitively and trims surrounding spaces', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index f504480..b5eeecd 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -67,7 +67,7 @@ type ParsedMemoConfig = Omit, 'providers'> & { providers?: u const DEFAULT_MEMO_HOME = join(homedir(), '.memo') const DEFAULT_SESSIONS_DIR = 'sessions' -const DEFAULT_CONTEXT_WINDOW = 120000 +const DEFAULT_CONTEXT_WINDOW = 1_000_000 const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT = 90 const DEFAULT_CONFIG: MemoConfig = { diff --git a/site/content/docs/en/configuration.mdx b/site/content/docs/en/configuration.mdx index 7d37f9a..4428f0b 100644 --- a/site/content/docs/en/configuration.mdx +++ b/site/content/docs/en/configuration.mdx @@ -128,7 +128,7 @@ Session context window is resolved with this priority: 1. `model_profiles."provider:model".context_window` 2. `model_profiles."".context_window` -3. default `120000` +3. default `1000000` Memo applies this at startup and re-applies it when switching models with `/models`. @@ -146,7 +146,7 @@ Result: - `openai:gpt-5` -> `128000` - other providers using `gpt-5` -> `272000` -- any model without `context_window` override -> `120000` +- any model without `context_window` override -> `1000000` ## MCP Server Configuration diff --git a/site/content/docs/zh/configuration.mdx b/site/content/docs/zh/configuration.mdx index de4aa6b..f2ceb7c 100644 --- a/site/content/docs/zh/configuration.mdx +++ b/site/content/docs/zh/configuration.mdx @@ -127,7 +127,7 @@ supports_parallel_tool_calls = false 1. `model_profiles."provider:model".context_window` 2. `model_profiles."".context_window` -3. 默认 `120000` +3. 默认 `1000000` Memo 在启动时应用此设置,并在使用 `/models` 切换模型时重新应用。 @@ -145,7 +145,7 @@ context_window = 128000 - `openai:gpt-5` -> `128000` - 其他使用 `gpt-5` 的提供商 -> `272000` -- 任何没有 `context_window` 覆盖的模型 -> `120000` +- 任何没有 `context_window` 覆盖的模型 -> `1000000` ## MCP 服务器配置 From 0cd4377a7ae5b8eab516e31c71cffada2c34a3a0 Mon Sep 17 00:00:00 2001 From: mcell Date: Sun, 2 Aug 2026 15:07:06 +0800 Subject: [PATCH 05/13] feat: agent fork/registry/status modules and site docs overhaul Core: add fork (spawn child sessions with filtered history), agent registry (name lookup for collaboration), communication (subagent messaging), and status (agent state) modules, wiring them through withDefaultDeps via createChildHistory. Site: restructure docs (project-guide replaces soul, drop web-console), refresh en/zh guides, components, and i18n messages. --- packages/core/src/agent/communication.test.ts | 19 + packages/core/src/agent/communication.ts | 89 +++++ packages/core/src/agent/defaults.ts | 31 +- packages/core/src/agent/fork.test.ts | 28 ++ packages/core/src/agent/fork.ts | 48 +++ packages/core/src/agent/registry.test.ts | 24 ++ packages/core/src/agent/registry.ts | 157 ++++++++ packages/core/src/agent/status.test.ts | 23 ++ packages/core/src/agent/status.ts | 40 ++ site/app/[lang]/home-client.tsx | 8 +- .../components/memo-architecture-remotion.tsx | 82 +---- site/components/memo-hero-remotion.tsx | 8 +- site/content/docs/en/README.mdx | 20 +- site/content/docs/en/cli-tui.mdx | 39 +- site/content/docs/en/configuration.mdx | 230 ++++++------ site/content/docs/en/getting-started.mdx | 15 +- site/content/docs/en/mcp.mdx | 8 - site/content/docs/en/plan.mdx | 3 + site/content/docs/en/project-guide.mdx | 342 +++--------------- site/content/docs/en/sessions-history.mdx | 8 + site/content/docs/en/tools.mdx | 7 + site/content/docs/zh/README.mdx | 20 +- site/content/docs/zh/cli-tui.mdx | 39 +- site/content/docs/zh/configuration.mdx | 30 +- site/content/docs/zh/getting-started.mdx | 15 +- site/content/docs/zh/mcp.mdx | 8 - site/content/docs/zh/plan.mdx | 2 + site/content/docs/zh/project-guide.mdx | 326 ++--------------- site/content/docs/zh/sessions-history.mdx | 8 + site/content/docs/zh/skills.mdx | 53 ++- site/content/docs/zh/tools.mdx | 7 + site/lib/i18n/messages/en.json | 8 +- site/lib/i18n/messages/zh.json | 8 +- 33 files changed, 885 insertions(+), 868 deletions(-) create mode 100644 packages/core/src/agent/communication.test.ts create mode 100644 packages/core/src/agent/communication.ts create mode 100644 packages/core/src/agent/fork.test.ts create mode 100644 packages/core/src/agent/fork.ts create mode 100644 packages/core/src/agent/registry.test.ts create mode 100644 packages/core/src/agent/registry.ts create mode 100644 packages/core/src/agent/status.test.ts create mode 100644 packages/core/src/agent/status.ts diff --git a/packages/core/src/agent/communication.test.ts b/packages/core/src/agent/communication.test.ts new file mode 100644 index 0000000..40c5738 --- /dev/null +++ b/packages/core/src/agent/communication.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'vitest' +import { InputQueue } from './communication' + +describe('InputQueue', () => { + test('wakes event-driven waiters without polling', async () => { + const queue = new InputQueue() + const waiting = queue.waitForActivity(1_000) + queue.enqueue({ author: '/root/a', recipient: '/root', content: 'done', triggerTurn: false }) + await expect(waiting).resolves.toBe('mailbox') + }) + + test('only drains a triggered batch when it contains trigger work', () => { + const queue = new InputQueue() + queue.enqueue({ author: '/root', recipient: '/root/a', content: 'note', triggerTurn: false }) + expect(queue.drainTriggeredBatch()).toEqual([]) + queue.enqueue({ author: '/root', recipient: '/root/a', content: 'continue', triggerTurn: true }) + expect(queue.drainTriggeredBatch().map((message) => message.content)).toEqual(['note', 'continue']) + }) +}) diff --git a/packages/core/src/agent/communication.ts b/packages/core/src/agent/communication.ts new file mode 100644 index 0000000..9169e86 --- /dev/null +++ b/packages/core/src/agent/communication.ts @@ -0,0 +1,89 @@ +export type InterAgentCommunication = { + author: string + recipient: string + content: string + triggerTurn: boolean +} + +export type InputQueueActivity = 'mailbox' | 'closed' | 'aborted' + +type ActivityWaiter = (activity: InputQueueActivity) => void + +export class InputQueue { + private messages: InterAgentCommunication[] = [] + private waiters = new Set() + private closed = false + + enqueue(communication: InterAgentCommunication): void { + if (this.closed) throw new Error('agent mailbox is closed') + this.messages.push(communication) + this.publish('mailbox') + } + + hasMessages(): boolean { + return this.messages.length > 0 + } + + hasTrigger(): boolean { + return this.messages.some((message) => message.triggerTurn) + } + + drainAll(): InterAgentCommunication[] { + if (this.messages.length === 0) return [] + const drained = this.messages + this.messages = [] + return drained + } + + drainTriggeredBatch(): InterAgentCommunication[] { + if (!this.hasTrigger()) return [] + return this.drainAll() + } + + async waitForActivity(timeoutMs: number, signal?: AbortSignal): Promise { + if (this.hasMessages()) return 'mailbox' + if (this.closed) return 'closed' + if (signal?.aborted) return 'aborted' + + return new Promise((resolve) => { + let settled = false + const finish = (activity: InputQueueActivity) => { + if (settled) return + settled = true + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + this.waiters.delete(finish) + resolve(activity) + } + const onAbort = () => finish('aborted') + const timer = setTimeout(() => finish('closed'), timeoutMs) + + this.waiters.add(finish) + signal?.addEventListener('abort', onAbort, { once: true }) + + // Recheck after subscribing so an enqueue cannot be missed at the boundary. + if (this.hasMessages()) finish('mailbox') + else if (this.closed) finish('closed') + }) + } + + close(): void { + if (this.closed) return + this.closed = true + this.publish('closed') + } + + private publish(activity: InputQueueActivity): void { + for (const waiter of [...this.waiters]) waiter(activity) + } +} + +export function formatInterAgentCommunication(message: InterAgentCommunication): string { + return `\n${message.content}\n` +} + +function escapeAttribute(value: string): string { + return value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} diff --git a/packages/core/src/agent/defaults.ts b/packages/core/src/agent/defaults.ts index c8ab5c1..2575416 100644 --- a/packages/core/src/agent/defaults.ts +++ b/packages/core/src/agent/defaults.ts @@ -35,15 +35,7 @@ export function filterMcpServersBySelection( return filtered } -/** - * Complete dependencies with default strategy (tools, callLLM, prompt, history sinks, tokenizer). - * Caller can provide only callbacks/overrides, rest use default implementations. - */ -export async function withDefaultDeps( - deps: AgentSessionDeps, - options: AgentSessionOptions, - sessionId: string, -): Promise<{ +export type ResolvedSessionDeps = { tools: ToolSet callLLM: CallLLM loadPrompt: () => Promise @@ -52,7 +44,18 @@ export async function withDefaultDeps( dispose: () => Promise historyFilePath?: string skillIndex: SkillIndex -}> { + createChildHistory: (sessionId: string) => { historySinks: HistorySink[]; historyFilePath?: string } +} + +/** + * Complete dependencies with default strategy (tools, callLLM, prompt, history sinks, tokenizer). + * Caller can provide only callbacks/overrides, rest use default implementations. + */ +export async function withDefaultDeps( + deps: AgentSessionDeps, + options: AgentSessionOptions, + sessionId: string, +): Promise { const loaded = await loadMemoConfig() const config = loaded.config @@ -146,5 +149,13 @@ export async function withDefaultDeps( tokenCounter: deps.tokenCounter ?? createTokenCounter(), historyFilePath: historyFilePath, skillIndex, + createChildHistory: (childSessionId) => { + if (deps.historySinks) return { historySinks: [] } + const childHistoryFilePath = buildSessionPath(sessionsDir, childSessionId) + return { + historySinks: [new JsonlHistorySink(childHistoryFilePath)], + historyFilePath: childHistoryFilePath, + } + }, } } diff --git a/packages/core/src/agent/fork.test.ts b/packages/core/src/agent/fork.test.ts new file mode 100644 index 0000000..866f98a --- /dev/null +++ b/packages/core/src/agent/fork.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest' +import { buildForkHistory } from './fork' + +describe('buildForkHistory', () => { + test('keeps user turns and final assistant text while dropping tools and reasoning', () => { + const history = buildForkHistory( + [ + { role: 'system', content: 'parent' }, + { role: 'user', content: 'first' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'answer' }, + { type: 'reasoning', text: 'private' }, + ], + }, + { role: 'tool', content: [] }, + { role: 'user', content: 'second' }, + ], + 'child', + 1, + ) + expect(history).toEqual([ + { role: 'system', content: 'child' }, + { role: 'user', content: 'second' }, + ]) + }) +}) diff --git a/packages/core/src/agent/fork.ts b/packages/core/src/agent/fork.ts new file mode 100644 index 0000000..c105c12 --- /dev/null +++ b/packages/core/src/agent/fork.ts @@ -0,0 +1,48 @@ +import type { ChatMessage } from '@memo/core/types' + +export type ForkTurns = 'none' | 'all' | number + +export function parseForkTurns(value: string | undefined): ForkTurns { + const normalized = value?.trim().toLowerCase() || 'all' + if (normalized === 'none' || normalized === 'all') return normalized + const parsed = Number.parseInt(normalized, 10) + if (!/^\d+$/.test(normalized) || parsed <= 0) { + throw new Error('fork_turns must be `none`, `all`, or a positive integer string') + } + return parsed +} + +export function buildForkHistory( + parentHistory: ChatMessage[], + childSystemPrompt: string, + forkTurns: ForkTurns, +): ChatMessage[] { + if (forkTurns === 'none') return [{ role: 'system', content: childSystemPrompt }] + + let source = parentHistory + if (typeof forkTurns === 'number') { + const userPositions = parentHistory + .map((message, index) => (message.role === 'user' ? index : -1)) + .filter((index) => index >= 0) + const start = userPositions[Math.max(0, userPositions.length - forkTurns)] ?? parentHistory.length + source = parentHistory.slice(start) + } + + const inherited = source.flatMap((message): ChatMessage[] => { + if (message.role === 'user') return [message] + if (message.role !== 'assistant') return [] + if (typeof message.content === 'string') return message.content ? [message] : [] + + const text = message.content + .filter((part): part is Extract<(typeof message.content)[number], { type: 'text' }> => part.type === 'text') + .map((part) => part.text) + .join('') + return text ? [{ role: 'assistant', content: text }] : [] + }) + + return [{ role: 'system', content: childSystemPrompt }, ...inherited] +} + +export function buildSubagentSystemPrompt(parentSystemPrompt: string, agentPath: string): string { + return `${parentSystemPrompt}\n\nYou are sub-agent ${agentPath}. Work only on the assigned task. Preserve repository instructions, coordinate through the collaboration tools when needed, and return a concise result to your parent agent.` +} diff --git a/packages/core/src/agent/registry.test.ts b/packages/core/src/agent/registry.test.ts new file mode 100644 index 0000000..bd5813a --- /dev/null +++ b/packages/core/src/agent/registry.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest' +import { AgentRegistry, ExecutionLimiter } from './registry' + +describe('AgentRegistry', () => { + test('reserves paths atomically and rolls failed spawns back', () => { + const registry = new AgentRegistry() + registry.registerRoot('root-id') + const reservation = registry.reserve('/root', 'review', 3) + expect(() => registry.reserve('/root', 'review', 3)).toThrow('already exists') + reservation.release() + expect(registry.reserve('/root', 'review', 3).agentPath).toBe('/root/review') + }) +}) + +describe('ExecutionLimiter', () => { + test('limits running turns independently of registered identities', () => { + const limiter = new ExecutionLimiter(1) + const first = limiter.tryAcquire() + expect(first).not.toBeNull() + expect(limiter.tryAcquire()).toBeNull() + first?.release() + expect(limiter.tryAcquire()).not.toBeNull() + }) +}) diff --git a/packages/core/src/agent/registry.ts b/packages/core/src/agent/registry.ts new file mode 100644 index 0000000..f370ec2 --- /dev/null +++ b/packages/core/src/agent/registry.ts @@ -0,0 +1,157 @@ +import type { AgentStatusSnapshot } from '@memo/core/agent/status' + +export const ROOT_AGENT_PATH = '/root' + +export type AgentMetadata = AgentStatusSnapshot & { + agentId: string + agentPath: string + taskName: string + parentId?: string + parentPath?: string + updatedAt: string +} + +export function agentPathDepth(path: string): number { + if (path === ROOT_AGENT_PATH) return 0 + return path.split('/').filter(Boolean).length - 1 +} + +export function joinAgentPath(parentPath: string, taskName: string): string { + return `${parentPath}/${taskName}` +} + +export function validateTaskName(taskName: string): string { + const normalized = taskName.trim() + if (!/^[a-z0-9][a-z0-9_-]*$/i.test(normalized)) { + throw new Error('task_name must contain only letters, numbers, underscores, or hyphens') + } + return normalized +} + +export class SpawnReservation { + private active = true + + constructor( + readonly agentPath: string, + private releaseReservation: () => void, + ) {} + + commit(): void { + this.active = false + } + + release(): void { + if (!this.active) return + this.active = false + this.releaseReservation() + } +} + +export class AgentRegistry { + private byId = new Map() + private byPath = new Map() + private reservedPaths = new Set() + + registerRoot(agentId: string): void { + const now = new Date().toISOString() + const root: AgentMetadata = { + agentId, + agentPath: ROOT_AGENT_PATH, + taskName: 'root', + status: 'completed', + updatedAt: now, + } + this.byId.set(agentId, root) + this.byPath.set(ROOT_AGENT_PATH, agentId) + } + + reserve(parentPath: string, taskName: string, maxDepth: number): SpawnReservation { + const normalized = validateTaskName(taskName) + const path = joinAgentPath(parentPath, normalized) + if (agentPathDepth(path) > maxDepth) { + throw new Error(`subagent depth limit reached (${maxDepth})`) + } + if (this.byPath.has(path) || this.reservedPaths.has(path)) { + throw new Error(`agent path already exists: ${path}`) + } + this.reservedPaths.add(path) + return new SpawnReservation(path, () => this.reservedPaths.delete(path)) + } + + register(metadata: AgentMetadata, reservation: SpawnReservation): void { + if (reservation.agentPath !== metadata.agentPath || this.byPath.has(metadata.agentPath)) { + throw new Error(`agent path already exists: ${metadata.agentPath}`) + } + this.reservedPaths.delete(metadata.agentPath) + this.byId.set(metadata.agentId, metadata) + this.byPath.set(metadata.agentPath, metadata.agentId) + reservation.commit() + } + + getById(agentId: string): AgentMetadata | undefined { + return this.byId.get(agentId) + } + + getByPath(agentPath: string): AgentMetadata | undefined { + const id = this.byPath.get(agentPath) + return id ? this.byId.get(id) : undefined + } + + resolve(target: string, senderPath: string): AgentMetadata | undefined { + const direct = this.getById(target) + if (direct) return direct + if (target.startsWith('/')) return this.getByPath(target) + return this.getByPath(joinAgentPath(senderPath, target)) ?? this.getByPath(`${ROOT_AGENT_PATH}/${target}`) + } + + update(agentId: string, update: Partial): AgentMetadata | undefined { + const current = this.byId.get(agentId) + if (!current) return undefined + const next = { ...current, ...update, updatedAt: new Date().toISOString() } + this.byId.set(agentId, next) + return next + } + + list(pathPrefix = ROOT_AGENT_PATH): AgentMetadata[] { + return [...this.byId.values()] + .filter((agent) => agent.agentPath !== ROOT_AGENT_PATH) + .filter((agent) => agent.agentPath === pathPrefix || agent.agentPath.startsWith(`${pathPrefix}/`)) + .sort((left, right) => left.agentPath.localeCompare(right.agentPath)) + } + + remove(agentId: string): void { + const metadata = this.byId.get(agentId) + if (!metadata || metadata.agentPath === ROOT_AGENT_PATH) return + this.byId.delete(agentId) + this.byPath.delete(metadata.agentPath) + } +} + +export type ExecutionPermit = { release: () => void } + +export class ExecutionLimiter { + private active = 0 + + constructor( + private readonly capacity: number, + private readonly onRelease?: () => void, + ) {} + + tryAcquire(): ExecutionPermit | null { + if (this.active >= this.capacity) return null + this.active += 1 + let released = false + return { + release: () => { + if (released) return + released = true + this.active -= 1 + this.onRelease?.() + }, + } + } + + get activeCount(): number { + return this.active + } +} diff --git a/packages/core/src/agent/status.test.ts b/packages/core/src/agent/status.test.ts new file mode 100644 index 0000000..8dcc0a1 --- /dev/null +++ b/packages/core/src/agent/status.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest' +import { deriveAgentStatusFromEvent, isFinalAgentStatus } from './status' + +describe('agent status', () => { + test('derives lifecycle status from session events', () => { + let status = { status: 'pending_init' as const } + status = deriveAgentStatusFromEvent(status, { type: 'turn_start' }) + expect(status.status).toBe('running') + status = deriveAgentStatusFromEvent(status, { type: 'final', content: 'done' }) + status = deriveAgentStatusFromEvent(status, { type: 'turn_end', meta: { status: 'ok' } }) + expect(status).toEqual({ status: 'completed', lastMessage: 'done' }) + expect(isFinalAgentStatus(status.status)).toBe(true) + }) + + test('keeps interrupted agents resumable', () => { + const status = deriveAgentStatusFromEvent( + { status: 'running' }, + { type: 'turn_end', meta: { status: 'cancelled' } }, + ) + expect(status.status).toBe('interrupted') + expect(isFinalAgentStatus(status.status)).toBe(false) + }) +}) diff --git a/packages/core/src/agent/status.ts b/packages/core/src/agent/status.ts new file mode 100644 index 0000000..0be956e --- /dev/null +++ b/packages/core/src/agent/status.ts @@ -0,0 +1,40 @@ +import type { HistoryEvent } from '@memo/core/types' + +export type AgentStatus = 'pending_init' | 'running' | 'interrupted' | 'completed' | 'errored' | 'shutdown' + +export type AgentStatusSnapshot = { + status: AgentStatus + lastMessage?: string + error?: string +} + +export function isFinalAgentStatus(status: AgentStatus): boolean { + return status === 'completed' || status === 'errored' || status === 'shutdown' +} + +export function deriveAgentStatusFromEvent( + current: AgentStatusSnapshot, + event: Pick, +): AgentStatusSnapshot { + if (event.type === 'final' && event.content !== undefined) { + return { ...current, lastMessage: event.content } + } + + if (event.type === 'turn_start') { + return { status: 'running', lastMessage: current.lastMessage } + } + + if (event.type === 'turn_end') { + const turnStatus = event.meta?.status + if (turnStatus === 'ok') return { status: 'completed', lastMessage: current.lastMessage } + if (turnStatus === 'cancelled') return { status: 'interrupted', lastMessage: current.lastMessage } + const error = typeof event.meta?.error_message === 'string' ? event.meta.error_message : undefined + return { status: 'errored', lastMessage: current.lastMessage, error } + } + + if (event.type === 'session_end') { + return { status: 'shutdown', lastMessage: current.lastMessage } + } + + return current +} diff --git a/site/app/[lang]/home-client.tsx b/site/app/[lang]/home-client.tsx index ff1d8e5..23060c6 100644 --- a/site/app/[lang]/home-client.tsx +++ b/site/app/[lang]/home-client.tsx @@ -4,7 +4,7 @@ import Image from 'next/image' import { SiteHeader } from '@/components/site-header' import { MemoHeroRemotion } from '@/components/memo-hero-remotion' import { MemoArchitectureDiagram } from '@/components/memo-architecture-remotion' -import { ArrowRight, Terminal, Cpu, Shield, GitBranch, Layers, Zap } from 'lucide-react' +import { ArrowRight, Terminal, Cpu, Shield, GitBranch, Sparkles, Zap } from 'lucide-react' import Link from 'next/link' const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '' @@ -36,9 +36,9 @@ export function HomeClient({ descKey: 'home.features.items.terminalNative.description', }, { - icon: Layers, - titleKey: 'home.features.items.webConsole.title', - descKey: 'home.features.items.webConsole.description', + icon: Sparkles, + titleKey: 'home.features.items.liveReasoning.title', + descKey: 'home.features.items.liveReasoning.description', }, { icon: Shield, diff --git a/site/components/memo-architecture-remotion.tsx b/site/components/memo-architecture-remotion.tsx index f309720..3d26199 100644 --- a/site/components/memo-architecture-remotion.tsx +++ b/site/components/memo-architecture-remotion.tsx @@ -205,7 +205,7 @@ export function MemoArchitectureDiagram() { - web to server - - - - - server to core - - - - - - + + -
- - -
-
- - - + ` also works (for direct switch). -## Web Mode Command +## CLI Subcommands + +Memo also ships standalone subcommands: ```bash -memo web --host 127.0.0.1 --port 5494 --open +memo init # generate AGENTS.md for the current project (idempotent) +memo mcp list # list configured MCP servers +memo mcp add -- +memo mcp login # OAuth login for remote MCP servers +memo skills list # list discovered skills +memo skills read # read a skill's full SKILL.md ``` -This starts the local web server and opens the browser UI. +See [MCP Integration](./mcp.md) and [Skills System](./skills.md) for details. ## Shortcuts - `Enter`: send message - `Shift+Enter`: insert newline -- `Tab`: accept current suggestion +- `Tab`: accept current suggestion; on an empty input, toggle thinking mode - `Up/Down`: move in suggestion list, or browse input history - `Esc`: close suggestion panel - `Esc Esc`: - while running: cancel current turn - while idle: clear current input +- `Ctrl+T`: toggle following live output - `Ctrl+L`: clear screen and start a new session +- `Ctrl+D`: exit (press twice on an empty input); deletes the char at the cursor otherwise - `Ctrl+C`: exit ## Approvals in TUI diff --git a/site/content/docs/en/configuration.mdx b/site/content/docs/en/configuration.mdx index 4428f0b..3a2beb8 100644 --- a/site/content/docs/en/configuration.mdx +++ b/site/content/docs/en/configuration.mdx @@ -1,44 +1,39 @@ --- -title: 'Configuration' -description: 'Provider, config.toml, environment variables, and MCP settings.' +title: '配置' +description: '提供商、config.toml、环境变量和 MCP 设置。' order: 3 category: 'Getting Started' --- -# Configuration +# 配置 -Memo uses `config.toml` to manage provider selection, model profile overrides, and MCP servers. -This page focuses on what each key means and how Memo resolves model behavior at runtime. +Memo 使用 `config.toml` 来管理提供商选择、模型配置覆盖和 MCP 服务器。 +本页重点介绍每个键的含义以及 Memo 如何在运行时解析模型行为。 -## Config File Location +## 配置文件位置 -Default: +默认: - `~/.memo/config.toml` -If `MEMO_HOME` is set: +如果设置了 `MEMO_HOME`: -- config path becomes `$MEMO_HOME/config.toml` +- 配置路径变为 `$MEMO_HOME/config.toml` -For web auth config: +## 配置如何应用 -- default path is `~/.memo/server.yaml` -- set `MEMO_SERVER_CONFIG` to use a custom path +Memo 运行时使用以下顺序的配置: -## How Config Is Applied +1. 通过 `current_provider` 选择活跃提供商。 +2. 读取该提供商的 `model`。 +3. 从 `model_profiles` 解析模型配置覆盖。 +4. 从 `context_window` 策略解析会话上下文窗口。 -Memo runtime uses config in this order: +TUI 中的 `/models` 切换会为选定的提供商/模型重新运行此解析。 -1. Select active provider by `current_provider`. -2. Read that provider's `model`. -3. Resolve model profile overrides from `model_profiles`. -4. Resolve session context window from `context_window` policy. +## 提供商配置 -`/models` switch in TUI re-runs this resolution for the selected provider/model. - -## Provider Configuration - -### Minimal Example +### 最小示例 ```toml current_provider = "deepseek" @@ -50,7 +45,7 @@ model = "deepseek-chat" base_url = "https://api.deepseek.com" ``` -### Multiple Providers +### 多个提供商 ```toml current_provider = "deepseek" @@ -68,25 +63,24 @@ model = "gpt-4.1-mini" base_url = "https://api.openai.com/v1" ``` -### Provider Keys +### 提供商键 - `current_provider` - - Default provider name for new sessions. - - Must match one configured provider `name`. + - 新会话的默认提供商名称。 + - 必须匹配一个已配置的提供商 `name`。 - `providers..name` - - Provider identifier shown in `/models`. + - 在 `/models` 中显示的提供商标识符。 - `providers..env_api_key` - - Environment variable name used as first-priority API key lookup. + - 用作第一优先级 API 密钥查找的环境变量名称。 - `providers..model` - - Model ID sent to the upstream API. -- `providers..base_url` (optional) - - OpenAI-compatible endpoint. - - Useful for custom gateways or compatible providers. + - 发送到上游 API 的模型 ID。 +- `providers..base_url`(可选) + - OpenAI 兼容端点。 + - 适用于自定义网关或兼容提供商。 -## Model Profiles (Overrides) +## 模型配置(覆盖) -Memo uses conservative defaults for model capabilities and gates request options through local -`config.toml` overrides: +Memo 对模型能力使用保守默认值,并通过本地 `config.toml` 覆盖来控制请求选项: ```toml [model_profiles.gpt-5] @@ -94,45 +88,45 @@ supports_parallel_tool_calls = true supports_reasoning_content = true context_window = 272000 -# Provider-specific override, key format: ":" +# 提供商特定覆盖,键格式:":" [model_profiles."openai:gpt-5"] supports_parallel_tool_calls = false ``` -### Supported Fields +### 支持的字段 - `supports_parallel_tool_calls` - - Controls whether Memo sends `parallel_tool_calls`. + - 控制 Memo 是否发送 `parallel_tool_calls`。 - `supports_reasoning_content` - - Controls whether reasoning content is carried in tool-call follow-up rounds. + - 控制推理内容是否携带在工具调用后续轮次中。 - `context_window` - - Session prompt/context hard limit used by Memo runtime. - - Must be a positive number. + - Memo 运行时使用的会话提示/上下文硬限制。 + - 必须是正数。 -### Key Formats +### 键格式 - `[model_profiles.]` - - Model-level override, for all providers using this model name. + - 模型级覆盖,适用于所有使用此模型名称的提供商。 - `[model_profiles."provider:model"]` - - Provider-specific override, for one provider + model pair. - - Use quotes because `:` is in the key. + - 提供商特定覆盖,适用于一个提供商 + 模型对。 + - 因为键中有 `:`,所以使用引号。 -Example: +示例: -- `[model_profiles.gpt-5]` applies to any provider using `gpt-5`. -- `[model_profiles."openai:gpt-5"]` only applies when provider is `openai` and model is `gpt-5`. +- `[model_profiles.gpt-5]` 适用于任何使用 `gpt-5` 的提供商。 +- `[model_profiles."openai:gpt-5"]` 仅在提供商为 `openai` 且模型为 `gpt-5` 时适用。 -## Context Window Policy +## 上下文窗口策略 -Session context window is resolved with this priority: +会话上下文窗口按以下优先级解析: 1. `model_profiles."provider:model".context_window` 2. `model_profiles."".context_window` -3. default `1000000` +3. 默认 `1000000` -Memo applies this at startup and re-applies it when switching models with `/models`. +Memo 在启动时应用此设置,并在使用 `/models` 切换模型时重新应用。 -### Practical Example +### 实际示例 ```toml [model_profiles.gpt-5] @@ -142,40 +136,65 @@ context_window = 272000 context_window = 128000 ``` -Result: +结果: - `openai:gpt-5` -> `128000` -- other providers using `gpt-5` -> `272000` -- any model without `context_window` override -> `1000000` +- 其他使用 `gpt-5` 的提供商 -> `272000` +- 任何没有 `context_window` 覆盖的模型 -> `1000000` + +## 上下文压缩 + +当上下文使用量超过阈值时,长会话会被自动压缩: + +```toml +auto_compact_threshold_percent = 90 # 默认:90(1-100) +``` + +- 有效范围为 `1` 到 `100`;非法值回退为 `90` +- 压缩会总结早期上下文并继续会话,保持会话可用 +- `/compact` 可随时手动压缩 + +## MCP 服务器配置 + +### OAuth 设置(远程服务器) + +```toml +# OAuth 浏览器回调使用的本地端口(可选) +mcp_oauth_callback_port = 38433 + +# 凭据存储方式:"auto"(默认)/ "keyring" / "file" +mcp_oauth_credentials_store_mode = "auto" +``` -## MCP Server Configuration +- `mcp_oauth_callback_port`:`memo mcp login` 使用的固定回调端口;未设置时自动选择空闲端口。 +- `mcp_oauth_credentials_store_mode`:OAuth 令牌的持久化位置。 -### Local Stdio MCP +### 本地 Stdio MCP ```toml [mcp_servers.local_tools] command = "npx" args = ["-y", "@modelcontextprotocol/server-filesystem", "/your/path"] -# optional +# 可选 # type = "stdio" -# stderr = "inherit" # or "pipe" / "ignore" +# stderr = "inherit" # 或 "pipe" / "ignore" # [mcp_servers.local_tools.env] # FOO = "bar" ``` -### Remote Streamable HTTP MCP +### 远程 Streamable HTTP MCP ```toml [mcp_servers.remote] type = "streamable_http" url = "https://your-mcp-server.com/mcp" bearer_token_env_var = "MCP_TOKEN" -# optional custom headers +# 可选自定义头部 # http_headers = { "X-Team" = "platform" } -# or headers = { "X-Team" = "platform" } +# 或 headers = { "X-Team" = "platform" } ``` -## Manage MCP with CLI +## 使用 CLI 管理 MCP ```bash memo mcp list @@ -185,43 +204,42 @@ memo mcp add --url --bearer-token-env-var memo mcp remove ``` -Also available: +也可使用: ```bash memo mcp help ``` -OAuth login/logout is supported for `streamable_http` MCP servers: +`streamable_http` MCP 服务器支持 OAuth 登录/注销: ```bash memo mcp login [--scopes read,write] memo mcp logout ``` -## Runtime Environment Variables +## 运行时环境变量 -### Required for model calls +### 模型调用必需 -- ``: the env var name configured in provider config, used as first priority for API key lookup. -- `OPENAI_API_KEY`: fallback API key when provider-specific key is missing. -- `DEEPSEEK_API_KEY`: fallback API key when provider-specific key and `OPENAI_API_KEY` are missing. +- ``:提供商配置中配置的环境变量名,用作第一优先级的 API 密钥查找。 +- `OPENAI_API_KEY`:当提供商特定密钥缺失时的回退 API 密钥。 +- `DEEPSEEK_API_KEY`:当提供商特定密钥和 `OPENAI_API_KEY` 都缺失时的回退 API 密钥。 -### Common optional +### 常用可选 -- `MEMO_HOME`: override Memo home directory. +- `MEMO_HOME`:覆盖 Memo 主目录。 -## Skills Discovery +## Skills 发现 -Memo auto-discovers Agent Skills (`SKILL.md`) at startup and appends available skill metadata -into the runtime system prompt. +Memo 在启动时自动发现 Agent Skills(`SKILL.md`)并将可用的 skill 元数据附加到运行时系统提示中。 -### Discovery Paths +### 发现路径 -- Project scope: project root `./skills` directories (for example: `.agents/skills`, `.claude/skills`, `.codex/skills`) -- User scope: `$MEMO_HOME/skills` (or `~/.memo/skills`) -- Not scanned: `~/.xxx/skills` hidden directories outside Memo home +- 项目范围:项目根目录 `./skills` 目录(例如:`.agents/skills`、`.claude/skills`、`.codex/skills`) +- 用户范围:`$MEMO_HOME/skills`(或 `~/.memo/skills`) +- 不扫描:Memo 主目录外的 `~/.xxx/skills` 隐藏目录 -### Minimal `SKILL.md` +### 最小 `SKILL.md` ```md --- @@ -232,47 +250,47 @@ description: Generate and update technical documentation. # Doc Writing ``` -Memo reads `name` + `description` frontmatter and exposes each discovered skill to the model as: +Memo 读取 `name` + `description` frontmatter,并将每个发现的 skill 暴露给模型: - `- : (file: )` -Users can explicitly invoke by mentioning `$skill-name` in the prompt (example: `$doc-writing`). +用户可以通过在提示中提及 `$skill-name` 来显式调用(例如:`$doc-writing`)。 -### Default Active Skills +### 默认激活 Skills -You can persist a default skill activation set: +可以持久化默认激活 skill 集合: ```toml -# unset: all discovered skills are active -# []: no skills active by default +# 未设置:默认激活所有已发现 skills +# []:默认不激活任何 skill active_skills = [ "/absolute/path/to/.codex/skills/doc-writing/SKILL.md" ] ``` -`active_skills` stores absolute `SKILL.md` file paths. +`active_skills` 保存的是 `SKILL.md` 的绝对路径。 -### Advanced tool/runtime switches +### 高级工具/运行时开关 -- `MEMO_SHELL_TOOL_TYPE`: `unified_exec` (default) / `shell` / `shell_command` / `disabled`. -- `MEMO_FS_ALLOWED_ROOTS`: comma-separated additional allowed filesystem roots for file tools. -- `MEMO_ENABLE_MEMORY_TOOL=0`: disable `get_memory`. -- `MEMO_ENABLE_COLLAB_TOOLS=0`: disable subagent tools. -- `MEMO_SUBAGENT_COMMAND`: command used to spawn subagents. -- `MEMO_SUBAGENT_MAX_AGENTS`: max concurrently running subagents (default `4`). -- `MEMO_TOOL_RESULT_MAX_CHARS`: max chars kept from a single tool result before omission hint. -- `MEMO_SANDBOX_WRITABLE_ROOTS`: comma-separated absolute paths appended to writable sandbox roots for local write tools. +- `MEMO_SHELL_TOOL_TYPE`:`unified_exec`(默认)/ `shell` / `shell_command` / `disabled`。 +- `MEMO_FS_ALLOWED_ROOTS`:文件系统工具额外允许访问的根目录,逗号分隔。 +- `MEMO_ENABLE_MEMORY_TOOL=0`:禁用 `get_memory`。 +- `MEMO_ENABLE_COLLAB_TOOLS=0`:禁用子代理工具。 +- `MEMO_SUBAGENT_COMMAND`:用于生成子代理的命令。 +- `MEMO_SUBAGENT_MAX_AGENTS`:最大并发运行的子代理数(默认 `4`)。 +- `MEMO_TOOL_RESULT_MAX_CHARS`:单个工具结果在省略提示前保留的最大字符数。 +- `MEMO_SANDBOX_WRITABLE_ROOTS`:逗号分隔的绝对路径,附加到本地写入工具的写入沙箱根目录。 -### MCP auth by configuration +### MCP 配置认证 -- ``: if set in an MCP server config, Memo reads this env var and injects `Authorization: Bearer ` for that server. +- ``:如果在 MCP 服务器配置中设置,Memo 会读取此环境变量并为该服务器注入 `Authorization: Bearer `。 -### Notes +### 注意 -- `SHELL`, `USER`, and `USERNAME` are system env vars read as runtime fallback; they are not Memo-specific feature switches. +- `SHELL`、`USER` 和 `USERNAME` 是作为运行时回退读取的系统环境变量;它们不是 Memo 特定的功能开关。 -## Related +## 相关 -- [MCP Integration](./mcp.md) -- [Built-in Tools](./tools.md) -- [Sessions & History](./sessions-history.md) +- [MCP 集成](./mcp.md) +- [内置工具](./tools.md) +- [会话与历史](./sessions-history.md) diff --git a/site/content/docs/en/getting-started.mdx b/site/content/docs/en/getting-started.mdx index 8b06712..f167f2e 100644 --- a/site/content/docs/en/getting-started.mdx +++ b/site/content/docs/en/getting-started.mdx @@ -11,7 +11,7 @@ Set up Memo in a few minutes: install, configure an API key, and run your first ## Prerequisites -- Node.js `>= 20` +- Node.js `>= 22` - At least one compatible API key (for example `DEEPSEEK_API_KEY` or `OPENAI_API_KEY`) ## Install @@ -84,18 +84,17 @@ memo -d Only use this in trusted repositories and controlled environments. -## Web Console +## Other Entry Points -Start local web server + UI: +Besides the interactive session, Memo ships standalone subcommands: ```bash -memo web --host 127.0.0.1 --port 5494 --open +memo init # generate AGENTS.md for the current project +memo mcp list # list configured MCP servers +memo skills list # list discovered skills ``` -- npm distribution already includes web assets. -- If running from source checkout, run `pnpm run build` first. -- Login credentials come from `~/.memo/server.yaml` (`auth.username` / `auth.password`). -- The UI includes chat timeline, project/session management, MCP management, and skills management. +See [MCP Integration](./mcp.md) and [Skills System](./skills.md) for full details. ## Next Steps diff --git a/site/content/docs/en/mcp.mdx b/site/content/docs/en/mcp.mdx index b1b6e58..a4fdd2f 100644 --- a/site/content/docs/en/mcp.mdx +++ b/site/content/docs/en/mcp.mdx @@ -97,14 +97,6 @@ Use slash command: It shows MCP servers configured for the current session. -## View and Manage MCP in Web UI - -In web mode (`memo web`), open the `MCP Servers` page from the sidebar to: - -- create / edit / remove servers -- login / logout for OAuth-enabled remote servers -- toggle default active servers - ## When Config Changes Take Effect MCP servers are loaded when a session is created. diff --git a/site/content/docs/en/plan.mdx b/site/content/docs/en/plan.mdx index eb4a6f1..d9184ee 100644 --- a/site/content/docs/en/plan.mdx +++ b/site/content/docs/en/plan.mdx @@ -18,6 +18,9 @@ The `update_plan` tool allows you to: - Maintain context and explanations for the current task - Ensure only one step is in progress at a time +In the TUI, an active plan is rendered as a progress panel with per-step statuses, so you +can see task progress at a glance while the agent works. + ## Tool Details **Tool Name**: `update_plan` diff --git a/site/content/docs/en/project-guide.mdx b/site/content/docs/en/project-guide.mdx index 9961241..2768b0b 100644 --- a/site/content/docs/en/project-guide.mdx +++ b/site/content/docs/en/project-guide.mdx @@ -1,336 +1,84 @@ --- -title: 'Agent Persona & Project Guide' -description: 'Configure Agent behavior and project guidelines using SOUL.md and AGENTS.md.' +title: 'Project Guide (AGENTS.md)' +description: 'Configure project conventions and workflows with AGENTS.md.' order: 12 category: 'Core Features' --- -# Agent Persona & Project Guide +# Project Guide (AGENTS.md) -Memo supports customizing Agent behavior styles and project-specific guidelines through two files: SOUL.md and AGENTS.md. They serve different purposes and scopes. +AGENTS.md is a plain Markdown file that carries project-level instructions: structure, conventions, and workflows. Memo reads it automatically so that any agent working in the repository follows the same rules. -## File Comparison +## Where AGENTS.md Lives -| File | Location | Scope | Purpose | -| ------------- | ----------------- | ------------- | ------------------------------------------------------- | -| **SOUL.md** | `~/.memo/SOUL.md` | Global | User persona, communication style, personal preferences | -| **AGENTS.md** | Project root | Project-level | Project structure, tech stack, workflows, conventions | +AGENTS.md files may exist anywhere in the repository: -## SOUL.md - User Persona Configuration +- Project root (for example `/AGENTS.md`) +- Any subdirectory — each AGENTS.md applies to the entire directory tree rooted at the folder that contains it -SOUL.md is used to define your personal preferences, communication style, and subjective behavioral tendencies. This is a global configuration that affects Memo behavior across all projects. +Memo automatically detects and loads AGENTS.md at startup: -### Location +- For every file you touch, Memo obeys instructions in any AGENTS.md whose scope includes that file. +- More-deeply-nested AGENTS.md files take precedence on conflict. +- The AGENTS.md at the repo root is included in the prompt — no need to re-read it. -``` -~/.memo/SOUL.md -``` - -Or when `MEMO_HOME` is set: - -``` -$MEMO_HOME/SOUL.md -``` - -### Use Cases - -SOUL.md is suitable for configuring: - -- **Communication Style**: Concise/detailed, formal/casual -- **Code Preferences**: Functional/OOP, specific patterns -- **Work Habits**: Favorite tools, common command aliases -- **Learning Preferences**: Detailed explanations vs quick answers - -### Example SOUL.md - -```markdown -# User Preferences - -## Communication Style - -- Prefer concise, direct responses -- Use technical terminology freely -- Skip greetings and sign-offs -- One-word answers when sufficient - -## Code Preferences - -- Prefer functional programming patterns -- Favor immutable data structures -- Use TypeScript strict mode always -- Prefer explicit types over inference - -## Workflow - -- Favorite editor: VS Code -- Preferred package manager: pnpm -- Like to see multiple solution options -- Prefer early returns over nested conditionals - -## Learning Style - -- Show code examples over explanations -- Ask before refactoring unrelated code -- Point out potential pitfalls proactively -``` - -### Priority Rules - -SOUL.md content is treated as a **soft preference layer**: +## What to Put In It -- ✅ Influences tone, style, and subjective behavior -- ❌ Does not override safety rules or tool policies -- ❌ Does not override AGENTS.md guidance -- ❌ Does not override explicit user instructions in the current turn +AGENTS.md is a good place for: -### Performance Tips +- **Project Structure**: directory organization, important file locations +- **Tech Stack**: main frameworks, libraries, tool versions +- **Development Workflow**: build, test, deploy commands +- **Code Conventions**: naming, file organization, style guides +- **Security Notes**: sensitive files, environment variable handling +- **Known Issues**: common pitfalls and their solutions -Keep SOUL.md concise: +Keep it concise and focused on information unique to the project — avoid repeating what is already in `package.json` or the README. -- Content is injected into every session's system prompt -- Long content increases token consumption -- Focus on preferences that truly affect behavior +## Auto-Generation -## AGENTS.md - Project Guide - -AGENTS.md is used to define project-specific structure, conventions, and workflows. It resides in the project root and provides context for Agents working on that project. - -### Location - -``` -/AGENTS.md -``` - -Memo automatically detects and loads AGENTS.md from the project root at startup. - -### Use Cases - -AGENTS.md is suitable for documenting: - -- **Project Structure**: Directory organization, important file locations -- **Tech Stack**: Main frameworks, libraries, tool versions -- **Development Workflow**: Build, test, deploy commands -- **Code Conventions**: Naming conventions, file organization, style guides -- **Security Notes**: Sensitive files, environment variable handling -- **Known Issues**: Common pitfalls, solutions - -### Example AGENTS.md - -```markdown -# Project Guide - -## Tech Stack - -- **Framework**: Next.js 14 (App Router) -- **Language**: TypeScript 5.x -- **Styling**: Tailwind CSS + CSS Modules -- **State**: Zustand -- **Testing**: Vitest + React Testing Library -- **Database**: PostgreSQL + Prisma - -## Project Structure -``` - -myapp/ -├── app/ # Next.js App Router -│ ├── (marketing)/ # Route groups -│ ├── api/ # API routes -│ └── layout.tsx # Root layout -├── components/ # React components -│ ├── ui/ # Base UI components -│ └── features/ # Feature components -├── lib/ # Utilities and helpers -├── prisma/ # Database schema and migrations -├── public/ # Static assets -└── tests/ # Test files - -```` - -## Development Commands +Use the `/init` slash command inside a session, or run the CLI command: ```bash -# Install dependencies -pnpm install - -# Development server -pnpm dev - -# Run tests -pnpm test -pnpm test:watch - -# Database operations -pnpm db:migrate -pnpm db:seed -pnpm db:studio - -# Build -pnpm build - -# Lint and type check -pnpm lint -pnpm typecheck -```` - -## Code Conventions - -- Use named exports instead of default exports -- Place tests next to source files: `Component.tsx` + `Component.test.tsx` -- Use kebab-case for file names: `my-component.tsx` -- Prefer server components by default -- Use Zod for form validation - -## Environment Variables - -Required variables in `.env.local`: - -- `DATABASE_URL`: PostgreSQL connection string -- `NEXTAUTH_SECRET`: Random secret for auth -- `NEXTAUTH_URL`: App URL for callbacks - -## Important Notes - -- Never commit `.env.local` -- Run `pnpm typecheck` before committing -- Database migrations are in `/prisma/migrations` -- API documentation is in `/docs/api.md` - -``` - -### Auto-Generation - -You can use the `/init` slash command to have Memo generate AGENTS.md for the current project: - +memo init ``` -/init +Memo analyzes the current project and generates an `AGENTS.md` containing tech stack, commands, and conventions. -```` +`memo init` is idempotent like `git init`: -Memo will analyze the project structure and generate a template file containing tech stack, commands, and conventions. +- if `AGENTS.md` already exists, it is never touched — Memo skips with a message +- delete the file first if you want to regenerate it -### Maintenance Responsibility +## Maintenance Responsibility -**Important**: If you modify anything mentioned in AGENTS.md, remember to update AGENTS.md as well: +**Important**: if you modify anything mentioned in AGENTS.md, update it as well: -- Add/remove important directories -- Change build/test commands -- Update tech stack -- Modify code conventions +- add/remove important directories +- change build/test commands +- update tech stack +- modify code conventions -## How Both Files Work Together +## Context Loading Order -SOUL.md and AGENTS.md are loaded into the system prompt in this order: +Memo builds the system prompt in this order: 1. Base system prompt -2. **SOUL.md** (User persona layer) -3. **AGENTS.md** (Project guide layer) -4. Skills (if any) +2. **AGENTS.md** (project guide layer) +3. Skills (if any) This means: -- AGENTS.md can override SOUL.md preferences that conflict with the project -- User instructions in the current turn have highest priority -- Neither can override safety rules and tool policies - -## Practical Examples - -### Scenario 1: Code Review Style - -**SOUL.md**: -```markdown -## Code Review Style - -- Be direct and critical -- Focus on performance issues -- Suggest concrete improvements -- No need to praise working code -```` - -**AGENTS.md**: - -```markdown -## Code Review Checklist - -- [ ] Type safety: no `any` types -- [ ] Error handling: all async calls wrapped -- [ ] Tests: new features have tests -- [ ] Accessibility: ARIA labels for interactive elements -``` - -### Scenario 2: Technology Preferences - -**SOUL.md**: - -```markdown -## Technology Preferences - -- Prefer functional React components -- Like using hooks for state management -- Prefer explicit TypeScript types -``` - -**AGENTS.md**: - -```markdown -## Technology Stack - -- React 18 with hooks -- Zustand for state (not Redux) -- TypeScript with strict mode -- React Query for server state -``` - -## Troubleshooting - -### SOUL.md Not Taking Effect - -1. Confirm file is at `~/.memo/SOUL.md` -2. Check that file has content (not empty) -3. Restart Memo or create a new session -4. Check `MEMO_HOME` points to the correct location - -### AGENTS.md Not Loading - -1. Confirm file is in project root -2. Filename must be `AGENTS.md` (uppercase) -3. Start Memo from the project directory -4. Check that file content is not empty - -### File Content Too Long - -- **SOUL.md**: Only keep preferences that affect behavior, remove descriptive content -- **AGENTS.md**: Move detailed info to project docs, AGENTS.md should only contain key conventions and links - -### Configuration Conflicts - -If SOUL.md and AGENTS.md conflict: - -- Project-specific settings in AGENTS.md take priority -- Explicit instructions in the current turn have highest priority -- If issues persist, check the loading order of both files in the system prompt - -## Best Practices - -### SOUL.md - -- ✅ Keep it concise (recommended < 500 characters) -- ✅ Focus on preferences that truly affect interaction -- ✅ Update regularly to reflect changes -- ❌ Don't include project-specific information -- ❌ Don't repeat general programming principles +- AGENTS.md guidance applies to all work in its scope +- explicit user instructions in the current turn have the highest priority +- neither AGENTS.md nor skills can override safety rules and tool policies -### AGENTS.md +## Persistent Preferences -- ✅ Include information unique to the project -- ✅ List commonly used commands and paths -- ✅ Document important technical decisions -- ✅ Add context new team members need to know -- ❌ Don't repeat information already in package.json -- ❌ Don't include temporary or frequently changing content +For user-level preferences that should persist across projects, see the [Memory System](./memory.md) (`get_memory` reads `~/.memo/Agents.md`). ## Related -- [Memory System](./memory.md): Persistent user preferences (Agents.md) +- [Memory System](./memory.md): persistent user preferences - [Configuration](./configuration.md): config.toml details -- [Skills System](./skills.md): Domain-specific expertise +- [Skills System](./skills.md): domain-specific expertise diff --git a/site/content/docs/en/sessions-history.mdx b/site/content/docs/en/sessions-history.mdx index 8056487..7b1839a 100644 --- a/site/content/docs/en/sessions-history.mdx +++ b/site/content/docs/en/sessions-history.mdx @@ -54,6 +54,14 @@ Current behavior: - selecting one loads historical conversation context into current session - the current active session file is excluded from resume suggestions +Resuming also restores the session's UI state that was persisted with the log: + +- provider and model +- thinking toggle +- tool permission mode (`none` / `once` / `full`) +- context window +- input history + ## Practical Tips - Share relevant `.jsonl` path when asking others to investigate behavior. diff --git a/site/content/docs/en/tools.mdx b/site/content/docs/en/tools.mdx index 7d4b96c..0624e4e 100644 --- a/site/content/docs/en/tools.mdx +++ b/site/content/docs/en/tools.mdx @@ -72,6 +72,13 @@ These work with MCP servers loaded for the current session. `get_memory` reads from `~/.memo/Agents.md` (or `$MEMO_HOME/Agents.md`). +### Skills + +- `read_skill` + +Loads the full `SKILL.md` instructions of a skill on demand (by name, or by `SKILL.md` +path when names are ambiguous). See [Skills System](./skills.md). + ### Subagent Collaboration - `spawn_agent` diff --git a/site/content/docs/zh/README.mdx b/site/content/docs/zh/README.mdx index 35d9469..3bb6000 100644 --- a/site/content/docs/zh/README.mdx +++ b/site/content/docs/zh/README.mdx @@ -10,14 +10,13 @@ 4. [内置工具](./tools.md) 5. [安全与审批](./approval-safety.md) 6. [多代理(子代理)](./subagent.md) -7. [Web 控制台](./web-console.md) -8. [MCP 集成](./mcp.md) -9. [Skills 系统](./skills.md) -10. [记忆系统](./memory.md) -11. [任务计划](./plan.md) -12. [Agent 人格与项目指南](./soul.md) -13. [会话与历史](./sessions-history.md) -14. [故障排除](./troubleshooting.md) +7. [MCP 集成](./mcp.md) +8. [Skills 系统](./skills.md) +9. [记忆系统](./memory.md) +10. [任务计划](./plan.md) +11. [项目指南(AGENTS.md)](./project-guide.md) +12. [会话与历史](./sessions-history.md) +13. [故障排除](./troubleshooting.md) ## 文档索引 @@ -39,14 +38,13 @@ - [MCP 集成](./mcp.md):如何添加/管理 MCP 服务器。 - [Skills 系统](./skills.md):发现、管理和使用 Skills 扩展领域能力。 -### 个性化与上下文 +### 上下文与个性化 -- [Agent 人格与项目指南](./soul.md):使用 SOUL.md 和 AGENTS.md 配置行为。 +- [项目指南(AGENTS.md)](./project-guide.md):使用 AGENTS.md 配置项目约定。 - [记忆系统](./memory.md):持久化用户偏好和项目上下文。 ### 操作与运维 -- [Web 控制台](./web-console.md):浏览器 UI、登录配置与侧边栏管理能力。 - [会话与历史](./sessions-history.md):JSONL 日志、恢复行为、清理。 - [故障排除](./troubleshooting.md):常见问题和直接修复。 diff --git a/site/content/docs/zh/cli-tui.mdx b/site/content/docs/zh/cli-tui.mdx index 1e5fa1a..198ea5e 100644 --- a/site/content/docs/zh/cli-tui.mdx +++ b/site/content/docs/zh/cli-tui.mdx @@ -12,7 +12,7 @@ Memo 有两种运行模式:用于日常开发的交互式 TUI,以及用于 ## 运行时架构(当前) - `packages/tui`:终端运行时包(`memo` 入口、参数解析、模式分发、纯文本模式 + 交互式 TUI 运行时)。 -- `packages/core`:两种模式使用的会话状态机和工具调用运行时。 +- `packages/core`:两种模式使用的会话状态机、工具调用运行时与 LLM 层。 ## 运行模式 @@ -51,6 +51,21 @@ echo "your prompt" | memo - `--prev` / `-prev`:预加载当前目录最近本地会话历史。 - `--dangerous` / `-d`:跳过审批检查。 +## 实时流式输出、思考与计划进度 + +TUI 时间线是实时的: + +- 模型输出与推理内容边生成边流入时间线,流式渲染 Markdown +- 进行中的任务计划(`update_plan`)会以进度面板展示各步骤状态 +- `Ctrl+T` 切换是否跟随实时输出流 + +思考模式可在会话内随时切换: + +- 在**空**输入框上按 `Tab` 切换思考模式开关 +- 思考模式开启时,输入框显示为琥珀色 + +该开关会被记住,并在恢复历史会话时一并还原(见下文)。 + ## 输入增强 ### 使用 `@` 引用文件 @@ -60,7 +75,7 @@ echo "your prompt" | memo 示例: - `Review @package.json and explain scripts` -- `Compare @packages/core/src/runtime/session.ts with @packages/tools/src/index.ts` +- `Compare @packages/core/src/agent/loop.ts with @packages/core/src/tools/index.ts` ### 会话恢复 (`resume`) @@ -72,6 +87,8 @@ echo "your prompt" | memo 然后选择一个会话建议来加载历史上下文。 +恢复会话时也会还原该会话的 UI 状态:提供商、模型、思考开关、工具权限模式、上下文窗口和输入历史。 + ## 斜杠命令 使用 `/` 打开命令建议。 @@ -81,6 +98,7 @@ echo "your prompt" | memo - `/exit`:退出 Memo - `/resume`:显示历史加载指南 - `/models`:列出/切换已配置的提供商和模型 +- `/tools`:切换工具权限模式(`none` / `once` / `full`) - `/compact`:手动压缩当前会话上下文 - `/mcp`:显示已配置的 MCP 服务器 - `/init`:让 Memo 为当前项目生成 `AGENTS.md` @@ -90,25 +108,34 @@ echo "your prompt" | memo - 输入 `exit`(不带斜杠)也会退出。 - `/models ` 也可以工作(用于直接切换)。 -## Web 模式命令 +## CLI 子命令 + +Memo 还提供独立的子命令: ```bash -memo web --host 127.0.0.1 --port 5494 --open +memo init # 为当前项目生成 AGENTS.md(幂等) +memo mcp list # 列出已配置的 MCP 服务器 +memo mcp add -- +memo mcp login # 远程 MCP 服务器的 OAuth 登录 +memo skills list # 列出发现的 skills +memo skills read # 读取某个 skill 的完整 SKILL.md ``` -该命令会启动本地 Web 服务并打开浏览器 UI。 +详见 [MCP 集成](./mcp.md) 与 [Skills 系统](./skills.md)。 ## 快捷键 - `Enter`:发送消息 - `Shift+Enter`:插入换行 -- `Tab`:接受当前建议 +- `Tab`:接受当前建议;在空输入框上切换思考模式 - `Up/Down`:在建议列表中移动,或浏览输入历史 - `Esc`:关闭建议面板 - `Esc Esc`: - 运行时:取消当前轮次 - 空闲时:清除当前输入 +- `Ctrl+T`:切换跟随实时输出 - `Ctrl+L`:清屏并开始新会话 +- `Ctrl+D`:退出(在空输入框上连按两次);否则删除光标处字符 - `Ctrl+C`:退出 ## TUI 中的审批 diff --git a/site/content/docs/zh/configuration.mdx b/site/content/docs/zh/configuration.mdx index f2ceb7c..3a2beb8 100644 --- a/site/content/docs/zh/configuration.mdx +++ b/site/content/docs/zh/configuration.mdx @@ -20,11 +20,6 @@ Memo 使用 `config.toml` 来管理提供商选择、模型配置覆盖和 MCP - 配置路径变为 `$MEMO_HOME/config.toml` -Web 认证配置: - -- 默认路径为 `~/.memo/server.yaml` -- 可通过 `MEMO_SERVER_CONFIG` 指定自定义路径 - ## 配置如何应用 Memo 运行时使用以下顺序的配置: @@ -147,8 +142,33 @@ context_window = 128000 - 其他使用 `gpt-5` 的提供商 -> `272000` - 任何没有 `context_window` 覆盖的模型 -> `1000000` +## 上下文压缩 + +当上下文使用量超过阈值时,长会话会被自动压缩: + +```toml +auto_compact_threshold_percent = 90 # 默认:90(1-100) +``` + +- 有效范围为 `1` 到 `100`;非法值回退为 `90` +- 压缩会总结早期上下文并继续会话,保持会话可用 +- `/compact` 可随时手动压缩 + ## MCP 服务器配置 +### OAuth 设置(远程服务器) + +```toml +# OAuth 浏览器回调使用的本地端口(可选) +mcp_oauth_callback_port = 38433 + +# 凭据存储方式:"auto"(默认)/ "keyring" / "file" +mcp_oauth_credentials_store_mode = "auto" +``` + +- `mcp_oauth_callback_port`:`memo mcp login` 使用的固定回调端口;未设置时自动选择空闲端口。 +- `mcp_oauth_credentials_store_mode`:OAuth 令牌的持久化位置。 + ### 本地 Stdio MCP ```toml diff --git a/site/content/docs/zh/getting-started.mdx b/site/content/docs/zh/getting-started.mdx index 82bf430..7e5b7ff 100644 --- a/site/content/docs/zh/getting-started.mdx +++ b/site/content/docs/zh/getting-started.mdx @@ -11,7 +11,7 @@ category: 'Getting Started' ## 前置条件 -- Node.js `>= 20` +- Node.js `>= 22` - 至少一个兼容的 API 密钥(例如 `DEEPSEEK_API_KEY` 或 `OPENAI_API_KEY`) ## 安装 @@ -84,18 +84,17 @@ memo -d 仅在受信任的仓库和受控环境中使用此模式。 -## Web 控制台 +## 其他入口 -启动本地 Web 服务与 UI: +除了交互式会话,Memo 还提供独立的子命令: ```bash -memo web --host 127.0.0.1 --port 5494 --open +memo init # 为当前项目生成 AGENTS.md +memo mcp list # 列出已配置的 MCP 服务器 +memo skills list # 列出发现的 skills ``` -- npm 分发包已包含 web 静态资源。 -- 如果从源码仓库运行,请先执行 `pnpm run build`。 -- 登录凭据来自 `~/.memo/server.yaml`(`auth.username` / `auth.password`)。 -- UI 提供聊天时间线、项目/会话管理、MCP 管理和 Skills 管理。 +完整说明见 [MCP 集成](./mcp.md) 与 [Skills 系统](./skills.md)。 ## 下一步 diff --git a/site/content/docs/zh/mcp.mdx b/site/content/docs/zh/mcp.mdx index d4b1c09..0f50284 100644 --- a/site/content/docs/zh/mcp.mdx +++ b/site/content/docs/zh/mcp.mdx @@ -97,14 +97,6 @@ bearer_token_env_var = "MCP_TOKEN" 它显示为当前会话配置的 MCP 服务器。 -## 在 Web UI 中管理 MCP - -在 `memo web` 模式下,可从侧边栏进入 `MCP Servers` 页面进行: - -- 创建 / 编辑 / 删除 server -- 对支持 OAuth 的远程 server 登录 / 登出 -- 切换默认激活状态 - ## 配置更改何时生效 MCP 服务器在创建会话时加载。 diff --git a/site/content/docs/zh/plan.mdx b/site/content/docs/zh/plan.mdx index b6fb70d..c0a86e4 100644 --- a/site/content/docs/zh/plan.mdx +++ b/site/content/docs/zh/plan.mdx @@ -18,6 +18,8 @@ Memo 的任务计划系统帮助你管理复杂任务的执行进度,通过结 - 维护当前任务的上下文和解释 - 确保一次只有一个步骤处于进行中状态 +在 TUI 中,进行中的计划会以进度面板展示各步骤状态,让你在 Agent 工作时一目了然。 + ## 工具详情 **工具名称**:`update_plan` diff --git a/site/content/docs/zh/project-guide.mdx b/site/content/docs/zh/project-guide.mdx index 880494b..ed1b691 100644 --- a/site/content/docs/zh/project-guide.mdx +++ b/site/content/docs/zh/project-guide.mdx @@ -1,109 +1,28 @@ --- -title: 'Agent 人格与项目指南' -description: '使用 SOUL.md 和 AGENTS.md 配置 Agent 行为和项目指南。' +title: '项目指南(AGENTS.md)' +description: '使用 AGENTS.md 配置项目约定与工作流。' order: 12 category: 'Core Features' --- -# Agent 人格与项目指南 +# 项目指南(AGENTS.md) -Memo 支持通过 SOUL.md 和 AGENTS.md 两个文件来自定义 Agent 的行为风格和项目特定的指南。它们分别用于不同的目的和范围。 +AGENTS.md 是一个纯 Markdown 文件,承载项目级指令:结构、约定与工作流。Memo 会自动读取它,确保在该仓库中工作的 Agent 遵循同一套规则。 -## 文件对比 +## AGENTS.md 存放位置 -| 文件 | 位置 | 作用范围 | 用途 | -| ------------- | ----------------- | -------- | -------------------------------- | -| **SOUL.md** | `~/.memo/SOUL.md` | 全局 | 用户人格、沟通风格、个人偏好 | -| **AGENTS.md** | 项目根目录 | 项目级 | 项目结构、技术栈、工作流程、约定 | +AGENTS.md 可以存在于仓库中的任何位置: -## SOUL.md - 用户人格配置 +- 项目根目录(例如 `/AGENTS.md`) +- 任意子目录——每个 AGENTS.md 的作用范围是包含它的整个目录树 -SOUL.md 用于定义你的个人偏好、沟通风格和主观行为倾向。这是一个全局配置,影响所有项目中的 Memo 行为。 +Memo 启动时自动检测并加载 AGENTS.md: -### 位置 +- 对于你接触的每个文件,Memo 都会遵守作用范围内所有 AGENTS.md 中的指令。 +- 嵌套更深的 AGENTS.md 在冲突时优先。 +- 仓库根目录的 AGENTS.md 已包含在提示中——无需重新读取。 -``` -~/.memo/SOUL.md -``` - -或当设置了 `MEMO_HOME` 时: - -``` -$MEMO_HOME/SOUL.md -``` - -### 用途 - -SOUL.md 适合配置: - -- **沟通风格**:简洁/详细、正式/随意 -- **代码偏好**:函数式/面向对象、特定模式 -- **工作习惯**:喜欢的工具、常用命令别名 -- **学习偏好**:详细解释 vs 快速答案 - -### 示例 SOUL.md - -```markdown -# User Preferences - -## Communication Style - -- Prefer concise, direct responses -- Use technical terminology freely -- Skip greetings and sign-offs -- One-word answers when sufficient - -## Code Preferences - -- Prefer functional programming patterns -- Favor immutable data structures -- Use TypeScript strict mode always -- Prefer explicit types over inference - -## Workflow - -- Favorite editor: VS Code -- Preferred package manager: pnpm -- Like to see multiple solution options -- Prefer early returns over nested conditionals - -## Learning Style - -- Show code examples over explanations -- Ask before refactoring unrelated code -- Point out potential pitfalls proactively -``` - -### 优先级规则 - -SOUL.md 的内容被视为**软偏好层**: - -- ✅ 影响语气、风格和主观行为 -- ❌ 不覆盖安全规则、工具策略 -- ❌ 不覆盖 AGENTS.md 的指导 -- ❌ 不覆盖当前回合的明确用户指令 - -### 性能建议 - -保持 SOUL.md 简洁: - -- 内容会被注入到每个会话的系统提示中 -- 过长的内容会增加 token 消耗 -- 专注于真正影响行为的偏好 - -## AGENTS.md - 项目指南 - -AGENTS.md 用于定义项目特定的结构、约定和工作流程。它位于项目根目录,为该项目的 Agent 提供上下文。 - -### 位置 - -``` -/AGENTS.md -``` - -Memo 会在启动时自动检测并加载项目根目录的 AGENTS.md。 - -### 用途 +## 应该放什么内容 AGENTS.md 适合记录: @@ -111,226 +30,55 @@ AGENTS.md 适合记录: - **技术栈**:主要框架、库、工具版本 - **开发工作流**:构建、测试、部署命令 - **代码约定**:命名规范、文件组织、风格指南 -- **安全注意事项**:敏感文件、环境变量处理 -- **已知问题**:常见陷阱、解决方案 - -### 示例 AGENTS.md - -```markdown -# Project Guide +- **安全注意**:敏感文件、环境变量处理 +- **已知问题**:常见陷阱及解决方案 -## Tech Stack - -- **Framework**: Next.js 14 (App Router) -- **Language**: TypeScript 5.x -- **Styling**: Tailwind CSS + CSS Modules -- **State**: Zustand -- **Testing**: Vitest + React Testing Library -- **Database**: PostgreSQL + Prisma - -## Project Structure -``` +保持简洁,只记录项目独有信息——避免重复 `package.json` 或 README 中已有的内容。 -myapp/ -├── app/ # Next.js App Router -│ ├── (marketing)/ # Route groups -│ ├── api/ # API routes -│ └── layout.tsx # Root layout -├── components/ # React components -│ ├── ui/ # Base UI components -│ └── features/ # Feature components -├── lib/ # Utilities and helpers -├── prisma/ # Database schema and migrations -├── public/ # Static assets -└── tests/ # Test files +## 自动生成 -```` - -## Development Commands +在会话中使用 `/init` 斜杠命令,或运行 CLI 命令: ```bash -# Install dependencies -pnpm install - -# Development server -pnpm dev - -# Run tests -pnpm test -pnpm test:watch - -# Database operations -pnpm db:migrate -pnpm db:seed -pnpm db:studio - -# Build -pnpm build - -# Lint and type check -pnpm lint -pnpm typecheck -```` - -## Code Conventions - -- Use named exports instead of default exports -- Place tests next to source files: `Component.tsx` + `Component.test.tsx` -- Use kebab-case for file names: `my-component.tsx` -- Prefer server components by default -- Use Zod for form validation - -## Environment Variables - -Required variables in `.env.local`: - -- `DATABASE_URL`: PostgreSQL connection string -- `NEXTAUTH_SECRET`: Random secret for auth -- `NEXTAUTH_URL`: App URL for callbacks - -## Important Notes - -- Never commit `.env.local` -- Run `pnpm typecheck` before committing -- Database migrations are in `/prisma/migrations` -- API documentation is in `/docs/api.md` - -``` - -### 自动生成 - -你可以使用 `/init` 斜杠命令让 Memo 为当前项目生成 AGENTS.md: - +memo init ``` -/init +Memo 会分析当前项目并生成一份包含技术栈、命令和约定的 `AGENTS.md`。 -```` +`memo init` 与 `git init` 一样是幂等的: -Memo 会分析项目结构并生成一个包含技术栈、命令和约定的模板文件。 +- 如果 `AGENTS.md` 已存在,绝不改动——Memo 会提示并跳过 +- 如需重新生成,先删除该文件 -### 维护责任 +## 维护责任 -**重要**:如果你修改了 AGENTS.md 中提到的任何内容,记得同时更新 AGENTS.md: +**重要**:如果你修改了 AGENTS.md 中提到的任何内容,请同步更新: - 添加/删除重要目录 - 更改构建/测试命令 - 更新技术栈 - 修改代码约定 -## 两个文件的协同工作 +## 上下文加载顺序 -SOUL.md 和 AGENTS.md 在系统提示中的加载顺序: +Memo 按以下顺序构建系统提示: 1. 基础系统提示 -2. **SOUL.md**(用户人格层) -3. **AGENTS.md**(项目指南层) -4. Skills(如果有) +2. **AGENTS.md**(项目指南层) +3. Skills(如果有) 这意味着: -- AGENTS.md 可以覆盖 SOUL.md 中与项目冲突的偏好 -- 当前回合的用户指令优先级最高 -- 两者都不能覆盖安全规则和工具策略 - -## 实际示例 - -### 场景 1:代码审查风格 - -**SOUL.md**: -```markdown -## Code Review Style - -- Be direct and critical -- Focus on performance issues -- Suggest concrete improvements -- No need to praise working code -```` - -**AGENTS.md**: - -```markdown -## Code Review Checklist - -- [ ] Type safety: no `any` types -- [ ] Error handling: all async calls wrapped -- [ ] Tests: new features have tests -- [ ] Accessibility: ARIA labels for interactive elements -``` - -### 场景 2:技术偏好 - -**SOUL.md**: - -```markdown -## Technology Preferences - -- Prefer functional React components -- Like using hooks for state management -- Prefer explicit TypeScript types -``` - -**AGENTS.md**: - -```markdown -## Technology Stack - -- React 18 with hooks -- Zustand for state (not Redux) -- TypeScript with strict mode -- React Query for server state -``` - -## 故障排除 - -### SOUL.md 未生效 - -1. 确认文件位于 `~/.memo/SOUL.md` -2. 检查文件是否有内容(非空) -3. 重启 Memo 或创建新会话 -4. 检查 `MEMO_HOME` 是否指向正确位置 - -### AGENTS.md 未加载 - -1. 确认文件位于项目根目录 -2. 文件名必须是 `AGENTS.md`(大写) -3. 从项目目录启动 Memo -4. 检查文件内容非空 - -### 文件内容过长 - -- **SOUL.md**:只保留影响行为的偏好,删除描述性内容 -- **AGENTS.md**:将详细信息移到项目文档,AGENTS.md 只保留关键约定和链接 - -### 配置冲突 - -如果 SOUL.md 和 AGENTS.md 有冲突: - -- AGENTS.md 中的项目特定设置优先 -- 当前回合的明确指令优先级最高 -- 如果仍有问题,检查系统提示中两者的加载顺序 - -## 最佳实践 - -### SOUL.md - -- ✅ 保持简洁(建议 < 500 字符) -- ✅ 专注于真正影响交互的偏好 -- ✅ 定期更新以反映变化 -- ❌ 不要包含项目特定信息 -- ❌ 不要重复通用编程原则 +- AGENTS.md 的指令适用于其作用范围内的所有工作 +- 当前回合中用户的明确指令拥有最高优先级 +- AGENTS.md 和 skills 都不能覆盖安全规则与工具策略 -### AGENTS.md +## 持久化偏好 -- ✅ 包含项目独有的信息 -- ✅ 列出常用的命令和路径 -- ✅ 记录重要的技术决策 -- ✅ 添加新成员需要知道的上下文 -- ❌ 不要重复 package.json 中已有的信息 -- ❌ 不要包含临时性或频繁变化的内容 +跨项目持久化的用户级偏好,请参阅 [记忆系统](./memory.md)(`get_memory` 读取 `~/.memo/Agents.md`)。 ## 相关 -- [记忆系统](./memory.md):持久化用户偏好(Agents.md) -- [配置](./configuration.md):config.toml 详解 -- [Skills 系统](./skills.md):领域特定专业知识 +- [记忆系统](./memory.md):持久化用户偏好 +- [配置](./configuration.md):config.toml 详情 +- [Skills 系统](./skills.md):领域专业能力 diff --git a/site/content/docs/zh/sessions-history.mdx b/site/content/docs/zh/sessions-history.mdx index 76a0533..dc7477b 100644 --- a/site/content/docs/zh/sessions-history.mdx +++ b/site/content/docs/zh/sessions-history.mdx @@ -54,6 +54,14 @@ Memo 将会话事件写入本地 JSONL 文件,用于恢复、调试和审计 - 选择一个将历史对话上下文加载到当前会话 - 当前活动会话文件从恢复建议中排除 +恢复时还会还原随日志持久化的会话 UI 状态: + +- 提供商与模型 +- 思考开关 +- 工具权限模式(`none` / `once` / `full`) +- 上下文窗口 +- 输入历史 + ## 实用技巧 - 在请他人调查行为时分享相关的 `.jsonl` 路径。 diff --git a/site/content/docs/zh/skills.mdx b/site/content/docs/zh/skills.mdx index 8ff80c3..4db2748 100644 --- a/site/content/docs/zh/skills.mdx +++ b/site/content/docs/zh/skills.mdx @@ -53,6 +53,23 @@ Memo 在启动时自动扫描以下位置来发现 Skills: ### 用户级 Skills - `$MEMO_HOME/skills`(默认为 `~/.memo/skills`) +- `~/.claude/skills` +- `~/.codex/skills` +- `~/.agents/skills` + +### 去重 + +Skills 会按其 `SKILL.md` 内容的 sha256 哈希去重。当同一个 skill 存在于多个位置时 +(例如同时安装在 `~/.claude/skills` 和 `~/.agents/skills`),只保留优先级最高的副本, +顺序如下: + +1. 项目级根目录 +2. `$MEMO_HOME/skills` +3. `~/.claude/skills` +4. `~/.codex/skills` +5. `~/.agents/skills` + +其余副本仍可作为别名解析——见 [配置默认激活 Skills](#配置默认激活-skills)。 ### 内置 Skills @@ -72,7 +89,6 @@ Memo 内置 `skill-creator` skill。每次会话启动时,如果 `$MEMO_HOME/s ### 排除的目录 - `.git/` 和 `node_modules/` -- Memo 主目录外的 `~/.xxx/skills` 隐藏目录 ## 创建 Skill @@ -163,6 +179,18 @@ A skill is a set of local instructions to follow that is stored in a SKILL.md fi - doc-writing: Generate and update technical documentation... ``` +技能目录受上下文预算限制;超出时描述会被截断,多余的 skills 会被省略(并附说明)。 +需要时可使用 `read_skill` 工具按需加载某个 skill 的完整 `SKILL.md`。 + +### 使用 read_skill 工具读取 Skill + +当你决定使用某个 skill 时,`read_skill` 工具会加载它的完整指令: + +- 传入技能目录中的 skill `name`,或名称有歧义时传入精确的 `SKILL.md` `path` +- 会自动去除 frontmatter +- 返回结果包含 `skill_directory`,skill 中的相对路径(如 `scripts/foo.py`、`references/`)都基于它解析 +- 特别长的 skill 可能会被工具输出限制截断 + ## 配置默认激活 Skills 在 `config.toml` 中设置默认激活的 Skills: @@ -182,24 +210,27 @@ active_skills = [ ``` 注意:`active_skills` 存储的是 SKILL.md 文件的**绝对路径**。 +指向去重副本(例如当 `~/.claude/skills` 去重胜出时指向 +`~/.agents/skills/react-hooks/SKILL.md`)的路径仍会激活该 skill。 -## 在 Web UI 中管理 Skills +## 使用 CLI 管理 Skills -启动 Web 控制台: +列出所有发现的 Skills(跨项目级和用户级根目录,已去重): ```bash -memo web --open +memo skills list +memo skills list --json ``` -在侧边栏的 **Skills** 页面可以: +读取某个 Skill 的完整指令: -- 查看所有已发现的 Skills -- 创建新的 Skill(自动生成目录结构和模板) -- 删除 Skill -- 切换激活状态 -- 查看 Skill 详情 +```bash +memo skills read react-hooks +memo skills read /absolute/path/to/.codex/skills/react-hooks/SKILL.md +memo skills read react-hooks --json +``` -Skills 的激活状态变更会自动保存到 `config.toml` 中。 +当名称匹配多个 skills 时,`read` 会列出候选路径;传入精确路径即可区分。 ## Skills 使用规则 diff --git a/site/content/docs/zh/tools.mdx b/site/content/docs/zh/tools.mdx index 52b8231..6c76def 100644 --- a/site/content/docs/zh/tools.mdx +++ b/site/content/docs/zh/tools.mdx @@ -72,6 +72,13 @@ Memo 包含一套 codex 风格的工具集,并可通过 MCP 服务器扩展能 `get_memory` 从 `~/.memo/Agents.md`(或 `$MEMO_HOME/Agents.md`)读取。 +### Skills + +- `read_skill` + +按需加载某个 skill 的完整 `SKILL.md` 指令(按名称,或名称有歧义时按 `SKILL.md` +路径加载)。见 [Skills 系统](./skills.md)。 + ### 子代理协作 - `spawn_agent` diff --git a/site/lib/i18n/messages/en.json b/site/lib/i18n/messages/en.json index 2e354d6..ff03fe8 100644 --- a/site/lib/i18n/messages/en.json +++ b/site/lib/i18n/messages/en.json @@ -26,9 +26,9 @@ "title": "Terminal-native", "description": "Works where you already ship: directly inside your terminal workflow." }, - "webConsole": { - "title": "Web console", - "description": "Manage multi-workspace projects and concurrent live sessions from the browser." + "liveReasoning": { + "title": "Live reasoning", + "description": "Thinking steps, plan progress, and tool output stream into the timeline as they happen." }, "builtInTools": { "title": "Rich built-in tools", @@ -44,7 +44,7 @@ }, "skills": { "title": "Skills", - "description": "Use markdown-based skills with activation control in both CLI and Web." + "description": "Markdown-based skills with auto-discovery, dedup, and on-demand activation." } } }, diff --git a/site/lib/i18n/messages/zh.json b/site/lib/i18n/messages/zh.json index f8fca96..bf21d09 100644 --- a/site/lib/i18n/messages/zh.json +++ b/site/lib/i18n/messages/zh.json @@ -26,9 +26,9 @@ "title": "终端原生", "description": "在你常用的终端环境中工作,直接集成到你的终端工作流中。" }, - "webConsole": { - "title": "Web 控制台", - "description": "在浏览器中管理多 workspace 项目与并发实时会话。" + "liveReasoning": { + "title": "实时推理", + "description": "思考过程、计划进度与工具输出实时流入会话时间线。" }, "builtInTools": { "title": "内置丰富工具", @@ -44,7 +44,7 @@ }, "skills": { "title": "Skills", - "description": "使用基于 Markdown 的 skills,并在 CLI 与 Web 中控制激活状态。" + "description": "基于 Markdown 的技能,自动发现、去重并按需激活。" } } }, From 2377347de9d36729819d00229a4bb15199211064 Mon Sep 17 00:00:00 2001 From: mcell Date: Sun, 2 Aug 2026 15:12:41 +0800 Subject: [PATCH 06/13] refactor(tui): drop runtime context window restore Context window is decided by config/defaults only: remove the session history round-trip (record in session_start, parse back, restore into the session options) that let a restored session override the configured window. Provider, model, thinking, and tool permission mode restore stay. --- packages/core/src/agent/session_hooks.test.ts | 1 - packages/core/src/api_types.ts | 2 -- packages/core/src/features/history/history_parser.test.ts | 5 +---- packages/core/src/features/history/history_parser.ts | 7 ------- packages/tui/src/app/App.tsx | 2 -- packages/tui/src/features/session/historyParser.test.ts | 2 -- packages/tui/src/features/session/historyParser.ts | 2 -- 7 files changed, 1 insertion(+), 20 deletions(-) diff --git a/packages/core/src/agent/session_hooks.test.ts b/packages/core/src/agent/session_hooks.test.ts index 00a9060..dc5442b 100644 --- a/packages/core/src/agent/session_hooks.test.ts +++ b/packages/core/src/agent/session_hooks.test.ts @@ -503,7 +503,6 @@ describe('session hooks & middleware', () => { assert.strictEqual(sessionStart?.meta?.cwd, process.cwd()) assert.strictEqual(sessionStart?.meta?.providerName, 'mock') assert.strictEqual(sessionStart?.meta?.modelName, 'mock-model') - assert.strictEqual(sessionStart?.meta?.contextWindow, 64_000) assert.strictEqual(sessionStart?.meta?.toolPermissionMode, 'once') assert.strictEqual(sessionStart?.meta?.thinking, true) assert.strictEqual(events.find((event) => event.type === 'turn_start')?.meta?.thinking, true) diff --git a/packages/core/src/api_types.ts b/packages/core/src/api_types.ts index aeb47a5..f83ec6d 100644 --- a/packages/core/src/api_types.ts +++ b/packages/core/src/api_types.ts @@ -87,8 +87,6 @@ export type SessionDetail = SessionListItem & { providerName?: string /** Model name recorded at session start, if any. */ modelName?: string - /** Context window (tokens) recorded at session start. */ - contextWindow?: number /** Tool permission mode recorded at session start. */ toolPermissionMode?: string /** Most recently recorded thinking override (undefined follows the model profile). */ diff --git a/packages/core/src/features/history/history_parser.test.ts b/packages/core/src/features/history/history_parser.test.ts index 528e01c..645c0b7 100644 --- a/packages/core/src/features/history/history_parser.test.ts +++ b/packages/core/src/features/history/history_parser.test.ts @@ -12,7 +12,6 @@ function buildSampleLog(): string { cwd: '/tmp/demo', providerName: 'deepseek', modelName: 'deepseek-chat', - contextWindow: 64000, toolPermissionMode: 'once', thinking: true, }, @@ -81,7 +80,6 @@ describe('parseHistoryLogToSessionDetail', () => { const detail = parseHistoryLogToSessionDetail(buildSampleLog(), '/tmp/demo/s1.jsonl') assert.strictEqual(detail.providerName, 'deepseek') assert.strictEqual(detail.modelName, 'deepseek-chat') - assert.strictEqual(detail.contextWindow, 64000) assert.strictEqual(detail.toolPermissionMode, 'once') assert.strictEqual(detail.thinking, true) }) @@ -108,12 +106,11 @@ describe('parseHistoryLogToSessionDetail', () => { ts: '2026-02-15T10:00:00.000Z', sessionId: 'legacy', type: 'session_start', - meta: { cwd: '/tmp/demo', contextWindow: -1, thinking: 'yes' }, + meta: { cwd: '/tmp/demo', thinking: 'yes' }, }) const detail = parseHistoryLogToSessionDetail(log, '/tmp/demo/legacy.jsonl') assert.strictEqual(detail.providerName, undefined) - assert.strictEqual(detail.contextWindow, undefined) assert.strictEqual(detail.thinking, undefined) }) diff --git a/packages/core/src/features/history/history_parser.ts b/packages/core/src/features/history/history_parser.ts index 287bed9..370bb7d 100644 --- a/packages/core/src/features/history/history_parser.ts +++ b/packages/core/src/features/history/history_parser.ts @@ -22,7 +22,6 @@ type ParseResultState = { cwd: string providerName?: string modelName?: string - contextWindow?: number toolPermissionMode?: string thinking?: boolean startedAt: string @@ -279,7 +278,6 @@ export function parseHistoryLogToSessionDetail(raw: string, filePath: string): S cwd: '', providerName: undefined, modelName: undefined, - contextWindow: undefined, toolPermissionMode: undefined, thinking: undefined, startedAt: events[0]?.ts ?? fallbackNow, @@ -306,10 +304,6 @@ export function parseHistoryLogToSessionDetail(raw: string, filePath: string): S if (providerName) state.providerName = providerName const modelName = safeString(event.meta.modelName) if (modelName) state.modelName = modelName - const contextWindow = event.meta.contextWindow - if (typeof contextWindow === 'number' && Number.isFinite(contextWindow) && contextWindow > 0) { - state.contextWindow = contextWindow - } const toolPermissionMode = safeString(event.meta.toolPermissionMode) if (toolPermissionMode) state.toolPermissionMode = toolPermissionMode if (typeof event.meta.thinking === 'boolean') { @@ -503,7 +497,6 @@ export function parseHistoryLogToSessionDetail(raw: string, filePath: string): S events, providerName: state.providerName, modelName: state.modelName, - contextWindow: state.contextWindow, toolPermissionMode: state.toolPermissionMode, thinking: state.thinking, compactionSummary: state.compactionSummary, diff --git a/packages/tui/src/app/App.tsx b/packages/tui/src/app/App.tsx index fc95425..e83c52f 100644 --- a/packages/tui/src/app/App.tsx +++ b/packages/tui/src/app/App.tsx @@ -246,14 +246,12 @@ export function App({ thinkingOnRef.current = parsed.thinking setThinkingOn(parsed.thinking) } - if (parsed.contextWindow) setContextLimit(parsed.contextWindow) if (restoredToolPermissionMode) setToolPermissionMode(restoredToolPermissionMode) setInputHistory(parsed.turns.map((turn) => turn.userInput.trim()).filter(Boolean)) setSessionOptionsState((prev) => ({ ...prev, providerName: parsed.providerName ?? prev.providerName, modelName: parsed.modelName ?? prev.modelName, - contextWindow: parsed.contextWindow ?? prev.contextWindow, toolPermissionMode: restoredToolPermissionMode ?? prev.toolPermissionMode, dangerous: restoredToolPermissionMode === undefined diff --git a/packages/tui/src/features/session/historyParser.test.ts b/packages/tui/src/features/session/historyParser.test.ts index 6799cb2..d4aa82d 100644 --- a/packages/tui/src/features/session/historyParser.test.ts +++ b/packages/tui/src/features/session/historyParser.test.ts @@ -21,7 +21,6 @@ describe('parseHistoryLog', () => { cwd: '/tmp/demo', providerName: 'deepseek', modelName: 'deepseek-chat', - contextWindow: 64000, toolPermissionMode: 'once', thinking: true, }, @@ -56,7 +55,6 @@ describe('parseHistoryLog', () => { const parsed = parseHistoryLog(raw) assert.strictEqual(parsed.providerName, 'deepseek') assert.strictEqual(parsed.modelName, 'deepseek-chat') - assert.strictEqual(parsed.contextWindow, 64000) assert.strictEqual(parsed.toolPermissionMode, 'once') assert.strictEqual(parsed.thinking, true) assert.strictEqual(parsed.messages.length, 2) diff --git a/packages/tui/src/features/session/historyParser.ts b/packages/tui/src/features/session/historyParser.ts index 8de240b..6fd42d7 100644 --- a/packages/tui/src/features/session/historyParser.ts +++ b/packages/tui/src/features/session/historyParser.ts @@ -14,7 +14,6 @@ export type ParsedHistoryLog = { maxSequence: number providerName?: string modelName?: string - contextWindow?: number toolPermissionMode?: string thinking?: boolean compactionSummary?: string @@ -107,7 +106,6 @@ export function parseHistoryLog(raw: string): ParsedHistoryLog { maxSequence: sequence, providerName: detail.providerName, modelName: detail.modelName, - contextWindow: detail.contextWindow, toolPermissionMode: detail.toolPermissionMode, thinking: detail.thinking, compactionSummary: detail.compactionSummary, From 55456ce73da943c77f04179718928ce3ae086a76 Mon Sep 17 00:00:00 2001 From: mcell Date: Sun, 2 Aug 2026 15:33:02 +0800 Subject: [PATCH 07/13] feat: add in-process multi-agent runtime --- packages/core/src/agent/communication.ts | 6 +- packages/core/src/agent/control.ts | 288 ++++++++ packages/core/src/agent/loop.ts | 86 ++- packages/core/src/agent/runtime.ts | 87 +++ packages/core/src/agent/session.ts | 26 + packages/core/src/agent/status.test.ts | 4 +- packages/core/src/agent/subagent.ts | 33 + packages/core/src/prompt/prompt.md | 7 +- packages/core/src/tools/approval/constants.ts | 18 +- packages/core/src/tools/approval/types.ts | 3 + packages/core/src/tools/index.test.ts | 18 +- packages/core/src/tools/index.ts | 18 +- packages/core/src/tools/sdk_tools.ts | 3 + packages/core/src/tools/tools/collab.test.ts | 353 +++++----- packages/core/src/tools/tools/collab.ts | 617 ++++-------------- packages/core/src/types.ts | 21 +- packages/tui/src/app/App.tsx | 21 +- packages/tui/src/app/approvalQueue.test.ts | 19 + packages/tui/src/app/approvalQueue.ts | 32 +- packages/tui/src/app/runtimeState.test.ts | 9 +- packages/tui/src/app/runtimeState.ts | 30 +- .../src/features/approval/ApprovalOverlay.tsx | 3 + .../features/session/historyParser.test.ts | 21 + .../tui/src/features/session/historyParser.ts | 40 +- packages/tui/src/features/timeline/Cells.tsx | 39 ++ .../tui/src/features/timeline/ChatWidget.tsx | 15 +- .../features/timeline/chatTimeline.test.ts | 17 + .../tui/src/features/timeline/chatTimeline.ts | 17 +- packages/tui/src/shared/types/index.ts | 13 +- packages/tui/src/shared/ui/Footer.tsx | 5 +- 30 files changed, 1146 insertions(+), 723 deletions(-) create mode 100644 packages/core/src/agent/control.ts create mode 100644 packages/core/src/agent/runtime.ts create mode 100644 packages/core/src/agent/subagent.ts diff --git a/packages/core/src/agent/communication.ts b/packages/core/src/agent/communication.ts index 9169e86..b544144 100644 --- a/packages/core/src/agent/communication.ts +++ b/packages/core/src/agent/communication.ts @@ -5,7 +5,7 @@ export type InterAgentCommunication = { triggerTurn: boolean } -export type InputQueueActivity = 'mailbox' | 'closed' | 'aborted' +export type InputQueueActivity = 'mailbox' | 'timeout' | 'closed' | 'aborted' type ActivityWaiter = (activity: InputQueueActivity) => void @@ -56,7 +56,7 @@ export class InputQueue { resolve(activity) } const onAbort = () => finish('aborted') - const timer = setTimeout(() => finish('closed'), timeoutMs) + const timer = setTimeout(() => finish('timeout'), timeoutMs) this.waiters.add(finish) signal?.addEventListener('abort', onAbort, { once: true }) @@ -74,7 +74,7 @@ export class InputQueue { } private publish(activity: InputQueueActivity): void { - for (const waiter of [...this.waiters]) waiter(activity) + for (const waiter of this.waiters) waiter(activity) } } diff --git a/packages/core/src/agent/control.ts b/packages/core/src/agent/control.ts new file mode 100644 index 0000000..b661034 --- /dev/null +++ b/packages/core/src/agent/control.ts @@ -0,0 +1,288 @@ +import { randomUUID } from 'node:crypto' +import type { AgentActivity, AgentSession, HistoryEvent, HistorySink } from '@memo/core/types' +import { InputQueue, type InterAgentCommunication } from './communication' +import { buildForkHistory, buildSubagentSystemPrompt, parseForkTurns } from './fork' +import { AgentRegistry, ExecutionLimiter, ROOT_AGENT_PATH, agentPathDepth, type AgentMetadata } from './registry' +import { AgentRuntime } from './runtime' +import { deriveAgentStatusFromEvent, isFinalAgentStatus } from './status' + +export type CollabSessionBinding = { + agentId: string + agentPath: string + inputQueue: InputQueue + control: AgentControl +} + +export type CreateSubagentParams = { + sessionId: string + agentPath: string + initialHistory: AgentSession['history'] + systemPrompt: string + binding: CollabSessionBinding + statusSink: HistorySink +} + +export type AgentControlOptions = { + rootSessionId: string + rootSystemPrompt: string + rootHistorySinks: HistorySink[] + createSubagent: (params: CreateSubagentParams) => Promise + onAgentActivity?: (activity: AgentActivity) => void + maxRunningTurns?: number + maxDepth?: number +} + +function parsePositiveInteger(value: string | undefined, fallback: number): number { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + +export class AgentControl { + private readonly registry = new AgentRegistry() + private readonly bindings = new Map() + private readonly sessions = new Map() + private readonly runtimes = new Map() + private readonly limiter: ExecutionLimiter + private readonly maxDepth: number + private shuttingDown = false + + constructor(private readonly options: AgentControlOptions) { + this.maxDepth = options.maxDepth ?? parsePositiveInteger(process.env.MEMO_SUBAGENT_MAX_DEPTH, 3) + const maxRunning = options.maxRunningTurns ?? parsePositiveInteger(process.env.MEMO_SUBAGENT_MAX_AGENTS, 4) + this.limiter = new ExecutionLimiter(maxRunning, () => { + queueMicrotask(() => this.wakePendingAgents()) + }) + this.registry.registerRoot(options.rootSessionId) + } + + createRootBinding(): CollabSessionBinding { + const existing = this.bindings.get(this.options.rootSessionId) + if (existing) return existing + const binding: CollabSessionBinding = { + agentId: this.options.rootSessionId, + agentPath: ROOT_AGENT_PATH, + inputQueue: new InputQueue(), + control: this, + } + this.bindings.set(binding.agentId, binding) + return binding + } + + attachRootSession(session: AgentSession): void { + this.sessions.set(this.options.rootSessionId, session) + } + + async spawnAgent( + sender: CollabSessionBinding, + params: { message: string; taskName: string; forkTurns?: string }, + ): Promise { + if (this.shuttingDown) throw new Error('agent tree is shutting down') + const parentSession = this.sessions.get(sender.agentId) + if (!parentSession) throw new Error(`parent agent is not loaded: ${sender.agentPath}`) + + const reservation = this.registry.reserve(sender.agentPath, params.taskName, this.maxDepth) + const permit = this.limiter.tryAcquire() + if (!permit) { + reservation.release() + throw new Error('subagent concurrency limit reached') + } + + const agentId = randomUUID() + const binding: CollabSessionBinding = { + agentId, + agentPath: reservation.agentPath, + inputQueue: new InputQueue(), + control: this, + } + const systemPrompt = buildSubagentSystemPrompt(this.options.rootSystemPrompt, binding.agentPath) + let session: AgentSession | undefined + try { + const initialHistory = buildForkHistory( + parentSession.history, + systemPrompt, + parseForkTurns(params.forkTurns), + ) + const statusSink: HistorySink = { + append: (event) => this.handleAgentEvent(agentId, event), + } + session = await this.options.createSubagent({ + sessionId: agentId, + agentPath: binding.agentPath, + initialHistory, + systemPrompt, + binding, + statusSink, + }) + const metadata: AgentMetadata = { + agentId, + agentPath: binding.agentPath, + taskName: params.taskName.trim(), + parentId: sender.agentId, + parentPath: sender.agentPath, + status: 'pending_init', + updatedAt: new Date().toISOString(), + } + this.registry.register(metadata, reservation) + this.bindings.set(agentId, binding) + this.sessions.set(agentId, session) + const runtime = new AgentRuntime(session, binding.inputQueue, this.limiter, (error) => { + void this.recordUnexpectedError(agentId, error) + }) + this.runtimes.set(agentId, runtime) + await this.publishActivity(metadata) + runtime.deliverWithPermit( + { + author: sender.agentPath, + recipient: binding.agentPath, + content: params.message, + triggerTurn: true, + }, + permit, + ) + return metadata + } catch (error) { + permit.release() + reservation.release() + if (session) await session.close().catch(() => {}) + this.runtimes.delete(agentId) + this.sessions.delete(agentId) + this.bindings.delete(agentId) + this.registry.remove(agentId) + throw error + } + } + + sendMessage(sender: CollabSessionBinding, target: string, content: string, triggerTurn: boolean): AgentMetadata { + const receiver = this.resolveTarget(target, sender.agentPath) + if (triggerTurn && receiver.agentPath === ROOT_AGENT_PATH) { + throw new Error('follow-up tasks cannot target the root agent') + } + const communication: InterAgentCommunication = { + author: sender.agentPath, + recipient: receiver.agentPath, + content, + triggerTurn, + } + const runtime = this.runtimes.get(receiver.agentId) + if (runtime) runtime.deliver(communication) + else this.bindings.get(receiver.agentId)?.inputQueue.enqueue(communication) + return receiver + } + + async waitForActivity(binding: CollabSessionBinding, timeoutMs: number, signal?: AbortSignal) { + return binding.inputQueue.waitForActivity(timeoutMs, signal) + } + + interruptAgent(sender: CollabSessionBinding, target: string): AgentMetadata { + const receiver = this.resolveTarget(target, sender.agentPath) + if (receiver.agentPath === ROOT_AGENT_PATH) throw new Error('root is not a spawned agent') + if (receiver.agentId === sender.agentId) throw new Error('an agent cannot interrupt itself') + this.runtimes.get(receiver.agentId)?.interrupt() + return receiver + } + + listAgents(sender: CollabSessionBinding, pathPrefix?: string): AgentMetadata[] { + let prefix = sender.agentPath + if (pathPrefix) { + prefix = pathPrefix.startsWith('/') ? pathPrefix : `${sender.agentPath}/${pathPrefix}` + } + return this.registry.list(prefix) + } + + async shutdownDescendants(rootAgentId: string): Promise { + if (rootAgentId !== this.options.rootSessionId || this.shuttingDown) return + this.shuttingDown = true + const descendants = this.registry + .list(ROOT_AGENT_PATH) + .sort((left, right) => agentPathDepth(right.agentPath) - agentPathDepth(left.agentPath)) + for (const agent of descendants) { + await this.runtimes.get(agent.agentId)?.shutdown() + this.runtimes.delete(agent.agentId) + this.sessions.delete(agent.agentId) + this.bindings.delete(agent.agentId) + this.registry.remove(agent.agentId) + } + this.bindings.get(rootAgentId)?.inputQueue.close() + } + + private resolveTarget(target: string, senderPath: string): AgentMetadata { + const receiver = this.registry.resolve(target.trim(), senderPath) + if (!receiver) throw new Error(`agent not found: ${target}`) + return receiver + } + + private wakePendingAgents(): void { + if (this.shuttingDown) return + for (const runtime of this.runtimes.values()) { + runtime.wakePending() + } + } + + private async handleAgentEvent(agentId: string, event: HistoryEvent): Promise { + const current = this.registry.getById(agentId) + if (!current) return + const nextStatus = deriveAgentStatusFromEvent(current, event) + const updated = this.registry.update(agentId, nextStatus) + if (!updated) return + + if (event.type === 'turn_start' || event.type === 'turn_end' || event.type === 'session_end') { + await this.publishActivity(updated) + } + + if (event.type !== 'turn_end' || !isFinalAgentStatus(updated.status) || this.shuttingDown) return + this.notifyParentOfFinal(updated) + } + + private notifyParentOfFinal(updated: AgentMetadata): void { + const parent = updated.parentId ? this.bindings.get(updated.parentId) : undefined + if (!parent) return + const detail = updated.error ?? updated.lastMessage ?? '' + parent.inputQueue.enqueue({ + author: updated.agentPath, + recipient: parent.agentPath, + content: `\n${detail}\n`, + triggerTurn: false, + }) + } + + private async recordUnexpectedError(agentId: string, error: Error): Promise { + const updated = this.registry.update(agentId, { status: 'errored', error: error.message }) + if (!updated) return + await this.publishActivity(updated) + if (!this.shuttingDown) this.notifyParentOfFinal(updated) + } + + private async publishActivity(metadata: AgentMetadata): Promise { + const activity: AgentActivity = { + agentId: metadata.agentId, + agentPath: metadata.agentPath, + taskName: metadata.taskName, + parentId: metadata.parentId, + status: metadata.status, + lastMessage: metadata.lastMessage, + error: metadata.error, + updatedAt: metadata.updatedAt, + } + try { + this.options.onAgentActivity?.(activity) + } catch { + // UI callbacks must not change agent lifecycle behavior. + } + const event: HistoryEvent = { + ts: new Date().toISOString(), + sessionId: this.options.rootSessionId, + type: 'agent_status', + content: activity.lastMessage, + meta: { + agent_id: activity.agentId, + agent_path: activity.agentPath, + task_name: activity.taskName, + parent_id: activity.parentId, + status: activity.status, + error: activity.error, + updated_at: activity.updatedAt, + }, + } + await Promise.allSettled(this.options.rootHistorySinks.map((sink) => sink.append(event))) + } +} diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index 3360f65..ca11b09 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -48,6 +48,8 @@ import { import type { ToolApprovalHooks } from '@memo/core/tools/sdk_tools' import { runWithRuntimeContext } from '@memo/core/tools/runtime/context' import type { ToolExecutionContext } from '@memo/core/tools/sdk_tools' +import { formatInterAgentCommunication, type InterAgentCommunication } from '@memo/core/agent/communication' +import type { CollabSessionBinding } from '@memo/core/agent/control' import { createStepGate } from '@memo/core/tools/runtime/step_gate' import { isToolSkippedOutput, @@ -84,6 +86,11 @@ type ActiveSessionOperation = { done: Promise } +export type AgentSessionRuntimeOptions = { + initialHistory?: ChatMessage[] + collab?: CollabSessionBinding +} + /** In-process conversation Session, implements multi-turn execution and log writing. */ export class AgentSessionImpl implements AgentSession { public title?: string @@ -111,6 +118,7 @@ export class AgentSessionImpl implements AgentSession { private toolPermissionMode: ToolPermissionMode | 'auto' = 'auto' /** Thinking override; undefined follows the provider model profile. */ private thinkingOverride: boolean | undefined + private collab?: CollabSessionBinding constructor( private deps: AgentSessionDeps & { @@ -121,10 +129,11 @@ export class AgentSessionImpl implements AgentSession { systemPrompt: string, tokenCounter: TokenCounter, historyFilePath?: string, + runtimeOptions: AgentSessionRuntimeOptions = {}, ) { this.id = options.sessionId || randomUUID() this.mode = options.mode || DEFAULT_SESSION_MODE - this.history = [{ role: 'system', content: systemPrompt }] + this.history = runtimeOptions.initialHistory?.slice() ?? [{ role: 'system', content: systemPrompt }] this.tokenCounter = tokenCounter this.sinks = deps.historySinks ?? [] this.hooks = buildHookRunners(deps) @@ -137,6 +146,7 @@ export class AgentSessionImpl implements AgentSession { mode: resolvedPermission.approvalMode, }) this.thinkingOverride = options.thinking + this.collab = runtimeOptions.collab } /** 运行时切换思考模式(undefined 恢复为跟随模型 profile)。 */ @@ -451,16 +461,17 @@ export class AgentSessionImpl implements AgentSession { private buildToolApprovalHooks(turn: number, step: number): ToolApprovalHooks { return { onApprovalRequest: async (request: ApprovalRequest) => { + const contextualRequest = this.contextualizeApprovalRequest(request) await runHook(this.hooks, 'onApprovalRequest', { sessionId: this.id, turn, step, - request, + request: contextualRequest, }) }, requestApproval: async (request: ApprovalRequest): Promise => { if (this.deps.requestApproval) { - return this.deps.requestApproval(request) + return this.deps.requestApproval(this.contextualizeApprovalRequest(request)) } return 'deny' }, @@ -476,8 +487,51 @@ export class AgentSessionImpl implements AgentSession { } } + private contextualizeApprovalRequest(request: ApprovalRequest): ApprovalRequest { + return { + ...request, + sessionId: this.id, + agentId: this.collab?.agentId, + agentPath: this.collab?.agentPath, + } + } + + private async recordAgentMessages( + turn: number, + messages: InterAgentCommunication[], + step?: number, + ): Promise { + if (messages.length === 0) return false + for (const message of messages) { + const content = formatInterAgentCommunication(message) + this.history.push({ role: 'user', content }) + await this.emitEvent('agent_message', { + turn, + step, + content, + role: 'user', + meta: { + author: message.author, + recipient: message.recipient, + trigger_turn: message.triggerTurn, + }, + }) + } + return true + } + + private async drainPendingAgentMessages(turn: number, step: number): Promise { + const messages = this.collab?.inputQueue.drainAll() ?? [] + await this.recordAgentMessages(turn, messages, step) + } + + private async drainTriggeredAgentMessages(turn: number, step: number): Promise { + const messages = this.collab?.inputQueue.drainTriggeredBatch() ?? [] + return this.recordAgentMessages(turn, messages, step) + } + private async maybeGenerateSessionTitle(turn: number, originalPrompt: string) { - if (turn !== 1 || this.title) return + if (turn !== 1 || this.title || (this.collab && this.collab.agentPath !== '/root')) return const title = fallbackSessionTitleFromPrompt(originalPrompt) this.title = title @@ -566,6 +620,7 @@ export class AgentSessionImpl implements AgentSession { // ReAct 主循环 for (let step = 0; ; step++) { + await this.drainPendingAgentMessages(turn, step) let estimatedPrompt = this.tokenCounter.countMessages(this.history) await this.emitContextUsage( turn, @@ -631,6 +686,7 @@ export class AgentSessionImpl implements AgentSession { toolsDisabled: this.toolsDisabled, gate: createStepGate(), skillIndex: this.deps.skillIndex, + collab: this.collab, } try { const llmResult = await this.deps.callLLM( @@ -639,13 +695,13 @@ export class AgentSessionImpl implements AgentSession { if (chunk) { receivedAssistantChunk = true } - this.deps.onAssistantStep?.(chunk, step) + this.deps.onAssistantStep?.(chunk, step, this.id) }, { signal: abortController.signal, toolContext, thinking: this.thinkingOverride, - onReasoningChunk: (chunk) => this.deps.onReasoningChunk?.(chunk, step), + onReasoningChunk: (chunk) => this.deps.onReasoningChunk?.(chunk, step, this.id), }, ) const normalized = normalizeLLMResponse(llmResult) @@ -702,7 +758,7 @@ export class AgentSessionImpl implements AgentSession { } if (!receivedAssistantChunk && assistantText) { - this.deps.onAssistantStep?.(assistantText, step) + this.deps.onAssistantStep?.(assistantText, step, this.id) } const textToolCall = @@ -998,6 +1054,9 @@ export class AgentSessionImpl implements AgentSession { // 无工具调用:文本即最终回复 if (toolUseBlocks.length === 0) { + if (await this.drainTriggeredAgentMessages(turn, step)) { + continue + } this.resetActionRepetition() const shouldFallbackFromPreviousText = toolUseBlocks.length === 0 && @@ -1068,6 +1127,7 @@ export class AgentSessionImpl implements AgentSession { stepCount: steps.length, durationMs: Date.now() - turnStartedAt, tokens: turnUsage, + error_message: errorMessage, protocol_violation_count: protocolViolationCount || undefined, }, }) @@ -1113,6 +1173,10 @@ export class AgentSessionImpl implements AgentSession { private async closeInternal() { await this.activeOperation?.done + if (this.collab?.agentPath === '/root') { + await this.collab.control.shutdownDescendants(this.collab.agentId) + } + this.collab?.inputQueue.close() // 空会话(从未 runTurn)不写 session_end,避免空会话落盘;sink 清理始终执行。 if (this.sessionStartEmitted) { await this.emitEvent('session_end', { @@ -1150,7 +1214,13 @@ export class AgentSessionImpl implements AgentSession { step: payload.step, content: payload.content, role: payload.role, - meta: payload.meta, + meta: this.collab + ? { + ...payload.meta, + agent_id: this.collab.agentId, + agent_path: this.collab.agentPath, + } + : payload.meta, }) await emitEventToSinks(event, this.sinks) } diff --git a/packages/core/src/agent/runtime.ts b/packages/core/src/agent/runtime.ts new file mode 100644 index 0000000..6e775b7 --- /dev/null +++ b/packages/core/src/agent/runtime.ts @@ -0,0 +1,87 @@ +import type { AgentSession } from '@memo/core/types' +import { formatInterAgentCommunication, type InterAgentCommunication, type InputQueue } from './communication' +import type { ExecutionLimiter, ExecutionPermit } from './registry' + +export class AgentRuntime { + private activeTurn: Promise | null = null + private shuttingDown = false + + constructor( + private readonly session: AgentSession, + readonly mailbox: InputQueue, + private readonly limiter: ExecutionLimiter, + private readonly onUnexpectedError: (error: Error) => void, + ) {} + + deliver(communication: InterAgentCommunication): void { + if (this.shuttingDown) throw new Error('agent is shutdown') + + if (communication.triggerTurn && !this.activeTurn) { + const permit = this.limiter.tryAcquire() + if (!permit) throw new Error('subagent concurrency limit reached') + this.mailbox.enqueue(communication) + this.startPendingTurn(permit) + return + } + + this.mailbox.enqueue(communication) + } + + deliverWithPermit(communication: InterAgentCommunication, permit: ExecutionPermit): void { + if (this.shuttingDown || this.activeTurn) { + permit.release() + throw new Error('agent cannot start its initial turn') + } + this.mailbox.enqueue(communication) + this.startPendingTurn(permit) + } + + wakePending(): boolean { + if (this.shuttingDown || this.activeTurn || !this.mailbox.hasTrigger()) return false + const permit = this.limiter.tryAcquire() + if (!permit) return false + this.startPendingTurn(permit) + return true + } + + interrupt(): void { + this.session.cancelCurrentTurn?.('interrupted by another agent') + } + + async shutdown(): Promise { + if (this.shuttingDown) { + await this.activeTurn + return + } + this.shuttingDown = true + this.mailbox.close() + this.session.cancelCurrentTurn?.('agent shutdown') + await this.activeTurn + await this.session.close() + } + + get isRunning(): boolean { + return this.activeTurn !== null + } + + private startPendingTurn(permit: ExecutionPermit): void { + const messages = this.mailbox.drainTriggeredBatch() + if (messages.length === 0) { + permit.release() + return + } + + const input = messages.map(formatInterAgentCommunication).join('\n\n') + const running = this.session + .runTurn(input) + .then(() => {}) + .catch((error: unknown) => { + this.onUnexpectedError(error instanceof Error ? error : new Error(String(error))) + }) + .finally(() => { + if (this.activeTurn === running) this.activeTurn = null + permit.release() + }) + this.activeTurn = running + } +} diff --git a/packages/core/src/agent/session.ts b/packages/core/src/agent/session.ts index 3f7974d..1604bae 100644 --- a/packages/core/src/agent/session.ts +++ b/packages/core/src/agent/session.ts @@ -3,6 +3,8 @@ import { withDefaultDeps } from '@memo/core/agent/defaults' import { DEFAULT_SESSION_MODE } from '@memo/core/agent/constants' import { AgentSessionImpl } from '@memo/core/agent/loop' import type { AgentSession, AgentSessionDeps, AgentSessionOptions } from '@memo/core/types' +import { AgentControl } from '@memo/core/agent/control' +import { createSubagentSession } from '@memo/core/agent/subagent' export { SessionBusyError, SessionClosedError, type SessionOperationKind } from '@memo/core/agent/loop' @@ -16,13 +18,37 @@ export async function createAgentSession( const sessionId = options.sessionId || randomUUID() const resolved = await withDefaultDeps(deps, { ...options, sessionId }, sessionId) const systemPrompt = await resolved.loadPrompt() + const collabEnabled = process.env.MEMO_ENABLE_COLLAB_TOOLS !== '0' + let control: AgentControl | undefined + if (collabEnabled) { + control = new AgentControl({ + rootSessionId: sessionId, + rootSystemPrompt: systemPrompt, + rootHistorySinks: resolved.historySinks, + onAgentActivity: deps.onAgentActivity, + createSubagent: (params) => + createSubagentSession({ + deps: resolved, + rootDeps: deps, + options: { ...options, mode: options.mode ?? DEFAULT_SESSION_MODE }, + sessionId: params.sessionId, + systemPrompt: params.systemPrompt, + initialHistory: params.initialHistory, + binding: params.binding, + statusSink: params.statusSink, + }), + }) + } + const collab = control?.createRootBinding() const session = new AgentSessionImpl( { ...(deps as AgentSessionDeps), ...resolved }, { ...options, sessionId, mode: options.mode ?? DEFAULT_SESSION_MODE }, systemPrompt, resolved.tokenCounter, resolved.historyFilePath, + { collab }, ) await session.init() + control?.attachRootSession(session) return session } diff --git a/packages/core/src/agent/status.test.ts b/packages/core/src/agent/status.test.ts index 8dcc0a1..1d49387 100644 --- a/packages/core/src/agent/status.test.ts +++ b/packages/core/src/agent/status.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'vitest' -import { deriveAgentStatusFromEvent, isFinalAgentStatus } from './status' +import { deriveAgentStatusFromEvent, isFinalAgentStatus, type AgentStatusSnapshot } from './status' describe('agent status', () => { test('derives lifecycle status from session events', () => { - let status = { status: 'pending_init' as const } + let status: AgentStatusSnapshot = { status: 'pending_init' } status = deriveAgentStatusFromEvent(status, { type: 'turn_start' }) expect(status.status).toBe('running') status = deriveAgentStatusFromEvent(status, { type: 'final', content: 'done' }) diff --git a/packages/core/src/agent/subagent.ts b/packages/core/src/agent/subagent.ts new file mode 100644 index 0000000..a9bdebb --- /dev/null +++ b/packages/core/src/agent/subagent.ts @@ -0,0 +1,33 @@ +import type { ResolvedSessionDeps } from '@memo/core/agent/defaults' +import { AgentSessionImpl } from '@memo/core/agent/loop' +import type { CollabSessionBinding } from '@memo/core/agent/control' +import type { AgentSession, AgentSessionDeps, AgentSessionOptions, ChatMessage, HistorySink } from '@memo/core/types' + +export async function createSubagentSession(params: { + deps: ResolvedSessionDeps + rootDeps: AgentSessionDeps + options: AgentSessionOptions + sessionId: string + systemPrompt: string + initialHistory: ChatMessage[] + binding: CollabSessionBinding + statusSink: HistorySink +}): Promise { + const childHistory = params.deps.createChildHistory(params.sessionId) + const session = new AgentSessionImpl( + { + ...params.deps, + historySinks: [...childHistory.historySinks, params.statusSink], + requestApproval: params.rootDeps.requestApproval, + // Shared model/MCP tools are borrowed from the root; only the root owns their disposer. + dispose: undefined, + }, + { ...params.options, sessionId: params.sessionId }, + params.systemPrompt, + params.deps.tokenCounter, + childHistory.historyFilePath, + { initialHistory: params.initialHistory, collab: params.binding }, + ) + await session.init() + return session +} diff --git a/packages/core/src/prompt/prompt.md b/packages/core/src/prompt/prompt.md index ebb3c1c..318b250 100644 --- a/packages/core/src/prompt/prompt.md +++ b/packages/core/src/prompt/prompt.md @@ -167,10 +167,11 @@ Use `get_memory` to retrieve persisted memory context for the current workflow: ## Subagent Collaboration -- Subagent tools (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`) do not require approval; treat their execution as dangerous and keep scope explicit +- Subagent tools (`spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, `list_agents`) do not require approval; keep delegated tasks narrow and explicit - Use subagents only for decomposable, well-scoped tasks; avoid recursive spawn loops -- Send concise task prompts, wait for completion, then summarize results back into the main thread -- Call `close_agent` for finished agents to release resources +- Use `send_message` to queue context without waking an idle agent; use `followup_task` when it should continue immediately +- `wait_agent` waits for mailbox activity; the result is injected into the next model request instead of returned by the tool +- Use `interrupt_agent` to stop a current turn without destroying the agent's conversation history ## Tool Call Discipline (CRITICAL) diff --git a/packages/core/src/tools/approval/constants.ts b/packages/core/src/tools/approval/constants.ts index 6a1ff54..a0bca98 100644 --- a/packages/core/src/tools/approval/constants.ts +++ b/packages/core/src/tools/approval/constants.ts @@ -16,11 +16,12 @@ export const DEFAULT_TOOL_RISK_LEVELS: Record = { read_files: 'read', list_directory: 'read', search_files: 'read', - wait: 'read', + wait_agent: 'read', spawn_agent: 'read', - send_input: 'read', - resume_agent: 'read', - close_agent: 'read', + send_message: 'read', + followup_task: 'read', + interrupt_agent: 'read', + list_agents: 'read', // Write tools: require approval. apply_patch: 'write', @@ -40,10 +41,11 @@ export const DEFAULT_TOOL_RISK_LEVELS: Record = { */ export const ALWAYS_AUTO_APPROVE_TOOLS = new Set([ 'spawn_agent', - 'send_input', - 'resume_agent', - 'wait', - 'close_agent', + 'send_message', + 'followup_task', + 'wait_agent', + 'interrupt_agent', + 'list_agents', ]) /** Risk level ordering for comparisons. */ diff --git a/packages/core/src/tools/approval/types.ts b/packages/core/src/tools/approval/types.ts index f004bcf..3cb2b6d 100644 --- a/packages/core/src/tools/approval/types.ts +++ b/packages/core/src/tools/approval/types.ts @@ -31,6 +31,9 @@ export interface ApprovalRequest { fingerprint: ApprovalKey riskLevel: RiskLevel reason: string + sessionId?: string + agentId?: string + agentPath?: string } /** Approval manager configuration */ diff --git a/packages/core/src/tools/index.test.ts b/packages/core/src/tools/index.test.ts index f029061..379a49d 100644 --- a/packages/core/src/tools/index.test.ts +++ b/packages/core/src/tools/index.test.ts @@ -12,10 +12,11 @@ describe('toolkit defaults', () => { vi.resetModules() const mod = await import('./index') assert.ok(mod.TOOLKIT.spawn_agent) - assert.ok(mod.TOOLKIT.send_input) - assert.ok(mod.TOOLKIT.resume_agent) - assert.ok(mod.TOOLKIT.wait) - assert.ok(mod.TOOLKIT.close_agent) + assert.ok(mod.TOOLKIT.send_message) + assert.ok(mod.TOOLKIT.followup_task) + assert.ok(mod.TOOLKIT.wait_agent) + assert.ok(mod.TOOLKIT.interrupt_agent) + assert.ok(mod.TOOLKIT.list_agents) }) test('allows explicit disabling collab tools via MEMO_ENABLE_COLLAB_TOOLS=0', async () => { @@ -23,9 +24,10 @@ describe('toolkit defaults', () => { vi.resetModules() const mod = await import('./index') assert.strictEqual(mod.TOOLKIT.spawn_agent, undefined) - assert.strictEqual(mod.TOOLKIT.send_input, undefined) - assert.strictEqual(mod.TOOLKIT.resume_agent, undefined) - assert.strictEqual(mod.TOOLKIT.wait, undefined) - assert.strictEqual(mod.TOOLKIT.close_agent, undefined) + assert.strictEqual(mod.TOOLKIT.send_message, undefined) + assert.strictEqual(mod.TOOLKIT.followup_task, undefined) + assert.strictEqual(mod.TOOLKIT.wait_agent, undefined) + assert.strictEqual(mod.TOOLKIT.interrupt_agent, undefined) + assert.strictEqual(mod.TOOLKIT.list_agents, undefined) }) }) diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index d733926..8246a54 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -20,7 +20,14 @@ import { updatePlanTool } from '@memo/core/tools/tools/update_plan' import { getMemoryTool } from '@memo/core/tools/tools/get_memory' import { readSkillTool } from '@memo/core/tools/tools/read_skill' import { webfetchTool } from '@memo/core/tools/tools/webfetch' -import { closeAgentTool, resumeAgentTool, sendInputTool, spawnAgentTool, waitTool } from '@memo/core/tools/tools/collab' +import { + followupTaskTool, + interruptAgentTool, + listAgentsTool, + sendMessageTool, + spawnAgentTool, + waitAgentTool, +} from '@memo/core/tools/tools/collab' function buildCodexTools(): Record { const tools: Record = {} @@ -62,10 +69,11 @@ function buildCodexTools(): Record { if (collabEnabled) { tools.spawn_agent = spawnAgentTool - tools.send_input = sendInputTool - tools.resume_agent = resumeAgentTool - tools.wait = waitTool - tools.close_agent = closeAgentTool + tools.send_message = sendMessageTool + tools.followup_task = followupTaskTool + tools.wait_agent = waitAgentTool + tools.interrupt_agent = interruptAgentTool + tools.list_agents = listAgentsTool } return tools diff --git a/packages/core/src/tools/sdk_tools.ts b/packages/core/src/tools/sdk_tools.ts index 2b67bbd..033277d 100644 --- a/packages/core/src/tools/sdk_tools.ts +++ b/packages/core/src/tools/sdk_tools.ts @@ -5,6 +5,7 @@ import type { ApprovalDecision, ApprovalManager, ApprovalRequest } from '@memo/c import { getMaxToolResultChars } from '@memo/core/tools/runtime/tool_output_limits' import type { StepGate } from '@memo/core/tools/runtime/step_gate' import type { SkillIndex } from '@memo/core/skills/skills' +import type { CollabSessionBinding } from '@memo/core/agent/control' const TOOL_SKIPPED_AFTER_REJECTION_MESSAGE = 'Skipped tool execution after previous rejection.' export const TOOL_SKIPPED_DISABLED_MESSAGE = 'Tool execution skipped: tools are disabled in current permission mode.' @@ -25,6 +26,8 @@ export type ToolExecutionContext = { gate: StepGate /** Deduped skill snapshot for the current session (read_skill). */ skillIndex?: SkillIndex + /** Session-scoped multi-agent control binding. */ + collab?: CollabSessionBinding } function escapeXmlAttr(value: string) { diff --git a/packages/core/src/tools/tools/collab.test.ts b/packages/core/src/tools/tools/collab.test.ts index 09eb625..f968d01 100644 --- a/packages/core/src/tools/tools/collab.test.ts +++ b/packages/core/src/tools/tools/collab.test.ts @@ -1,187 +1,218 @@ -import assert from 'node:assert' +import { describe, expect, test } from 'vitest' import type { Tool, ToolExecutionOptions } from 'ai' import type { ToolResultOutput } from '@ai-sdk/provider-utils' -import type { ToolOutput } from '@memo/core/tools/tools/mcp' -import { mkdir, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { afterAll, afterEach, beforeAll, describe, test } from 'vitest' +import { createAgentSession, createTokenCounter, type CallLLM, type AgentSessionDeps, type LLMResult } from '@memo/core' +import type { ToolExecutionContext } from '@memo/core/tools/sdk_tools' +import { emptyUsage } from '@memo/core/utils/usage' import { - __resetCollabStateForTests, - closeAgentTool, - resumeAgentTool, - sendInputTool, + followupTaskTool, + interruptAgentTool, + listAgentsTool, + sendMessageTool, spawnAgentTool, - waitTool, -} from '@memo/core/tools/tools/collab' - -let tempDir: string -let prevCommand: string | undefined -let prevMaxAgents: string | undefined - -async function makeTempDir(prefix: string) { - const dir = join(tmpdir(), `${prefix}-${crypto.randomUUID()}`) - await mkdir(dir, { recursive: true }) - return dir + waitAgentTool, +} from './collab' + +function response(text: string): LLMResult { + return { + text, + toolCalls: [], + toolResults: [], + usage: emptyUsage(), + finishReason: 'stop', + } } -function textPayload(result: ToolOutput) { - if (result.type === 'text' || result.type === 'error-text') return result.value ?? '' - return '' +function textPayload(result: ToolResultOutput): string { + return result.type === 'text' || result.type === 'error-text' ? result.value : '' } -beforeAll(async () => { - tempDir = await makeTempDir('memo-tools-collab') - const scriptPath = join(tempDir, 'fake-subagent.cjs') - const script = ` -process.stdin.setEncoding('utf8') -let input = '' -process.stdin.on('data', (chunk) => { - input += chunk -}) -process.stdin.on('end', () => { - const msg = input.trim() - if (msg.startsWith('sleep:')) { - const ms = Number(msg.slice('sleep:'.length)) || 0 - setTimeout(() => { - console.log('slept:' + ms) - process.exit(0) - }, ms) - return - } - if (msg === 'hang') { - setInterval(() => {}, 1000) - return - } - if (msg === 'fail') { - console.error('forced failure') - process.exit(2) - return - } - if (msg.startsWith('echo:')) { - console.log(msg.slice('echo:'.length)) - process.exit(0) - return - } - console.log('ok:' + msg) - process.exit(0) -}) -`.trim() - await writeFile(scriptPath, script, 'utf8') - - prevCommand = process.env.MEMO_SUBAGENT_COMMAND - prevMaxAgents = process.env.MEMO_SUBAGENT_MAX_AGENTS - process.env.MEMO_SUBAGENT_COMMAND = `node ${JSON.stringify(scriptPath)}` - process.env.MEMO_SUBAGENT_MAX_AGENTS = '4' -}) - -afterEach(async () => { - await __resetCollabStateForTests() -}) - -afterAll(async () => { - if (prevCommand === undefined) { - delete process.env.MEMO_SUBAGENT_COMMAND - } else { - process.env.MEMO_SUBAGENT_COMMAND = prevCommand - } - if (prevMaxAgents === undefined) { - delete process.env.MEMO_SUBAGENT_MAX_AGENTS - } else { - process.env.MEMO_SUBAGENT_MAX_AGENTS = prevMaxAgents - } - await __resetCollabStateForTests() - await rm(tempDir, { recursive: true, force: true }) -}) - -async function runTool(tool: Tool, input: unknown): Promise { - return (await tool.execute!(input, {} as ToolExecutionOptions)) as ToolResultOutput +function toolOptions(context: ToolExecutionContext): ToolExecutionOptions { + return { experimental_context: context } as ToolExecutionOptions } -describe('collab tools', () => { - test('spawn + wait reaches completed status and returns final map payload', async () => { - const spawnResult = await runTool(spawnAgentTool, { message: 'echo:hello' }) - assert.strictEqual(spawnResult.type, 'text') - const spawned = JSON.parse(textPayload(spawnResult)) - assert.strictEqual(spawned.status, 'running') - assert.ok(typeof spawned.agent_id === 'string' && spawned.agent_id.length > 0) +async function runTool(tool: Tool, input: unknown, context: ToolExecutionContext): Promise { + return (await tool.execute!(input, toolOptions(context))) as ToolResultOutput +} - const waitResult = await runTool(waitTool, { ids: [spawned.agent_id], timeout_ms: 10_000 }) - assert.strictEqual(waitResult.type, 'text') - const waited = JSON.parse(textPayload(waitResult)) - assert.strictEqual(waited.timed_out, false) - assert.strictEqual(waited.status[spawned.agent_id], 'completed') - assert.strictEqual(waited.details[spawned.agent_id].status, 'completed') - assert.ok( - typeof waited.details[spawned.agent_id].last_output === 'string' && - waited.details[spawned.agent_id].last_output.includes('hello'), - ) +async function createHarness(callLLM?: CallLLM, overrides: Partial = {}) { + let rootContext: ToolExecutionContext | undefined + const calls: Array<{ path?: string; content: string }> = [] + const session = await createAgentSession({ + ...overrides, + callLLM: async (messages, _onChunk, options) => { + const path = options?.toolContext?.collab?.agentPath + if (path === '/root') rootContext = options?.toolContext + const last = messages[messages.length - 1] + const content = typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? '') + if (path) calls.push({ path, content }) + return callLLM ? callLLM(messages, _onChunk, options) : response(`done:${content}`) + }, + historySinks: [], + loadPrompt: async () => 'system', + tokenCounter: createTokenCounter(), + tools: {}, }) + await session.runTurn('bootstrap') + if (!rootContext) throw new Error('root tool context was not captured') + return { session, context: rootContext, calls } +} - test('close_agent marks closed and resume_agent restores pre-close status', async () => { - const spawnResult = await runTool(spawnAgentTool, { message: 'echo:first' }) - const spawned = JSON.parse(textPayload(spawnResult)) - const agentId = spawned.agent_id as string - - await runTool(waitTool, { ids: [agentId], timeout_ms: 10_000 }) +describe('in-process collab tools', () => { + test('spawns a stateful child and delivers completion through the parent mailbox', async () => { + const harness = await createHarness() + try { + const spawnedResult = await runTool( + spawnAgentTool, + { task_name: 'review', message: 'inspect this', fork_turns: 'all' }, + harness.context, + ) + const spawned = JSON.parse(textPayload(spawnedResult)) + expect(spawned.agent_path).toBe('/root/review') + + const waitResult = await runTool(waitAgentTool, { timeout_ms: 10_000 }, harness.context) + expect(JSON.parse(textPayload(waitResult))).toMatchObject({ timed_out: false }) + + const agentsResult = await runTool(listAgentsTool, {}, harness.context) + const agents = JSON.parse(textPayload(agentsResult)).agents + expect(agents).toHaveLength(1) + expect(agents[0]).toMatchObject({ agent_path: '/root/review', status: 'completed' }) + expect(harness.context.collab?.inputQueue.drainAll()[0]?.content).toContain('agent_completion') + } finally { + await harness.session.close() + } + }) - const closeResult = await runTool(closeAgentTool, { id: agentId }) - const closed = JSON.parse(textPayload(closeResult)) - assert.strictEqual(closed.status, 'closed') + test('queues messages without waking idle agents and folds them into a later follow-up', async () => { + const harness = await createHarness() + try { + await runTool(spawnAgentTool, { task_name: 'worker', message: 'first' }, harness.context) + await runTool(waitAgentTool, { timeout_ms: 10_000 }, harness.context) + harness.context.collab?.inputQueue.drainAll() + + await runTool(sendMessageTool, { target: 'worker', message: 'queued note' }, harness.context) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(harness.calls.filter((call) => call.path === '/root/worker')).toHaveLength(1) + + await runTool(followupTaskTool, { target: 'worker', message: 'continue now' }, harness.context) + await runTool(waitAgentTool, { timeout_ms: 10_000 }, harness.context) + const childCalls = harness.calls.filter((call) => call.path === '/root/worker') + expect(childCalls).toHaveLength(2) + expect(childCalls[1]?.content).toContain('queued note') + expect(childCalls[1]?.content).toContain('continue now') + } finally { + await harness.session.close() + } + }) - const sendWhileClosed = await runTool(sendInputTool, { - id: agentId, - message: 'echo:second', + test('rejects a second running child when the execution limit is reached', async () => { + const previous = process.env.MEMO_SUBAGENT_MAX_AGENTS + process.env.MEMO_SUBAGENT_MAX_AGENTS = '1' + let releaseChild!: () => void + const childBlocked = new Promise((resolve) => { + releaseChild = resolve }) - assert.strictEqual(sendWhileClosed.type, 'error-text') - assert.ok(textPayload(sendWhileClosed).includes('resume_agent')) - - const resumeResult = await runTool(resumeAgentTool, { id: agentId }) - const resumed = JSON.parse(textPayload(resumeResult)) - assert.strictEqual(resumed.status, 'completed') - - const sendAfterResume = await runTool(sendInputTool, { - id: agentId, - message: 'echo:second', + const harness = await createHarness(async (_messages, _onChunk, options) => { + if (options?.toolContext?.collab?.agentPath !== '/root') await childBlocked + return response('done') }) - assert.strictEqual(sendAfterResume.type, 'text') + try { + await runTool(spawnAgentTool, { task_name: 'first', message: 'hold' }, harness.context) + const second = await runTool(spawnAgentTool, { task_name: 'second', message: 'blocked' }, harness.context) + expect(second.type).toBe('error-text') + expect(textPayload(second)).toContain('concurrency limit') + releaseChild() + await runTool(waitAgentTool, { timeout_ms: 10_000 }, harness.context) + } finally { + releaseChild() + await harness.session.close() + if (previous === undefined) delete process.env.MEMO_SUBAGENT_MAX_AGENTS + else process.env.MEMO_SUBAGENT_MAX_AGENTS = previous + } }) - test('wait returns not_found immediately for unknown agents', async () => { - const waitResult = await runTool(waitTool, { - ids: ['missing-agent-id'], - timeout_ms: 10_000, + test('injects a running follow-up before the child turn can finish', async () => { + let childCalls = 0 + let markStarted!: () => void + let releaseFirst!: () => void + const started = new Promise((resolve) => { + markStarted = resolve + }) + const blocked = new Promise((resolve) => { + releaseFirst = resolve + }) + const harness = await createHarness(async (_messages, _onChunk, options) => { + if (options?.toolContext?.collab?.agentPath === '/root/live') { + childCalls += 1 + if (childCalls === 1) { + markStarted() + await blocked + } + } + return response(`child-call-${childCalls}`) }) - const waited = JSON.parse(textPayload(waitResult)) - assert.strictEqual(waited.timed_out, false) - assert.strictEqual(waited.status['missing-agent-id'], 'not_found') - assert.strictEqual(waited.details['missing-agent-id'].status, 'not_found') - assert.strictEqual(waited.details['missing-agent-id'].last_output, null) + try { + await runTool(spawnAgentTool, { task_name: 'live', message: 'start' }, harness.context) + await started + await runTool(followupTaskTool, { target: 'live', message: 'new requirement' }, harness.context) + releaseFirst() + await runTool(waitAgentTool, { timeout_ms: 10_000 }, harness.context) + expect(childCalls).toBe(2) + const calls = harness.calls.filter((call) => call.path === '/root/live') + expect(calls[1]?.content).toContain('new requirement') + } finally { + releaseFirst() + await harness.session.close() + } }) - test('spawn_agent respects MEMO_SUBAGENT_MAX_AGENTS limit', async () => { - process.env.MEMO_SUBAGENT_MAX_AGENTS = '1' - const first = await runTool(spawnAgentTool, { message: 'sleep:5000' }) - assert.strictEqual(first.type, 'text') - - const second = await runTool(spawnAgentTool, { message: 'echo:blocked' }) - assert.strictEqual(second.type, 'error-text') - assert.ok(textPayload(second).includes('concurrency limit')) + test('interrupts a turn and resumes the same child session with follow-up work', async () => { + let childCalls = 0 + let markStarted!: () => void + const started = new Promise((resolve) => { + markStarted = resolve + }) + const harness = await createHarness(async (_messages, _onChunk, options) => { + if (options?.toolContext?.collab?.agentPath !== '/root/recover') return response('root') + childCalls += 1 + if (childCalls > 1) return response('recovered') + markStarted() + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + const error = new Error('operation was aborted') + error.name = 'AbortError' + reject(error) + }, + { once: true }, + ) + }) + }) + try { + await runTool(spawnAgentTool, { task_name: 'recover', message: 'hang' }, harness.context) + await started + const interrupted = await runTool(interruptAgentTool, { target: 'recover' }, harness.context) + expect(JSON.parse(textPayload(interrupted)).previous_status).toBe('running') + await runTool(followupTaskTool, { target: 'recover', message: 'try again' }, harness.context) + await runTool(waitAgentTool, { timeout_ms: 10_000 }, harness.context) + expect(childCalls).toBe(2) + } finally { + await harness.session.close() + } }) - test('wait validates timeout and mutating tools report missing agents', async () => { - const invalidTimeout = await runTool(waitTool, { ids: ['missing'], timeout_ms: 0 }) - assert.strictEqual(invalidTimeout.type, 'error-text') - assert.ok(textPayload(invalidTimeout).includes('timeout_ms')) - - const sendResult = await runTool(sendInputTool, { id: 'missing', message: 'x' }) - assert.strictEqual(sendResult.type, 'error-text') - assert.ok(textPayload(sendResult).includes('agent not found')) - - const closeResult = await runTool(closeAgentTool, { id: 'missing' }) - assert.strictEqual(closeResult.type, 'error-text') - - const resumeResult = await runTool(resumeAgentTool, { id: 'missing' }) - assert.strictEqual(resumeResult.type, 'error-text') + test('keeps the shared dependency disposer owned by the root session', async () => { + let disposeCalls = 0 + const harness = await createHarness(undefined, { + dispose: async () => { + disposeCalls += 1 + }, + }) + await runTool(spawnAgentTool, { task_name: 'owned', message: 'finish' }, harness.context) + await runTool(waitAgentTool, { timeout_ms: 10_000 }, harness.context) + expect(disposeCalls).toBe(0) + await harness.session.close() + expect(disposeCalls).toBe(1) }) }) diff --git a/packages/core/src/tools/tools/collab.ts b/packages/core/src/tools/tools/collab.ts index 8d1f2db..ee2f71a 100644 --- a/packages/core/src/tools/tools/collab.ts +++ b/packages/core/src/tools/tools/collab.ts @@ -1,572 +1,199 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { existsSync } from 'node:fs' -import { resolve } from 'node:path' import { z } from 'zod' -import { tool } from 'ai' +import { tool, type ToolExecutionOptions } from 'ai' import { textResult } from '@memo/core/tools/tools/mcp' -import { getRuntimeCwd } from '@memo/core/tools/runtime/context' - -type AgentStatus = 'running' | 'completed' | 'errored' | 'closed' -type WaitStatus = AgentStatus | 'not_found' -type WaitDetail = { - status: WaitStatus - last_message: string | null - last_output: string | null - last_error: string | null - last_submission_id: string | null - updated_at: string | null -} - -type RunningSubmission = { - id: string - message: string - process: ChildProcessWithoutNullStreams - startedAt: string - interrupted: boolean -} - -type AgentRecord = { - id: string - createdAt: string - updatedAt: string - status: AgentStatus - statusBeforeClose: AgentStatus - lastMessage: string - lastSubmissionId: string | null - lastOutput: string | null - lastError: string | null - running: RunningSubmission | null -} +import type { ToolExecutionContext } from '@memo/core/tools/sdk_tools' +import type { CollabSessionBinding } from '@memo/core/agent/control' const DEFAULT_WAIT_TIMEOUT_MS = 30_000 const MIN_WAIT_TIMEOUT_MS = 10_000 const MAX_WAIT_TIMEOUT_MS = 300_000 -const DEFAULT_MAX_AGENTS = 4 -const TERMINATE_GRACE_MS = 1_500 -const MAX_OUTPUT_PREVIEW_CHARS = 2_000 - -const agents = new Map() const SPAWN_AGENT_INPUT_SCHEMA = z .object({ message: z.string().min(1), + task_name: z.string().min(1), agent_type: z.string().optional(), + fork_turns: z.string().optional(), }) .strict() -const SEND_INPUT_INPUT_SCHEMA = z +const MESSAGE_INPUT_SCHEMA = z .object({ - id: z.string().min(1), + target: z.string().min(1), message: z.string().min(1), - interrupt: z.boolean().optional(), }) .strict() -const RESUME_AGENT_INPUT_SCHEMA = z +const WAIT_AGENT_INPUT_SCHEMA = z .object({ - id: z.string().min(1), + timeout_ms: z.number().int().positive().optional(), }) .strict() -const WAIT_INPUT_SCHEMA = z +const TARGET_INPUT_SCHEMA = z .object({ - ids: z.array(z.string().min(1)).min(1), - timeout_ms: z.number().int().positive().optional(), + target: z.string().min(1), }) .strict() -const CLOSE_AGENT_INPUT_SCHEMA = z +const LIST_AGENTS_INPUT_SCHEMA = z .object({ - id: z.string().min(1), + path_prefix: z.string().min(1).optional(), }) .strict() -function nowIso() { - return new Date().toISOString() +function collabBinding(options: ToolExecutionOptions): CollabSessionBinding { + const context = options.experimental_context as ToolExecutionContext | undefined + if (!context?.collab) throw new Error('collaboration tools require an active agent session') + return context.collab } -function buildMissingAgentError(id: string) { - return textResult(`agent not found: ${id}`, true) -} - -function parseMaxAgents() { - const raw = process.env.MEMO_SUBAGENT_MAX_AGENTS?.trim() - if (!raw) return DEFAULT_MAX_AGENTS - const parsed = Number(raw) - if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_AGENTS - return Math.floor(parsed) -} - -function runningAgentCount() { - let count = 0 - for (const record of agents.values()) { - if (record.running) count += 1 - } - return count -} - -function resolveSubagentCommand() { - const explicit = process.env.MEMO_SUBAGENT_COMMAND?.trim() - if (explicit) return explicit - - const distEntry = resolve(getRuntimeCwd(), 'dist/index.js') - if (existsSync(distEntry)) { - return `node ${JSON.stringify(distEntry)} --dangerous` - } - - return 'memo --dangerous' -} - -function isFinalStatus(status: WaitStatus) { - return status !== 'running' -} - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) -} - -function clampWaitTimeout(raw?: number): number | null { - if (raw === undefined) return DEFAULT_WAIT_TIMEOUT_MS - if (raw <= 0) return null - return Math.max(MIN_WAIT_TIMEOUT_MS, Math.min(MAX_WAIT_TIMEOUT_MS, raw)) -} - -function truncateOutput(text: string) { - if (text.length <= MAX_OUTPUT_PREVIEW_CHARS) return text - return `${text.slice(0, MAX_OUTPUT_PREVIEW_CHARS)}\n...[truncated]` -} - -function compactOutput(stdout: string, stderr: string) { - const pieces: string[] = [] - const out = stdout.trim() - const err = stderr.trim() - if (out) pieces.push(out) - if (err) pieces.push(`stderr:\n${err}`) - return truncateOutput(pieces.join('\n\n')) -} - -async function terminateRunningSubmission(record: AgentRecord) { - const running = record.running - if (!running) return - running.interrupted = true - const proc = running.process - - if (proc.exitCode !== null || proc.killed) return - - await new Promise((resolve) => { - let settled = false - const finish = () => { - if (settled) return - settled = true - clearTimeout(killTimer) - proc.off('close', finish) - resolve() - } - const killTimer = setTimeout(() => { - if (proc.exitCode === null) { - try { - proc.kill('SIGKILL') - } catch { - finish() - } - } - }, TERMINATE_GRACE_MS) - proc.on('close', finish) - try { - proc.kill('SIGTERM') - } catch { - finish() - } - }) -} - -function getWaitStatus(id: string): WaitStatus { - const record = agents.get(id) - if (!record) return 'not_found' - return record.status -} - -function getWaitDetail(id: string): WaitDetail { - const record = agents.get(id) - if (!record) { - return { - status: 'not_found', - last_message: null, - last_output: null, - last_error: null, - last_submission_id: null, - updated_at: null, - } - } - return { - status: record.status, - last_message: record.lastMessage, - last_output: record.lastOutput, - last_error: record.lastError, - last_submission_id: record.lastSubmissionId, - updated_at: record.updatedAt, - } -} - -function buildAgentSummary(record: AgentRecord) { +function compactAgent(agent: { + agentId: string + agentPath: string + taskName: string + status: string + lastMessage?: string + error?: string +}) { return { - agent_id: record.id, - status: record.status, - created_at: record.createdAt, - updated_at: record.updatedAt, - last_message: record.lastMessage, - last_submission_id: record.lastSubmissionId, - has_last_output: Boolean(record.lastOutput), - has_last_error: Boolean(record.lastError), + agent_id: agent.agentId, + agent_path: agent.agentPath, + task_name: agent.taskName, + status: agent.status, + last_message: agent.lastMessage ?? null, + error: agent.error ?? null, } } -function finalizeSubmission(params: { - record: AgentRecord - submissionId: string - stdout: string - stderr: string - exitCode: number - interrupted: boolean -}) { - const { record, submissionId, stdout, stderr, exitCode, interrupted } = params - if (!record.running || record.running.id !== submissionId) { - return - } - - record.running = null - record.updatedAt = nowIso() - record.lastOutput = compactOutput(stdout, stderr) || null - record.lastError = null - - if (record.status === 'closed') { - return - } - - if (interrupted) { - record.status = 'errored' - record.lastError = 'interrupted' - record.statusBeforeClose = 'errored' - return - } - - if (exitCode === 0) { - record.status = 'completed' - record.statusBeforeClose = 'completed' - return - } - - record.status = 'errored' - record.lastError = `submission failed with exit code ${exitCode}` - record.statusBeforeClose = 'errored' +function toolFailure(name: string, error: unknown) { + return textResult(`${name} failed: ${error instanceof Error ? error.message : String(error)}`, true) } -async function startSubmission(record: AgentRecord, message: string): Promise { - const maxAgents = parseMaxAgents() - if (runningAgentCount() >= maxAgents) { - throw new Error(`subagent concurrency limit reached (${maxAgents})`) - } - - const submissionId = crypto.randomUUID() - const command = resolveSubagentCommand() - const proc = spawn(command, { - cwd: getRuntimeCwd(), - env: { - ...process.env, - }, - shell: true, - stdio: ['pipe', 'pipe', 'pipe'], - }) - - const stdoutChunks: string[] = [] - const stderrChunks: string[] = [] - - proc.stdout?.setEncoding('utf8') - proc.stderr?.setEncoding('utf8') - proc.stdout?.on('data', (chunk) => stdoutChunks.push(chunk)) - proc.stderr?.on('data', (chunk) => stderrChunks.push(chunk)) - proc.on('error', (err) => { - stderrChunks.push(`[spawn error] ${(err as Error).message}`) - }) - - record.running = { - id: submissionId, - message, - process: proc, - startedAt: nowIso(), - interrupted: false, - } - record.status = 'running' - record.lastMessage = message - record.lastSubmissionId = submissionId - record.updatedAt = nowIso() - - proc.on('close', (code) => { - const exitCode = typeof code === 'number' ? code : -1 - const interrupted = Boolean(record.running?.id === submissionId && record.running.interrupted) - finalizeSubmission({ - record, - submissionId, - stdout: stdoutChunks.join(''), - stderr: stderrChunks.join(''), - exitCode, - interrupted, - }) - }) - - try { - proc.stdin?.write(`${message.trim()}\n`) - } catch { - // ignore short-lived stdin errors; close handler will report final status - } - try { - proc.stdin?.end() - } catch { - // ignore - } - - return submissionId +function nonEmptyMessage(value: string): string { + const message = value.trim() + if (!message) throw new Error('message must not be empty') + return message } -export async function __resetCollabStateForTests() { - const tasks: Promise[] = [] - for (const record of agents.values()) { - tasks.push(terminateRunningSubmission(record)) - } - await Promise.allSettled(tasks) - agents.clear() -} +const collabMetadata = { memo: { supportsParallelToolCalls: true, isMutating: false } } export const spawnAgentTool = tool({ - description: 'Spawn a sub-agent for a well-scoped task and return the agent id.', + description: 'Spawn an in-process sub-agent for a well-scoped task.', inputSchema: SPAWN_AGENT_INPUT_SCHEMA, - metadata: { memo: { supportsParallelToolCalls: false, isMutating: true } }, - - execute: async ({ message }) => { - const trimmed = message.trim() - if (!trimmed) { - return textResult(`spawn_agent failed: message must not be empty`, true) - } - - const id = crypto.randomUUID() - const createdAt = nowIso() - const record: AgentRecord = { - id, - createdAt, - updatedAt: createdAt, - status: 'running', - statusBeforeClose: 'completed', - lastMessage: trimmed, - lastSubmissionId: null, - lastOutput: null, - lastError: null, - running: null, - } - agents.set(id, record) - + metadata: collabMetadata, + execute: async ({ message, task_name, fork_turns }, options) => { try { - const submissionId = await startSubmission(record, trimmed) - return textResult( - JSON.stringify( - { - ...buildAgentSummary(record), - submission_id: submissionId, - }, - null, - 2, - ), - ) - } catch (err) { - agents.delete(id) - return textResult(`spawn_agent failed: ${(err as Error).message}`, true) + const sender = collabBinding(options) + const agent = await sender.control.spawnAgent(sender, { + message: nonEmptyMessage(message), + taskName: task_name, + forkTurns: fork_turns, + }) + return textResult(JSON.stringify(compactAgent(agent), null, 2)) + } catch (error) { + return toolFailure('spawn_agent', error) } }, }) -export const sendInputTool = tool({ - description: 'Send a message to an existing agent.', - inputSchema: SEND_INPUT_INPUT_SCHEMA, - metadata: { memo: { supportsParallelToolCalls: false, isMutating: true } }, - - execute: async ({ id, message, interrupt }) => { - const record = agents.get(id) - if (!record) return buildMissingAgentError(id) - - const trimmed = message.trim() - if (!trimmed) { - return textResult(`send_input failed: message must not be empty`, true) - } - - if (record.status === 'closed') { - return textResult(`send_input failed: agent ${id} is closed; run resume_agent first`, true) - } - - if (record.running) { - if (!interrupt) { - return textResult( - `send_input failed: agent ${id} is busy; set interrupt=true to cancel current submission`, - true, - ) - } - await terminateRunningSubmission(record) - } - +export const sendMessageTool = tool({ + description: 'Queue a message for an agent without starting an idle turn.', + inputSchema: MESSAGE_INPUT_SCHEMA, + metadata: collabMetadata, + execute: async ({ target, message }, options) => { try { - const submissionId = await startSubmission(record, trimmed) - return textResult( - JSON.stringify( - { - agent_id: record.id, - status: record.status, - submission_id: submissionId, - }, - null, - 2, - ), - ) - } catch (err) { - return textResult(`send_input failed: ${(err as Error).message}`, true) + const sender = collabBinding(options) + const receiver = sender.control.sendMessage(sender, target, nonEmptyMessage(message), false) + return textResult(JSON.stringify({ target: receiver.agentPath, queued: true }, null, 2)) + } catch (error) { + return toolFailure('send_message', error) } }, }) -export const resumeAgentTool = tool({ - description: 'Resume a previously closed agent by id.', - inputSchema: RESUME_AGENT_INPUT_SCHEMA, - metadata: { memo: { supportsParallelToolCalls: false, isMutating: true } }, - - execute: async ({ id }) => { - const record = agents.get(id) - if (!record) return buildMissingAgentError(id) - - if (record.status === 'closed') { - record.status = record.statusBeforeClose - record.updatedAt = nowIso() +export const followupTaskTool = tool({ + description: 'Send follow-up work to an agent and start a turn if it is idle.', + inputSchema: MESSAGE_INPUT_SCHEMA, + metadata: collabMetadata, + execute: async ({ target, message }, options) => { + try { + const sender = collabBinding(options) + const receiver = sender.control.sendMessage(sender, target, nonEmptyMessage(message), true) + return textResult(JSON.stringify({ target: receiver.agentPath, triggered: true }, null, 2)) + } catch (error) { + return toolFailure('followup_task', error) } - - return textResult( - JSON.stringify( - { - agent_id: id, - status: record.status, - }, - null, - 2, - ), - ) }, }) -export const waitTool = tool({ - description: 'Wait for agent statuses and return current snapshots.', - inputSchema: WAIT_INPUT_SCHEMA, - metadata: { memo: { supportsParallelToolCalls: false, isMutating: false } }, - - execute: async ({ ids, timeout_ms }) => { - const resolvedTimeout = clampWaitTimeout(timeout_ms) - if (resolvedTimeout === null) { - return textResult(`wait failed: timeout_ms must be greater than zero`, true) - } - - const collectFinals = () => { - const status: Record = {} - const details: Record = {} - for (const id of ids) { - const current = getWaitStatus(id) - if (isFinalStatus(current)) { - status[id] = current - details[id] = getWaitDetail(id) - } - } - return { status, details } - } - - let finalSnapshots = collectFinals() - if (Object.keys(finalSnapshots.status).length > 0) { +export const waitAgentTool = tool({ + description: 'Wait for mailbox activity. Child result content is delivered separately to the next model request.', + inputSchema: WAIT_AGENT_INPUT_SCHEMA, + metadata: collabMetadata, + execute: async ({ timeout_ms }, options) => { + const timeoutMs = timeout_ms ?? DEFAULT_WAIT_TIMEOUT_MS + if (timeoutMs < MIN_WAIT_TIMEOUT_MS || timeoutMs > MAX_WAIT_TIMEOUT_MS) { return textResult( - JSON.stringify( - { - status: finalSnapshots.status, - details: finalSnapshots.details, - timed_out: false, - }, - null, - 2, - ), + `wait_agent failed: timeout_ms must be between ${MIN_WAIT_TIMEOUT_MS} and ${MAX_WAIT_TIMEOUT_MS}`, + true, ) } - - const deadline = Date.now() + resolvedTimeout - while (Date.now() < deadline) { - await sleep(100) - finalSnapshots = collectFinals() - if (Object.keys(finalSnapshots.status).length > 0) { - return textResult( - JSON.stringify( - { - status: finalSnapshots.status, - details: finalSnapshots.details, - timed_out: false, - }, - null, - 2, - ), - ) - } + try { + const sender = collabBinding(options) + const activity = await sender.control.waitForActivity(sender, timeoutMs, options.abortSignal) + const timedOut = activity === 'timeout' + const message = + activity === 'mailbox' + ? 'Wait completed.' + : activity === 'aborted' + ? 'Wait interrupted by cancellation.' + : activity === 'closed' + ? 'Agent mailbox closed.' + : 'Wait timed out.' + return textResult(JSON.stringify({ message, timed_out: timedOut }, null, 2)) + } catch (error) { + return toolFailure('wait_agent', error) } - - return textResult( - JSON.stringify( - { - status: {}, - details: {}, - timed_out: true, - }, - null, - 2, - ), - ) }, }) -export const closeAgentTool = tool({ - description: 'Close an agent and return its last known status.', - inputSchema: CLOSE_AGENT_INPUT_SCHEMA, - metadata: { memo: { supportsParallelToolCalls: false, isMutating: true } }, - - execute: async ({ id }) => { - const record = agents.get(id) - if (!record) return buildMissingAgentError(id) - - if (record.status === 'closed') { +export const interruptAgentTool = tool({ + description: 'Interrupt an agent current turn while keeping the agent available for follow-up work.', + inputSchema: TARGET_INPUT_SCHEMA, + metadata: collabMetadata, + execute: async ({ target }, options) => { + try { + const sender = collabBinding(options) + const receiver = sender.control.interruptAgent(sender, target) return textResult( JSON.stringify( { - agent_id: id, - status: 'closed', + agent_id: receiver.agentId, + agent_path: receiver.agentPath, + previous_status: receiver.status, }, null, 2, ), ) + } catch (error) { + return toolFailure('interrupt_agent', error) } + }, +}) - record.statusBeforeClose = record.running ? 'completed' : record.status - record.status = 'closed' - record.updatedAt = nowIso() - await terminateRunningSubmission(record) - - return textResult( - JSON.stringify( - { - agent_id: id, - status: 'closed', - }, - null, - 2, - ), - ) +export const listAgentsTool = tool({ + description: 'List live sub-agents in the current agent subtree.', + inputSchema: LIST_AGENTS_INPUT_SCHEMA, + metadata: collabMetadata, + execute: async ({ path_prefix }, options) => { + try { + const sender = collabBinding(options) + const agents = sender.control.listAgents(sender, path_prefix).map(compactAgent) + return textResult(JSON.stringify({ agents }, null, 2)) + } catch (error) { + return toolFailure('list_agents', error) + } }, }) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1cae1f4..c913c64 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3,7 +3,9 @@ import type { FinishReason, LanguageModelUsage, ModelMessage, ToolCallPart, Tool import type { ApprovalRequest, ApprovalDecision, ToolActionStatus } from '@memo/core/tools/approval' import type { ToolExecutionContext } from '@memo/core/tools/sdk_tools' import type { SkillIndex } from '@memo/core/skills/skills' +import type { AgentStatus } from '@memo/core/agent/status' export type { ApprovalDecision, ApprovalRequest, ToolActionStatus } from '@memo/core/tools/approval' +export type { AgentStatus } from '@memo/core/agent/status' export type { FinishReason, LanguageModelUsage } from 'ai' /** AI SDK generation result subset returned by CallLLM (all fields are AI SDK types). */ @@ -117,9 +119,11 @@ export type AgentDeps = { /** System prompt loading (uses built-in default if not provided). */ loadPrompt?: () => Promise /** Callback for each assistant output. */ - onAssistantStep?: (content: string, step: number) => void + onAssistantStep?: (content: string, step: number, sessionId?: string) => void /** Callback for each streaming reasoning chunk (thinking trace). */ - onReasoningChunk?: (content: string, step: number) => void + onReasoningChunk?: (content: string, step: number, sessionId?: string) => void + /** Structured sub-agent lifecycle updates for UI and external integrations. */ + onAgentActivity?: (activity: AgentActivity) => void /** Hook collection: inject one-time lifecycle listeners. */ hooks?: AgentHooks /** Middleware list: can register multiple Hook implementations. */ @@ -132,6 +136,17 @@ export type AgentDeps = { skillIndex?: SkillIndex } +export type AgentActivity = { + agentId: string + agentPath: string + taskName: string + parentId?: string + status: AgentStatus + lastMessage?: string + error?: string + updatedAt: string +} + /** Session mode: currently only interactive is supported. */ export type SessionMode = 'interactive' export type ToolPermissionMode = 'none' | 'once' | 'full' @@ -349,6 +364,8 @@ export type HistoryEventType = | 'observation' | 'context_usage' | 'context_compact' + | 'agent_message' + | 'agent_status' | 'final' | 'turn_end' diff --git a/packages/tui/src/app/App.tsx b/packages/tui/src/app/App.tsx index e83c52f..3141cea 100644 --- a/packages/tui/src/app/App.tsx +++ b/packages/tui/src/app/App.tsx @@ -268,6 +268,7 @@ export function App({ dispatchTimeline({ type: 'replace_history', turns: initialHistory.turns, + agents: initialHistory.agents, maxSequence: initialHistory.maxSequence, }) dispatchPlan({ type: 'restore_history', turns: initialHistory.turns }) @@ -290,7 +291,8 @@ export function App({ const deps = useMemo( () => ({ - onAssistantStep: (chunk: string, step: number) => { + onAssistantStep: (chunk: string, step: number, sessionId?: string) => { + if (sessionId && sessionId !== sessionRef.current?.id) return const turn = currentTurnRef.current if (!turn) return visibleUpdateQueue.enqueue({ @@ -298,7 +300,8 @@ export function App({ action: { type: 'assistant_chunk', turn, step, chunk }, }) }, - onReasoningChunk: (chunk: string, step: number) => { + onReasoningChunk: (chunk: string, step: number, sessionId?: string) => { + if (sessionId && sessionId !== sessionRef.current?.id) return const turn = currentTurnRef.current if (!turn) return visibleUpdateQueue.enqueue({ @@ -306,6 +309,12 @@ export function App({ action: { type: 'reasoning_chunk', turn, step, chunk }, }) }, + onAgentActivity: (activity) => { + visibleUpdateQueue.enqueue({ + kind: 'timeline', + action: { type: 'agent_status', activity }, + }) + }, requestApproval: toolPermissionMode === TOOL_PERMISSION_MODES.FULL || toolPermissionMode === TOOL_PERMISSION_MODES.NONE ? undefined @@ -703,6 +712,7 @@ export function App({ dispatchTimeline({ type: 'replace_history', turns: parsed.turns, + agents: parsed.agents, maxSequence: parsed.maxSequence, }) dispatchPlan({ type: 'restore_history', turns: parsed.turns }) @@ -727,7 +737,7 @@ export function App({ const handleCancelRun = useCallback(() => { if (runtime.active?.kind !== 'turn') return - approvalQueue.denyAll() + approvalQueue.denySource(session?.id) dispatchRuntime({ type: 'cancel_requested' }) session?.cancelCurrentTurn?.() }, [approvalQueue, runtime.active, session]) @@ -917,6 +927,7 @@ export function App({ systemMessages={timeline.systemMessages} turns={timeline.turns} historicalTurns={timeline.historicalTurns} + agents={timeline.agents} /> {activePlan ? : null} @@ -967,6 +978,10 @@ export function App({ contextPercent={contextPercent} thinkingOn={thinkingOn} followOutput={followOutput} + activeAgentCount={ + timeline.agents.filter((agent) => agent.status === 'pending_init' || agent.status === 'running') + .length + } /> ) diff --git a/packages/tui/src/app/approvalQueue.test.ts b/packages/tui/src/app/approvalQueue.test.ts index 9b2ad0a..d777cd7 100644 --- a/packages/tui/src/app/approvalQueue.test.ts +++ b/packages/tui/src/app/approvalQueue.test.ts @@ -13,6 +13,10 @@ function request(fingerprint: string): ApprovalRequest { } } +function agentRequest(fingerprint: string, sessionId: string): ApprovalRequest { + return { ...request(fingerprint), sessionId, agentPath: `/root/${sessionId}` } +} + describe('ApprovalQueue', () => { test('shows and resolves approvals one at a time', async () => { const active: Array = [] @@ -53,4 +57,19 @@ describe('ApprovalQueue', () => { assert.strictEqual(await first, 'deny') assert.strictEqual(await second, 'deny') }) + + test('denial only rejects approvals from the same agent session', async () => { + const active: Array = [] + const queue = new ApprovalQueue((item) => active.push(item?.fingerprint ?? null)) + const first = queue.request(agentRequest('first-a', 'a')) + const second = queue.request(agentRequest('second-a', 'a')) + const other = queue.request(agentRequest('first-b', 'b')) + + queue.decide('deny') + assert.strictEqual(await first, 'deny') + assert.strictEqual(await second, 'deny') + assert.deepStrictEqual(active, ['first-a', 'first-b']) + queue.decide('once') + assert.strictEqual(await other, 'once') + }) }) diff --git a/packages/tui/src/app/approvalQueue.ts b/packages/tui/src/app/approvalQueue.ts index e3d09b6..0bbc272 100644 --- a/packages/tui/src/app/approvalQueue.ts +++ b/packages/tui/src/app/approvalQueue.ts @@ -26,10 +26,14 @@ export class ApprovalQueue { current.resolve(decision) if (decision === 'deny') { - const pending = this.pending - this.pending = [] - for (const item of pending) item.resolve('deny') - this.onActiveChange(null) + const source = approvalSource(current.request) + const retained: PendingApproval[] = [] + for (const item of this.pending) { + if (approvalSource(item.request) === source) item.resolve('deny') + else retained.push(item) + } + this.pending = retained + this.advance() return } @@ -47,6 +51,22 @@ export class ApprovalQueue { if (active || pending.length > 0) this.onActiveChange(null) } + denySource(sessionId: string | undefined): void { + if (!sessionId) return + if (this.active?.request.sessionId === sessionId) { + const current = this.active + this.active = null + current.resolve('deny') + } + const retained: PendingApproval[] = [] + for (const item of this.pending) { + if (item.request.sessionId === sessionId) item.resolve('deny') + else retained.push(item) + } + this.pending = retained + if (!this.active) this.advance() + } + private advance(): void { if (this.active) return const next = this.pending.shift() @@ -58,3 +78,7 @@ export class ApprovalQueue { this.onActiveChange(next.request) } } + +function approvalSource(request: ApprovalRequest): string { + return request.sessionId ?? request.agentId ?? 'legacy' +} diff --git a/packages/tui/src/app/runtimeState.test.ts b/packages/tui/src/app/runtimeState.test.ts index c85fe13..7a6e1b3 100644 --- a/packages/tui/src/app/runtimeState.test.ts +++ b/packages/tui/src/app/runtimeState.test.ts @@ -68,7 +68,14 @@ describe('runtimeReducer', () => { assert.strictEqual(runtimeStatus(state), 'cancelling') state = runtimeReducer(state, { type: 'approval_requested', request: approval }) - assert.strictEqual(runtimeStatus(state), 'cancelling') + assert.strictEqual(runtimeStatus(state), 'awaiting_approval') + }) + + test('surfaces background approvals without an active root turn', () => { + let state = runtimeReducer(createInitialRuntimeState(), { type: 'approval_requested', request: approval }) + assert.strictEqual(runtimeStatus(state), 'awaiting_approval') + state = runtimeReducer(state, { type: 'approval_resolved' }) + assert.strictEqual(runtimeStatus(state), 'idle') }) test('ignores stale operation completion', () => { diff --git a/packages/tui/src/app/runtimeState.ts b/packages/tui/src/app/runtimeState.ts index 64d4008..53cba5d 100644 --- a/packages/tui/src/app/runtimeState.ts +++ b/packages/tui/src/app/runtimeState.ts @@ -24,6 +24,7 @@ export type ActiveRuntimeOperation = ActiveTurn | ActiveCompact export type RuntimeState = { active: ActiveRuntimeOperation | null + pendingApproval: ApprovalRequest | null queuedTurns: TurnRequest[] nextOperationId: number } @@ -40,6 +41,7 @@ export type RuntimeAction = export function createInitialRuntimeState(): RuntimeState { return { active: null, + pendingApproval: null, queuedTurns: [], nextOperationId: 1, } @@ -53,20 +55,21 @@ function activateTurn(state: RuntimeState, request: TurnRequest, queuedTurns = s request, stage: 'running', }, + pendingApproval: state.pendingApproval, queuedTurns, nextOperationId: state.nextOperationId + 1, } } export function runtimeStatus(state: RuntimeState): RuntimeStatus { + if (state.pendingApproval) return 'awaiting_approval' if (!state.active) return 'idle' if (state.active.kind === 'compact') return 'compacting' return state.active.stage } export function pendingRuntimeApproval(state: RuntimeState): ApprovalRequest | null { - if (state.active?.kind !== 'turn' || state.active.stage !== 'awaiting_approval') return null - return state.active.approval ?? null + return state.pendingApproval } export function runtimeReducer(state: RuntimeState, action: RuntimeAction): RuntimeState { @@ -85,25 +88,23 @@ export function runtimeReducer(state: RuntimeState, action: RuntimeAction): Runt } case 'approval_requested': - if (state.active?.kind !== 'turn' || state.active.stage === 'cancelling') return state return { ...state, - active: { - ...state.active, - stage: 'awaiting_approval', - approval: action.request, - }, + pendingApproval: action.request, + active: + state.active?.kind === 'turn' && state.active.stage !== 'cancelling' + ? { ...state.active, stage: 'awaiting_approval', approval: action.request } + : state.active, } case 'approval_resolved': - if (state.active?.kind !== 'turn' || state.active.stage !== 'awaiting_approval') return state return { ...state, - active: { - ...state.active, - stage: 'running', - approval: undefined, - }, + pendingApproval: null, + active: + state.active?.kind === 'turn' && state.active.stage === 'awaiting_approval' + ? { ...state.active, stage: 'running', approval: undefined } + : state.active, } case 'cancel_requested': @@ -127,6 +128,7 @@ export function runtimeReducer(state: RuntimeState, action: RuntimeAction): Runt case 'reset': return { active: null, + pendingApproval: null, queuedTurns: [], nextOperationId: state.nextOperationId, } diff --git a/packages/tui/src/features/approval/ApprovalOverlay.tsx b/packages/tui/src/features/approval/ApprovalOverlay.tsx index 2f7d7cc..0be017a 100644 --- a/packages/tui/src/features/approval/ApprovalOverlay.tsx +++ b/packages/tui/src/features/approval/ApprovalOverlay.tsx @@ -56,6 +56,9 @@ export const ApprovalOverlay = memo(function ApprovalOverlay({ request, onDecisi
{request.riskLevel} + {request.agentPath && request.agentPath !== '/root' ? ( + Agent {request.agentPath} + ) : null} {request.toolName} diff --git a/packages/tui/src/features/session/historyParser.test.ts b/packages/tui/src/features/session/historyParser.test.ts index d4aa82d..dfa1d55 100644 --- a/packages/tui/src/features/session/historyParser.test.ts +++ b/packages/tui/src/features/session/historyParser.test.ts @@ -27,6 +27,17 @@ describe('parseHistoryLog', () => { }), line({ type: 'turn_start', turn: 1, content: 'plan this task' }), line({ type: 'assistant', turn: 1, step: 0, content: 'thinking...' }), + line({ + type: 'agent_status', + content: 'review complete', + meta: { + agent_id: 'agent-1', + agent_path: '/root/review', + task_name: 'review', + status: 'completed', + updated_at: '2026-01-01T00:00:00.000Z', + }, + }), line({ type: 'action', turn: 1, @@ -82,6 +93,16 @@ describe('parseHistoryLog', () => { status: TOOL_STATUS.SUCCESS, }, ]) + assert.deepStrictEqual(parsed.agents, [ + { + agentId: 'agent-1', + agentPath: '/root/review', + taskName: 'review', + status: 'completed', + lastMessage: 'review complete', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ]) }) test('injects the latest compaction summary as the first message', () => { diff --git a/packages/tui/src/features/session/historyParser.ts b/packages/tui/src/features/session/historyParser.ts index 6fd42d7..c597238 100644 --- a/packages/tui/src/features/session/historyParser.ts +++ b/packages/tui/src/features/session/historyParser.ts @@ -2,10 +2,11 @@ import { CONTEXT_SUMMARY_PREFIX, parseHistoryLogToSessionDetail, type ChatMessage, + type SessionEventItem, type SessionTurnDetail, type SessionTurnStep, } from '@memo/core' -import { TOOL_STATUS, type StepView, type TurnView } from '../../shared/types' +import { TOOL_STATUS, type AgentActivityView, type StepView, type TurnView } from '../../shared/types' export type ParsedHistoryLog = { summary: string @@ -17,6 +18,42 @@ export type ParsedHistoryLog = { toolPermissionMode?: string thinking?: boolean compactionSummary?: string + agents: AgentActivityView[] +} + +function parseAgentActivities(events: SessionEventItem[]): AgentActivityView[] { + const agents = new Map() + for (const event of events) { + if (event.type !== 'agent_status' || !event.meta) continue + const agentId = event.meta.agent_id + const agentPath = event.meta.agent_path + const taskName = event.meta.task_name + const status = event.meta.status + if (typeof agentId !== 'string' || typeof agentPath !== 'string' || typeof taskName !== 'string') continue + if ( + status !== 'pending_init' && + status !== 'running' && + status !== 'interrupted' && + status !== 'completed' && + status !== 'errored' && + status !== 'shutdown' + ) { + continue + } + const parentId = typeof event.meta.parent_id === 'string' ? event.meta.parent_id : undefined + const error = typeof event.meta.error === 'string' ? event.meta.error : undefined + agents.set(agentId, { + agentId, + agentPath, + taskName, + ...(parentId ? { parentId } : {}), + status, + ...(event.content !== undefined ? { lastMessage: event.content } : {}), + ...(error ? { error } : {}), + updatedAt: typeof event.meta.updated_at === 'string' ? event.meta.updated_at : event.ts, + }) + } + return [...agents.values()].sort((left, right) => left.agentPath.localeCompare(right.agentPath)) } function toAssistantText(turn: SessionTurnDetail): string { @@ -109,5 +146,6 @@ export function parseHistoryLog(raw: string): ParsedHistoryLog { toolPermissionMode: detail.toolPermissionMode, thinking: detail.thinking, compactionSummary: detail.compactionSummary, + agents: parseAgentActivities(detail.events), } } diff --git a/packages/tui/src/features/timeline/Cells.tsx b/packages/tui/src/features/timeline/Cells.tsx index cde1bd6..bc467ee 100644 --- a/packages/tui/src/features/timeline/Cells.tsx +++ b/packages/tui/src/features/timeline/Cells.tsx @@ -2,6 +2,7 @@ import { memo } from 'react' import { Box, Text, useStdout } from 'ink' import { TOOL_STATUS, + type AgentActivityView, type SystemMessage, type StepView, type ToolAction, @@ -91,6 +92,44 @@ export const SystemCell = memo(function SystemCell({ message }: { message: Syste ) }) +function agentStatusPresentation(status: AgentActivityView['status']): { glyph: string; color: string; label: string } { + if (status === 'running') return { glyph: '›', color: 'yellow', label: 'Running' } + if (status === 'completed') return { glyph: '✓', color: 'green', label: 'Completed' } + if (status === 'interrupted') return { glyph: '!', color: 'yellow', label: 'Interrupted' } + if (status === 'errored') return { glyph: '×', color: 'red', label: 'Failed' } + if (status === 'shutdown') return { glyph: '·', color: 'gray', label: 'Shutdown' } + return { glyph: '○', color: 'gray', label: 'Starting' } +} + +export const AgentCell = memo(function AgentCell({ agent }: { agent: AgentActivityView }) { + const { stdout } = useStdout() + const presentation = agentStatusPresentation(agent.status) + const detail = agent.error ?? agent.lastMessage + const preview = detail + ? previewText(detail.replace(/\s+/g, ' ').trim(), { + columns: Math.max(1, (stdout?.columns ?? 80) - 6), + maxLines: 1, + from: 'end', + }).text + : null + return ( + + + {presentation.glyph} + {agent.agentPath} + · {presentation.label} + + {preview ? ( + + + {preview} + + + ) : null} + + ) +}) + const ToolRow = memo(function ToolRow({ tool, input, diff --git a/packages/tui/src/features/timeline/ChatWidget.tsx b/packages/tui/src/features/timeline/ChatWidget.tsx index e7c783b..a7c9e34 100644 --- a/packages/tui/src/features/timeline/ChatWidget.tsx +++ b/packages/tui/src/features/timeline/ChatWidget.tsx @@ -1,7 +1,7 @@ import { memo, useMemo } from 'react' import { Box, Static, Text } from 'ink' -import type { SystemMessage, TurnView } from '../../shared/types' -import { SystemCell, TurnCell } from './Cells' +import type { AgentActivityView, SystemMessage, TurnView } from '../../shared/types' +import { AgentCell, SystemCell, TurnCell } from './Cells' type HeaderInfo = { providerName: string @@ -17,6 +17,7 @@ type ChatWidgetProps = { systemMessages: SystemMessage[] turns: TurnView[] historicalTurns: TurnView[] + agents: AgentActivityView[] } type HeaderStaticItem = { type: 'header'; data: HeaderInfo } @@ -40,6 +41,7 @@ export const ChatWidget = memo(function ChatWidget({ systemMessages, turns, historicalTurns, + agents, }: ChatWidgetProps) { const { inProgressTurn, staticItems } = useMemo(() => { const allTurns = [...historicalTurns, ...turns] @@ -91,6 +93,15 @@ export const ChatWidget = memo(function ChatWidget({ }} + {agents.length > 0 ? ( + + Sub-agents + {agents.map((agent) => ( + + ))} + + ) : null} + {inProgressTurn ? : null} ) diff --git a/packages/tui/src/features/timeline/chatTimeline.test.ts b/packages/tui/src/features/timeline/chatTimeline.test.ts index d494515..c8692b5 100644 --- a/packages/tui/src/features/timeline/chatTimeline.test.ts +++ b/packages/tui/src/features/timeline/chatTimeline.test.ts @@ -40,6 +40,23 @@ describe('chatTimelineReducer', () => { assert.strictEqual(state.systemMessages[0]?.sequence, 1) }) + test('upserts structured agent activity by agent id', () => { + let state = createInitialTimelineState() + const base = { + agentId: 'agent-1', + agentPath: '/root/review', + taskName: 'review', + status: 'running' as const, + updatedAt: '2026-01-01T00:00:00.000Z', + } + state = chatTimelineReducer(state, { type: 'agent_status', activity: base }) + state = chatTimelineReducer(state, { + type: 'agent_status', + activity: { ...base, status: 'completed', lastMessage: 'done' }, + }) + assert.deepStrictEqual(state.agents, [{ ...base, status: 'completed', lastMessage: 'done' }]) + }) + test('updates context prompt tokens at step granularity', () => { let state = createInitialTimelineState() diff --git a/packages/tui/src/features/timeline/chatTimeline.ts b/packages/tui/src/features/timeline/chatTimeline.ts index fa79e2e..d09f97f 100644 --- a/packages/tui/src/features/timeline/chatTimeline.ts +++ b/packages/tui/src/features/timeline/chatTimeline.ts @@ -1,6 +1,7 @@ import type { ContextUsagePhase, LanguageModelUsage, TurnStatus } from '@memo/core' import type { StepView, + AgentActivityView, SystemMessage, SystemMessageTone, ToolAction, @@ -14,11 +15,13 @@ export type ChatTimelineState = { turns: TurnView[] historicalTurns: TurnView[] systemMessages: SystemMessage[] + agents: AgentActivityView[] sequence: number } export type ChatTimelineAction = | { type: 'append_system_message'; title: string; content: string; tone?: SystemMessageTone } + | { type: 'agent_status'; activity: AgentActivityView } | { type: 'turn_start'; turn: number; input: string; promptTokens?: number } | { type: 'context_usage' @@ -57,7 +60,7 @@ export type ChatTimelineAction = /** Thinking trace of the final step (rendered on the last step cell). */ thinking?: string } - | { type: 'replace_history'; turns: TurnView[]; maxSequence: number } + | { type: 'replace_history'; turns: TurnView[]; agents?: AgentActivityView[]; maxSequence: number } | { type: 'clear_current_timeline' } | { type: 'reset_all' } @@ -66,6 +69,7 @@ export function createInitialTimelineState(): ChatTimelineState { turns: [], historicalTurns: [], systemMessages: [], + agents: [], sequence: 0, } } @@ -147,6 +151,15 @@ export function chatTimelineReducer(state: ChatTimelineState, action: ChatTimeli } } + case 'agent_status': { + const existing = state.agents.findIndex((agent) => agent.agentId === action.activity.agentId) + const agents = state.agents.slice() + if (existing === -1) agents.push(action.activity) + else agents[existing] = action.activity + agents.sort((left, right) => left.agentPath.localeCompare(right.agentPath)) + return { ...state, agents } + } + case 'turn_start': { const updated = upsertTurn(state, action.turn, (turnView) => ({ ...turnView, @@ -319,6 +332,7 @@ export function chatTimelineReducer(state: ChatTimelineState, action: ChatTimeli return { ...state, historicalTurns: action.turns, + agents: action.agents ?? [], sequence: Math.max(state.sequence, action.maxSequence), } } @@ -328,6 +342,7 @@ export function chatTimelineReducer(state: ChatTimelineState, action: ChatTimeli ...state, turns: [], systemMessages: [], + agents: [], } } diff --git a/packages/tui/src/shared/types/index.ts b/packages/tui/src/shared/types/index.ts index 563a105..00e3a48 100644 --- a/packages/tui/src/shared/types/index.ts +++ b/packages/tui/src/shared/types/index.ts @@ -1,4 +1,4 @@ -import type { LanguageModelUsage, TurnStatus } from '@memo/core' +import type { AgentStatus, LanguageModelUsage, TurnStatus } from '@memo/core' export const TOOL_STATUS = { PENDING: 'pending', @@ -63,6 +63,17 @@ export type SystemMessage = { tone?: SystemMessageTone } +export type AgentActivityView = { + agentId: string + agentPath: string + taskName: string + parentId?: string + status: AgentStatus + lastMessage?: string + error?: string + updatedAt: string +} + export type TimelineItem = | { type: 'system'; sequence: number; message: SystemMessage } | { type: 'turn'; sequence: number; turn: TurnView } diff --git a/packages/tui/src/shared/ui/Footer.tsx b/packages/tui/src/shared/ui/Footer.tsx index 735134d..f850cd8 100644 --- a/packages/tui/src/shared/ui/Footer.tsx +++ b/packages/tui/src/shared/ui/Footer.tsx @@ -9,6 +9,7 @@ type FooterProps = { /** Thinking mode state (toggled with Tab on an empty input). */ thinkingOn?: boolean followOutput?: boolean + activeAgentCount?: number } export const Footer = memo(function Footer({ @@ -17,6 +18,7 @@ export const Footer = memo(function Footer({ contextPercent, thinkingOn = true, followOutput = true, + activeAgentCount = 0, }: FooterProps) { const { stdout } = useStdout() const compact = (stdout.columns ?? 80) < 72 @@ -43,12 +45,13 @@ export const Footer = memo(function Footer({ : compact ? 'Enter send • /help' : 'Enter send • Shift+Enter newline • Tab thinking • Esc×2 cancel • /help' - const metrics = + const baseMetrics = compact && queuedCount > 0 ? `ctx:${Math.round(contextPercent)}%` : compact ? `think:${thinkingOn ? 'on' : 'off'} • ctx:${context}` : `thinking: ${thinkingOn ? 'on' : 'off'} • context: ${context}` + const metrics = activeAgentCount > 0 ? `${baseMetrics} • agents:${activeAgentCount}` : baseMetrics const queueText = queuedCount > 0 ? `${queuedCount} queued` : null return ( From 03eb5c625bc03749ab8290910ff96cc6dab27d5e Mon Sep 17 00:00:00 2001 From: mcell Date: Sun, 2 Aug 2026 15:40:24 +0800 Subject: [PATCH 08/13] fix(tui): show only active sub-agent status --- packages/core/src/agent/control.ts | 18 ++++++++- packages/core/src/agent/registry.ts | 6 ++- packages/core/src/tools/tools/collab.test.ts | 19 ++++++++- packages/core/src/types.ts | 1 + packages/tui/src/app/App.tsx | 15 ++++--- .../src/features/agents/AgentStatusList.tsx | 31 +++++++++++++++ .../features/session/historyParser.test.ts | 2 + .../tui/src/features/session/historyParser.ts | 2 + packages/tui/src/features/timeline/Cells.tsx | 39 ------------------- .../tui/src/features/timeline/ChatWidget.tsx | 15 +------ .../features/timeline/chatTimeline.test.ts | 23 ++++++++++- .../tui/src/features/timeline/chatTimeline.ts | 4 ++ packages/tui/src/shared/types/index.ts | 1 + packages/tui/src/shared/ui/Footer.tsx | 5 +-- 14 files changed, 113 insertions(+), 68 deletions(-) create mode 100644 packages/tui/src/features/agents/AgentStatusList.tsx diff --git a/packages/core/src/agent/control.ts b/packages/core/src/agent/control.ts index b661034..4848cdf 100644 --- a/packages/core/src/agent/control.ts +++ b/packages/core/src/agent/control.ts @@ -222,10 +222,22 @@ export class AgentControl { const current = this.registry.getById(agentId) if (!current) return const nextStatus = deriveAgentStatusFromEvent(current, event) - const updated = this.registry.update(agentId, nextStatus) + const contextPercent = + event.type === 'context_usage' && typeof event.meta?.usage_percent === 'number' + ? event.meta.usage_percent + : undefined + const updated = this.registry.update(agentId, { + ...nextStatus, + ...(contextPercent === undefined ? {} : { contextPercent }), + }) if (!updated) return - if (event.type === 'turn_start' || event.type === 'turn_end' || event.type === 'session_end') { + if ( + event.type === 'turn_start' || + event.type === 'context_usage' || + event.type === 'turn_end' || + event.type === 'session_end' + ) { await this.publishActivity(updated) } @@ -259,6 +271,7 @@ export class AgentControl { taskName: metadata.taskName, parentId: metadata.parentId, status: metadata.status, + contextPercent: metadata.contextPercent, lastMessage: metadata.lastMessage, error: metadata.error, updatedAt: metadata.updatedAt, @@ -279,6 +292,7 @@ export class AgentControl { task_name: activity.taskName, parent_id: activity.parentId, status: activity.status, + context_percent: activity.contextPercent, error: activity.error, updated_at: activity.updatedAt, }, diff --git a/packages/core/src/agent/registry.ts b/packages/core/src/agent/registry.ts index f370ec2..488ffb7 100644 --- a/packages/core/src/agent/registry.ts +++ b/packages/core/src/agent/registry.ts @@ -8,6 +8,7 @@ export type AgentMetadata = AgentStatusSnapshot & { taskName: string parentId?: string parentPath?: string + contextPercent?: number updatedAt: string } @@ -104,7 +105,10 @@ export class AgentRegistry { return this.getByPath(joinAgentPath(senderPath, target)) ?? this.getByPath(`${ROOT_AGENT_PATH}/${target}`) } - update(agentId: string, update: Partial): AgentMetadata | undefined { + update( + agentId: string, + update: Partial & { contextPercent?: number }, + ): AgentMetadata | undefined { const current = this.byId.get(agentId) if (!current) return undefined const next = { ...current, ...update, updatedAt: new Date().toISOString() } diff --git a/packages/core/src/tools/tools/collab.test.ts b/packages/core/src/tools/tools/collab.test.ts index f968d01..64d71bd 100644 --- a/packages/core/src/tools/tools/collab.test.ts +++ b/packages/core/src/tools/tools/collab.test.ts @@ -1,7 +1,14 @@ import { describe, expect, test } from 'vitest' import type { Tool, ToolExecutionOptions } from 'ai' import type { ToolResultOutput } from '@ai-sdk/provider-utils' -import { createAgentSession, createTokenCounter, type CallLLM, type AgentSessionDeps, type LLMResult } from '@memo/core' +import { + createAgentSession, + createTokenCounter, + type AgentActivity, + type AgentSessionDeps, + type CallLLM, + type LLMResult, +} from '@memo/core' import type { ToolExecutionContext } from '@memo/core/tools/sdk_tools' import { emptyUsage } from '@memo/core/utils/usage' import { @@ -60,7 +67,10 @@ async function createHarness(callLLM?: CallLLM, overrides: Partial { test('spawns a stateful child and delivers completion through the parent mailbox', async () => { - const harness = await createHarness() + const activities: AgentActivity[] = [] + const harness = await createHarness(undefined, { + onAgentActivity: (activity) => activities.push(activity), + }) try { const spawnedResult = await runTool( spawnAgentTool, @@ -78,6 +88,11 @@ describe('in-process collab tools', () => { expect(agents).toHaveLength(1) expect(agents[0]).toMatchObject({ agent_path: '/root/review', status: 'completed' }) expect(harness.context.collab?.inputQueue.drainAll()[0]?.content).toContain('agent_completion') + expect( + activities.some( + (activity) => activity.status === 'running' && typeof activity.contextPercent === 'number', + ), + ).toBe(true) } finally { await harness.session.close() } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c913c64..00f99bc 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -142,6 +142,7 @@ export type AgentActivity = { taskName: string parentId?: string status: AgentStatus + contextPercent?: number lastMessage?: string error?: string updatedAt: string diff --git a/packages/tui/src/app/App.tsx b/packages/tui/src/app/App.tsx index 3141cea..878a5d1 100644 --- a/packages/tui/src/app/App.tsx +++ b/packages/tui/src/app/App.tsx @@ -29,6 +29,7 @@ import { VisibleUpdateQueue, type VisibleUpdate } from './visibleUpdateQueue' import { ChatWidget } from '../features/timeline/ChatWidget' import { Composer } from '../features/composer/Composer' import { Footer } from '../shared/ui/Footer' +import { AgentStatusList } from '../features/agents/AgentStatusList' import { ApprovalOverlay } from '../features/approval/ApprovalOverlay' import { McpActivationOverlay } from '../features/mcp/McpActivationOverlay' import { notifyApprovalRequested } from '../features/approval/approvalNotification' @@ -36,7 +37,11 @@ import { PlanPanel } from '../features/plan/PlanPanel' import { planStateReducer } from '../features/plan/planState' import { SetupWizard } from '../features/setup/SetupWizard' import { parseHistoryLog } from '../features/session/historyParser' -import { chatTimelineReducer, createInitialTimelineState } from '../features/timeline/chatTimeline' +import { + activeAgentActivities, + chatTimelineReducer, + createInitialTimelineState, +} from '../features/timeline/chatTimeline' import { calculateContextPercent, inferParallelToolStatuses, inferToolStatus } from '../shared/lib/utils' import { checkForUpdate, findLocalPackageInfoSync } from '../shared/lib/version' import type { SessionHistoryEntry } from '../features/session/sessionHistory' @@ -879,6 +884,7 @@ export function App({ }, [pendingHistoryMessages, session]) const contextPercent = calculateContextPercent(currentContextTokens, contextLimit) + const activeAgents = useMemo(() => activeAgentActivities(timeline.agents), [timeline.agents]) const chatHeader = useMemo( () => ({ providerName: currentProvider, @@ -927,7 +933,6 @@ export function App({ systemMessages={timeline.systemMessages} turns={timeline.turns} historicalTurns={timeline.historicalTurns} - agents={timeline.agents} /> {activePlan ? : null} @@ -978,11 +983,9 @@ export function App({ contextPercent={contextPercent} thinkingOn={thinkingOn} followOutput={followOutput} - activeAgentCount={ - timeline.agents.filter((agent) => agent.status === 'pending_init' || agent.status === 'running') - .length - } /> + + ) } diff --git a/packages/tui/src/features/agents/AgentStatusList.tsx b/packages/tui/src/features/agents/AgentStatusList.tsx new file mode 100644 index 0000000..650544a --- /dev/null +++ b/packages/tui/src/features/agents/AgentStatusList.tsx @@ -0,0 +1,31 @@ +import { memo } from 'react' +import { Box, Text } from 'ink' +import type { AgentActivityView } from '../../shared/types' + +export const AgentStatusList = memo(function AgentStatusList({ agents }: { agents: AgentActivityView[] }) { + if (agents.length === 0) return null + + return ( + + Sub-agents + {agents.map((agent) => { + const context = + agent.contextPercent === undefined ? 'context --' : `context ${agent.contextPercent.toFixed(1)}%` + const status = agent.status === 'pending_init' ? 'Starting' : 'Working' + return ( + + + + + {agent.taskName} + + + + {context} · {status} + + + ) + })} + + ) +}) diff --git a/packages/tui/src/features/session/historyParser.test.ts b/packages/tui/src/features/session/historyParser.test.ts index dfa1d55..536a95e 100644 --- a/packages/tui/src/features/session/historyParser.test.ts +++ b/packages/tui/src/features/session/historyParser.test.ts @@ -35,6 +35,7 @@ describe('parseHistoryLog', () => { agent_path: '/root/review', task_name: 'review', status: 'completed', + context_percent: 12.5, updated_at: '2026-01-01T00:00:00.000Z', }, }), @@ -99,6 +100,7 @@ describe('parseHistoryLog', () => { agentPath: '/root/review', taskName: 'review', status: 'completed', + contextPercent: 12.5, lastMessage: 'review complete', updatedAt: '2026-01-01T00:00:00.000Z', }, diff --git a/packages/tui/src/features/session/historyParser.ts b/packages/tui/src/features/session/historyParser.ts index c597238..ac95ac1 100644 --- a/packages/tui/src/features/session/historyParser.ts +++ b/packages/tui/src/features/session/historyParser.ts @@ -41,6 +41,7 @@ function parseAgentActivities(events: SessionEventItem[]): AgentActivityView[] { continue } const parentId = typeof event.meta.parent_id === 'string' ? event.meta.parent_id : undefined + const contextPercent = typeof event.meta.context_percent === 'number' ? event.meta.context_percent : undefined const error = typeof event.meta.error === 'string' ? event.meta.error : undefined agents.set(agentId, { agentId, @@ -48,6 +49,7 @@ function parseAgentActivities(events: SessionEventItem[]): AgentActivityView[] { taskName, ...(parentId ? { parentId } : {}), status, + ...(contextPercent === undefined ? {} : { contextPercent }), ...(event.content !== undefined ? { lastMessage: event.content } : {}), ...(error ? { error } : {}), updatedAt: typeof event.meta.updated_at === 'string' ? event.meta.updated_at : event.ts, diff --git a/packages/tui/src/features/timeline/Cells.tsx b/packages/tui/src/features/timeline/Cells.tsx index bc467ee..cde1bd6 100644 --- a/packages/tui/src/features/timeline/Cells.tsx +++ b/packages/tui/src/features/timeline/Cells.tsx @@ -2,7 +2,6 @@ import { memo } from 'react' import { Box, Text, useStdout } from 'ink' import { TOOL_STATUS, - type AgentActivityView, type SystemMessage, type StepView, type ToolAction, @@ -92,44 +91,6 @@ export const SystemCell = memo(function SystemCell({ message }: { message: Syste ) }) -function agentStatusPresentation(status: AgentActivityView['status']): { glyph: string; color: string; label: string } { - if (status === 'running') return { glyph: '›', color: 'yellow', label: 'Running' } - if (status === 'completed') return { glyph: '✓', color: 'green', label: 'Completed' } - if (status === 'interrupted') return { glyph: '!', color: 'yellow', label: 'Interrupted' } - if (status === 'errored') return { glyph: '×', color: 'red', label: 'Failed' } - if (status === 'shutdown') return { glyph: '·', color: 'gray', label: 'Shutdown' } - return { glyph: '○', color: 'gray', label: 'Starting' } -} - -export const AgentCell = memo(function AgentCell({ agent }: { agent: AgentActivityView }) { - const { stdout } = useStdout() - const presentation = agentStatusPresentation(agent.status) - const detail = agent.error ?? agent.lastMessage - const preview = detail - ? previewText(detail.replace(/\s+/g, ' ').trim(), { - columns: Math.max(1, (stdout?.columns ?? 80) - 6), - maxLines: 1, - from: 'end', - }).text - : null - return ( - - - {presentation.glyph} - {agent.agentPath} - · {presentation.label} - - {preview ? ( - - - {preview} - - - ) : null} - - ) -}) - const ToolRow = memo(function ToolRow({ tool, input, diff --git a/packages/tui/src/features/timeline/ChatWidget.tsx b/packages/tui/src/features/timeline/ChatWidget.tsx index a7c9e34..e7c783b 100644 --- a/packages/tui/src/features/timeline/ChatWidget.tsx +++ b/packages/tui/src/features/timeline/ChatWidget.tsx @@ -1,7 +1,7 @@ import { memo, useMemo } from 'react' import { Box, Static, Text } from 'ink' -import type { AgentActivityView, SystemMessage, TurnView } from '../../shared/types' -import { AgentCell, SystemCell, TurnCell } from './Cells' +import type { SystemMessage, TurnView } from '../../shared/types' +import { SystemCell, TurnCell } from './Cells' type HeaderInfo = { providerName: string @@ -17,7 +17,6 @@ type ChatWidgetProps = { systemMessages: SystemMessage[] turns: TurnView[] historicalTurns: TurnView[] - agents: AgentActivityView[] } type HeaderStaticItem = { type: 'header'; data: HeaderInfo } @@ -41,7 +40,6 @@ export const ChatWidget = memo(function ChatWidget({ systemMessages, turns, historicalTurns, - agents, }: ChatWidgetProps) { const { inProgressTurn, staticItems } = useMemo(() => { const allTurns = [...historicalTurns, ...turns] @@ -93,15 +91,6 @@ export const ChatWidget = memo(function ChatWidget({ }} - {agents.length > 0 ? ( - - Sub-agents - {agents.map((agent) => ( - - ))} - - ) : null} - {inProgressTurn ? : null} ) diff --git a/packages/tui/src/features/timeline/chatTimeline.test.ts b/packages/tui/src/features/timeline/chatTimeline.test.ts index c8692b5..f96c59e 100644 --- a/packages/tui/src/features/timeline/chatTimeline.test.ts +++ b/packages/tui/src/features/timeline/chatTimeline.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert' import { describe, test } from 'vitest' import type { LanguageModelUsage } from '@memo/core' -import { chatTimelineReducer, createInitialTimelineState } from './chatTimeline' +import { activeAgentActivities, chatTimelineReducer, createInitialTimelineState } from './chatTimeline' describe('chatTimelineReducer', () => { test('creates turn and appends chunks', () => { @@ -55,6 +55,27 @@ describe('chatTimelineReducer', () => { activity: { ...base, status: 'completed', lastMessage: 'done' }, }) assert.deepStrictEqual(state.agents, [{ ...base, status: 'completed', lastMessage: 'done' }]) + assert.deepStrictEqual(activeAgentActivities(state.agents), []) + }) + + test('only exposes agents that are starting or running', () => { + const base = { + agentId: 'agent-1', + agentPath: '/root/review', + taskName: 'review', + updatedAt: '2026-01-01T00:00:00.000Z', + } + const agents = activeAgentActivities([ + { ...base, status: 'pending_init' }, + { ...base, agentId: 'agent-2', status: 'running', contextPercent: 12.5 }, + { ...base, agentId: 'agent-3', status: 'completed' }, + { ...base, agentId: 'agent-4', status: 'errored' }, + ]) + + assert.deepStrictEqual( + agents.map((agent) => agent.agentId), + ['agent-1', 'agent-2'], + ) }) test('updates context prompt tokens at step granularity', () => { diff --git a/packages/tui/src/features/timeline/chatTimeline.ts b/packages/tui/src/features/timeline/chatTimeline.ts index d09f97f..f88cc61 100644 --- a/packages/tui/src/features/timeline/chatTimeline.ts +++ b/packages/tui/src/features/timeline/chatTimeline.ts @@ -74,6 +74,10 @@ export function createInitialTimelineState(): ChatTimelineState { } } +export function activeAgentActivities(agents: AgentActivityView[]): AgentActivityView[] { + return agents.filter((agent) => agent.status === 'pending_init' || agent.status === 'running') +} + function createEmptyTurn(index: number, sequence: number): TurnView { return { index, diff --git a/packages/tui/src/shared/types/index.ts b/packages/tui/src/shared/types/index.ts index 00e3a48..cb5c666 100644 --- a/packages/tui/src/shared/types/index.ts +++ b/packages/tui/src/shared/types/index.ts @@ -69,6 +69,7 @@ export type AgentActivityView = { taskName: string parentId?: string status: AgentStatus + contextPercent?: number lastMessage?: string error?: string updatedAt: string diff --git a/packages/tui/src/shared/ui/Footer.tsx b/packages/tui/src/shared/ui/Footer.tsx index f850cd8..735134d 100644 --- a/packages/tui/src/shared/ui/Footer.tsx +++ b/packages/tui/src/shared/ui/Footer.tsx @@ -9,7 +9,6 @@ type FooterProps = { /** Thinking mode state (toggled with Tab on an empty input). */ thinkingOn?: boolean followOutput?: boolean - activeAgentCount?: number } export const Footer = memo(function Footer({ @@ -18,7 +17,6 @@ export const Footer = memo(function Footer({ contextPercent, thinkingOn = true, followOutput = true, - activeAgentCount = 0, }: FooterProps) { const { stdout } = useStdout() const compact = (stdout.columns ?? 80) < 72 @@ -45,13 +43,12 @@ export const Footer = memo(function Footer({ : compact ? 'Enter send • /help' : 'Enter send • Shift+Enter newline • Tab thinking • Esc×2 cancel • /help' - const baseMetrics = + const metrics = compact && queuedCount > 0 ? `ctx:${Math.round(contextPercent)}%` : compact ? `think:${thinkingOn ? 'on' : 'off'} • ctx:${context}` : `thinking: ${thinkingOn ? 'on' : 'off'} • context: ${context}` - const metrics = activeAgentCount > 0 ? `${baseMetrics} • agents:${activeAgentCount}` : baseMetrics const queueText = queuedCount > 0 ? `${queuedCount} queued` : null return ( From 6a49f9f3eef500507f157a81d4806935cc3e5179 Mon Sep 17 00:00:00 2001 From: mcell Date: Sun, 2 Aug 2026 15:43:59 +0800 Subject: [PATCH 09/13] fix(core): allow safe absolute apply_patch paths --- .../core/src/tools/tools/apply_patch.test.ts | 16 +++++++++++++--- packages/core/src/tools/tools/apply_patch.ts | 11 ++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/core/src/tools/tools/apply_patch.test.ts b/packages/core/src/tools/tools/apply_patch.test.ts index 1c4c19c..c41e1a0 100644 --- a/packages/core/src/tools/tools/apply_patch.test.ts +++ b/packages/core/src/tools/tools/apply_patch.test.ts @@ -253,11 +253,21 @@ describe('apply_patch tool (structured patch)', () => { assertPatchError(result, 'Unexpected line found in update hunk') }) - test('rejects absolute file paths', async () => { + test('allows absolute file paths inside writable roots', async () => { + const absolutePath = join(tempDir, 'absolute.txt') const result = await executePatch( - ['*** Begin Patch', `*** Add File: ${join(tempDir, 'absolute.txt')}`, '+x', '*** End Patch'].join('\n'), + ['*** Begin Patch', `*** Add File: ${absolutePath}`, '+x', '*** End Patch'].join('\n'), ) - assertPatchError(result, 'File references must be relative, NEVER ABSOLUTE') + assertPatchOk(result) + assert.strictEqual(await readText(absolutePath), 'x\n') + }) + + test('rejects absolute file paths outside writable roots', async () => { + const outsidePath = join(tempDir, '..', 'outside-absolute.txt') + const result = await executePatch( + ['*** Begin Patch', `*** Add File: ${outsidePath}`, '+x', '*** End Patch'].join('\n'), + ) + assertPatchError(result, 'sandbox write denied') }) test('rejects empty file paths in headers', async () => { diff --git a/packages/core/src/tools/tools/apply_patch.ts b/packages/core/src/tools/tools/apply_patch.ts index c2f73bf..ccba56f 100644 --- a/packages/core/src/tools/tools/apply_patch.ts +++ b/packages/core/src/tools/tools/apply_patch.ts @@ -36,6 +36,9 @@ Each operation starts with one of three headers: *** Add File: - create a new file. Every following line is a + line (the initial contents). *** Delete File: - remove an existing file. Nothing follows. *** Update File: - patch an existing file in place (optionally with a rename). + is resolved relative to the current working directory; an absolute path +(e.g. \`*** Update File: /abs/path/to/file.ts\`) is also accepted, but only if it +stays within the allowed writable directories - otherwise the patch is denied. May be immediately followed by *** Move to: if you want to rename the file. Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header). @@ -88,7 +91,8 @@ It is important to remember: - You must include a header with your intended action (Add/Delete/Update) - You must prefix new lines with \`+\` even when creating a new file -- File references can only be relative, NEVER ABSOLUTE. +- File paths are relative to the current working directory, or absolute + (e.g. \`*** Update File: /abs/path/to/file.ts\`) within the allowed writable directories. ` type AddFileHunk = { @@ -166,9 +170,6 @@ function parsePathFromHeader(path: string, lineNumber: number): string { if (!value) { invalidHunk('path cannot be empty', lineNumber) } - if (isAbsolute(value)) { - invalidHunk(`File references must be relative, NEVER ABSOLUTE: ${value}`, lineNumber) - } return value } @@ -552,7 +553,7 @@ function deriveNewContentsFromChunks(originalContents: string, displayPath: stri } function resolvePatchPath(cwd: string, patchPath: string): string { - return normalizePath(join(cwd, patchPath)) + return normalizePath(isAbsolute(patchPath) ? patchPath : join(cwd, patchPath)) } async function canonicalWritePath(absPath: string): Promise { From 0532950c6a4de87006989df42cb9a3dbb16677b7 Mon Sep 17 00:00:00 2001 From: mcell Date: Sun, 2 Aug 2026 15:44:24 +0800 Subject: [PATCH 10/13] docs: refresh project overview --- README.md | 23 ++++++++++++----------- README.zh.md | 25 +++++++++++++------------ 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 1091a15..5fbdd71 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ This project grew from a small demo into an indispensable "productivity assistan | **Terminal Mode** | Smooth TUI in terminal | | **Smart Context Management** | Auto-compact long session context, configurable threshold, millisecond-level token estimation | | **Skills System** | Skills integration, auto-discover `SKILL.md`, activate by scenario | -| **Deep MCP Integration** | Local/remote MCP servers, OAuth login, runtime dynamic switching | +| **Deep MCP Integration** | Local/remote MCP servers, OAuth login, per-session dynamic switching | | **Enterprise-Grade Security** | Tool approval system (auto-approve/manual-approve), supports once/session/deny modes | | **OpenAI Compatible** | Works with any OpenAI-compatible API, flexible multi-Provider configuration | @@ -103,19 +103,18 @@ First run will guide you through Provider/Model setup and save config to `~/.mem ``` memo-code/ ├── packages/ -│ ├── core/ # Core logic: Session state machine, Config handling -│ ├── tools/ # Tool routing, MCP Client management, built-in tools (exec_command, read_text_file, apply_patch...) -│ ├── tui/ # Terminal runtime: CLI entry, interactive TUI -└── docs/ # Technical documentation +│ ├── core/ # Agent engine: session state machine, LLM/tool loop, built-in tools, MCP client, skills +│ └── tui/ # Terminal runtime: CLI entry, interactive TUI (Ink) +└── site/ # Documentation website (Next.js, static export) ``` **Technical Highlights:** -- **Architecture**: Clean Core / Tools / TUI separation, state-machine driven session management -- **Testing**: Core + Tools coverage > 70%, complete unit + integration tests +- **Architecture**: Core engine with integrated tool routing, thin TUI on top, state-machine driven session management +- **Testing**: Unit + integration tests, coverage threshold ≥70% - **Protocol**: Native MCP (Model Context Protocol) support, can integrate any MCP tool server - **Token Estimation**: Real-time context monitoring based on tiktoken, configurable auto-compaction strategy -- **Distribution**: npm package with hot-reloading without perception +- **Distribution**: Published to npm with version-driven auto releases via CI --- @@ -125,9 +124,11 @@ memo-code/ - `apply_patch` - Structured patch editing (`*** Begin Patch`/`*** End Patch`) - `read_text_file` / `read_media_file` / `read_files` / `write_file` / `edit_file` / `list_directory` / `search_files` - Filesystem read/write/search - `webfetch` - Paged web fetching with markdown extraction and policy guards -- MCP resource access - `list_mcp_resources`, `read_mcp_resource` +- MCP resource access - `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource` - `update_plan` - Structured task progress management +- `read_skill` - Load skill instructions on demand - `get_memory` - Persistent memory reading +- Agent collaboration - `spawn_agent` / `send_message` / `followup_task` / `wait_agent` / `interrupt_agent` / `list_agents` --- @@ -148,8 +149,8 @@ base_url = "https://api.openai.com/v1" command = "npx" args = ["-y", "@modelcontextprotocol/server-github"] -# Skills -active_skills = ["./skills/doc-writing/SKILL.md"] +# Skills (absolute paths to SKILL.md files) +active_skills = ["/path/to/skills/doc-writing/SKILL.md"] ``` --- diff --git a/README.zh.md b/README.zh.md index 131565d..b64b2bd 100644 --- a/README.zh.md +++ b/README.zh.md @@ -52,7 +52,7 @@ Memo 诞生于一个简单的想法:**我想验证一个最简单的 Agent 是 | **终端模式** | 终端 TUI 交互流畅 | | **智能上下文管理** | 自动压缩长会话上下文,支持配置压缩阈值,毫秒级 token 估算 | | **Skills 技能系统** | Skills 技能集成,自动发现 `SKILL.md`,支持按场景激活 | -| **MCP 深度集成** | 支持本地/远程 MCP 服务器,OAuth 登录,运行时动态切换 | +| **MCP 深度集成** | 支持本地/远程 MCP 服务器,OAuth 登录,会话级动态切换 | | **企业级安全** | 工具分级审批机制(自动批准/手动批准),支持单次/会话/拒绝三种模式 | | **OpenAI 兼容** | 支持任意 OpenAI 兼容 API,灵活配置多 Provider 切换 | @@ -93,29 +93,30 @@ memo ``` memo-code/ ├── packages/ -│ ├── core/ # 核心逻辑:Session 状态机、Config 处理 -│ ├── tools/ # Tool 路由、MCP Client管理、内置工具实现(exec_command, read_text_file, apply_patch...) -│ ├── tui/ # 终端运行时:CLI 入口、交互式 TUI -└── docs/ # 技术文档 +│ ├── core/ # 核心引擎:Session 状态机、LLM/工具循环、内置工具、MCP 客户端、技能 +│ └── tui/ # 终端运行时:CLI 入口、交互式 TUI (Ink) +└── site/ # 文档网站(Next.js 静态导出) ``` **技术亮点:** -- **架构**:清晰的 Core / Tools / TUI 分层,状态机驱动会话管理 -- **测试**:Core + Tools 覆盖率 > 70%,完整的单元 + 集成测试 +- **架构**:核心引擎内置工具路由,TUI 薄壳,状态机驱动会话管理 +- **测试**:单元 + 集成测试,覆盖率门槛 ≥70% - **协议**:原生支持 MCP (Model Context Protocol),可接入任意 MCP 工具服务器 - **Token 估算**:基于 tiktoken 的实时上下文监控,支持可配置的自动压缩策略 -- **分发**:npm 包热加载无感知 +- **分发**:发布至 npm,CI 版本驱动自动发版 ## 🔧 内置工具 - `exec_command` / `write_stdin` - 执行 Shell 命令 -- `apply_patch` - 字符串级代码编辑(单文件/批量) +- `apply_patch` - 结构化补丁编辑(`*** Begin Patch`/`*** End Patch`) - `read_text_file` / `read_media_file` / `read_files` / `write_file` / `edit_file` / `list_directory` / `search_files` - 文件系统读写与检索 - `webfetch` - 支持分页、Markdown 提取与策略防护的网页抓取 -- MCP 资源访问 - `list_mcp_resources`, `read_mcp_resource` +- MCP 资源访问 - `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource` - `update_plan` - 结构化任务进度管理 +- `read_skill` - 按需加载技能指令 - `get_memory` - 持久化记忆读取 +- Agent 协作 - `spawn_agent` / `send_message` / `followup_task` / `wait_agent` / `interrupt_agent` / `list_agents` ## ⚙️ 配置示例 @@ -134,8 +135,8 @@ base_url = "https://api.openai.com/v1" command = "npx" args = ["-y", "@modelcontextprotocol/server-github"] -# Skills -active_skills = ["./skills/doc-writing/SKILL.md"] +# Skills(SKILL.md 的绝对路径) +active_skills = ["/path/to/skills/doc-writing/SKILL.md"] ``` --- From 97fefac82e608a13532df67a329095ee632222c1 Mon Sep 17 00:00:00 2001 From: mcell Date: Sun, 2 Aug 2026 15:56:33 +0800 Subject: [PATCH 11/13] fix(core): address security audit findings - deps: minimatch 10.2.6, brace-expansion 5.0.9, picomatch 2.3.2, undici 6.28.0 - oauth: escape callback error text; use PowerShell for Windows URL opening - exec: 16 MiB output cap with process kill; free completed session buffers - fs: 50 MiB read cap, 10 MiB media cap - history: owner-only session file permissions --- package.json | 2 +- packages/core/package.json | 3 +- .../src/features/history/history_sink.test.ts | 10 +- .../core/src/features/history/history_sink.ts | 4 +- .../tools/router/mcp/oauth.runtime.test.ts | 61 ++ packages/core/src/tools/router/mcp/oauth.ts | 20 +- .../core/src/tools/tools/exec_runtime.test.ts | 10 + packages/core/src/tools/tools/exec_runtime.ts | 15 + .../core/src/tools/tools/filesystem/lib.ts | 11 +- .../src/tools/tools/filesystem_tools.test.ts | 25 +- .../core/src/tools/tools/read_media_file.ts | 13 +- pnpm-lock.yaml | 706 ++++++++++++++++-- pnpm-workspace.yaml | 2 + 13 files changed, 828 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 8e5f699..356d13f 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "string-width": "^7.2.0", "toml": "^3.0.0", "turndown": "^7.2.2", - "undici": "^6.23.0", + "undici": "^6.28.0", "zod": "^4.3.6" } } diff --git a/packages/core/package.json b/packages/core/package.json index 9df47cf..4627948 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,7 +26,8 @@ "diff": "^8.0.3", "ignore": "^7.0.5", "js-tiktoken": "^1.0.21", - "minimatch": "^10.0.1", + "minimatch": "^10.2.6", + "undici": "^6.28.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/packages/core/src/features/history/history_sink.test.ts b/packages/core/src/features/history/history_sink.test.ts index 1f28b7f..a83b62f 100644 --- a/packages/core/src/features/history/history_sink.test.ts +++ b/packages/core/src/features/history/history_sink.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach, afterEach } from 'vitest' -import { unlink, readFile } from 'node:fs/promises' +import { unlink, readFile, stat } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -40,6 +40,14 @@ describe('JsonlHistorySink', () => { expect(parsed.type === 'session_start').toBe(true) }) + test('writes session file with owner-only permissions', async () => { + const sink = new JsonlHistorySink(filePath) + await sink.append(createHistoryEvent({ sessionId: 'test-session', type: 'session_start' })) + + const mode = (await stat(filePath)).mode & 0o777 + expect(mode).toBe(0o600) + }) + test('creates parent directory if not exists', async () => { const nestedPath = join(tmpdir(), `memo-test-nested-${Date.now()}`, 'subdir', 'test.jsonl') const sink = new JsonlHistorySink(nestedPath) diff --git a/packages/core/src/features/history/history_sink.ts b/packages/core/src/features/history/history_sink.ts index 5c3db44..5e00181 100644 --- a/packages/core/src/features/history/history_sink.ts +++ b/packages/core/src/features/history/history_sink.ts @@ -24,7 +24,9 @@ export class JsonlHistorySink implements HistorySink { } this.writeQueue = this.writeQueue.then(async () => { await this.ensureDirectory() - await appendFile(this.filePath, `${JSON.stringify(event)}\n`, 'utf8') + // Session logs contain full conversations and tool output, which may + // include secrets; restrict to the owner like the OAuth credential file. + await appendFile(this.filePath, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 }) }) return this.writeQueue } diff --git a/packages/core/src/tools/router/mcp/oauth.runtime.test.ts b/packages/core/src/tools/router/mcp/oauth.runtime.test.ts index 33d9454..9e8bca2 100644 --- a/packages/core/src/tools/router/mcp/oauth.runtime.test.ts +++ b/packages/core/src/tools/router/mcp/oauth.runtime.test.ts @@ -121,6 +121,30 @@ describe('oauth runtime helpers', () => { await expect(openExternalUrl('https://example.com/oauth/authorize')).rejects.toThrow('spawn failed') }) + test('openExternalUrl on win32 uses PowerShell Start-Process', async () => { + const child = spawnChild() + spawnMock.mockReturnValue(child) + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + + await openExternalUrl('https://example.com/oauth/authorize') + + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', "Start-Process 'https://example.com/oauth/authorize'"], + expect.objectContaining({ detached: true }), + ) + platformSpy.mockRestore() + }) + + test('openExternalUrl on win32 rejects URLs containing quotes', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + + await expect(openExternalUrl('https://example.com/a"b')).rejects.toThrow('quote') + + expect(spawnMock).not.toHaveBeenCalled() + platformSpy.mockRestore() + }) + test('createRuntimeMcpOAuthProvider returns null when no stored token exists', async () => { await withTempMemoHome(async (home) => { const provider = await createRuntimeMcpOAuthProvider({ @@ -247,6 +271,43 @@ describe('oauth login flow', () => { ).rejects.toThrow('does not advertise OAuth support') }) + test('callback error page escapes attacker-controlled error text', async () => { + await withTempMemoHome(async (home) => { + installDiscoveryFetch() + spawnMock.mockReturnValue(spawnChild()) + + let redirectUrl = '' + authMock.mockImplementationOnce(async (provider: any) => { + redirectUrl = provider.redirectUrl + return 'REDIRECT' + }) + authMock.mockResolvedValueOnce('REDIRECT') + + const loginRejection = loginMcpServerOAuth({ + serverName: 'remote', + config: { type: 'streamable_http', url: TEST_URL }, + timeoutMs: 5_000, + settings: { memoHome: home, storeMode: 'file' }, + }).then( + () => null, + (error: unknown) => error, + ) + + await vi.waitFor(() => { + assert.ok(redirectUrl, 'callback server did not come up') + }) + + const res = await fetch(`${redirectUrl}?error=bad&error_description=`) + const body = await res.text() + + assert.strictEqual(res.status, 400) + expect(body).toContain('<script>') + expect(body).not.toContain('