Skip to content

Commit 7aefc29

Browse files
authored
refactor: migrate LLM, MCP, and tool layers to AI SDK (#226)
* 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). * 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%. * 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. * 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. * 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. * 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. * feat: add in-process multi-agent runtime * fix(tui): show only active sub-agent status * fix(core): allow safe absolute apply_patch paths * docs: refresh project overview * 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 * fix: regenerate lockfile with pnpm 9 for CI compatibility * style: apply oxfmt formatting to oauth.ts
1 parent f6b7942 commit 7aefc29

91 files changed

Lines changed: 2612 additions & 2045 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ This project grew from a small demo into an indispensable "productivity assistan
5656
| **Terminal Mode** | Smooth TUI in terminal |
5757
| **Smart Context Management** | Auto-compact long session context, configurable threshold, millisecond-level token estimation |
5858
| **Skills System** | Skills integration, auto-discover `SKILL.md`, activate by scenario |
59-
| **Deep MCP Integration** | Local/remote MCP servers, OAuth login, runtime dynamic switching |
59+
| **Deep MCP Integration** | Local/remote MCP servers, OAuth login, per-session dynamic switching |
6060
| **Enterprise-Grade Security** | Tool approval system (auto-approve/manual-approve), supports once/session/deny modes |
6161
| **OpenAI Compatible** | Works with any OpenAI-compatible API, flexible multi-Provider configuration |
6262

@@ -103,19 +103,18 @@ First run will guide you through Provider/Model setup and save config to `~/.mem
103103
```
104104
memo-code/
105105
├── packages/
106-
│ ├── core/ # Core logic: Session state machine, Config handling
107-
│ ├── tools/ # Tool routing, MCP Client management, built-in tools (exec_command, read_text_file, apply_patch...)
108-
│ ├── tui/ # Terminal runtime: CLI entry, interactive TUI
109-
└── docs/ # Technical documentation
106+
│ ├── core/ # Agent engine: session state machine, LLM/tool loop, built-in tools, MCP client, skills
107+
│ └── tui/ # Terminal runtime: CLI entry, interactive TUI (Ink)
108+
└── site/ # Documentation website (Next.js, static export)
110109
```
111110

112111
**Technical Highlights:**
113112

114-
- **Architecture**: Clean Core / Tools / TUI separation, state-machine driven session management
115-
- **Testing**: Core + Tools coverage > 70%, complete unit + integration tests
113+
- **Architecture**: Core engine with integrated tool routing, thin TUI on top, state-machine driven session management
114+
- **Testing**: Unit + integration tests, coverage threshold ≥70%
116115
- **Protocol**: Native MCP (Model Context Protocol) support, can integrate any MCP tool server
117116
- **Token Estimation**: Real-time context monitoring based on tiktoken, configurable auto-compaction strategy
118-
- **Distribution**: npm package with hot-reloading without perception
117+
- **Distribution**: Published to npm with version-driven auto releases via CI
119118

120119
---
121120

@@ -125,9 +124,11 @@ memo-code/
125124
- `apply_patch` - Structured patch editing (`*** Begin Patch`/`*** End Patch`)
126125
- `read_text_file` / `read_media_file` / `read_files` / `write_file` / `edit_file` / `list_directory` / `search_files` - Filesystem read/write/search
127126
- `webfetch` - Paged web fetching with markdown extraction and policy guards
128-
- MCP resource access - `list_mcp_resources`, `read_mcp_resource`
127+
- MCP resource access - `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource`
129128
- `update_plan` - Structured task progress management
129+
- `read_skill` - Load skill instructions on demand
130130
- `get_memory` - Persistent memory reading
131+
- Agent collaboration - `spawn_agent` / `send_message` / `followup_task` / `wait_agent` / `interrupt_agent` / `list_agents`
131132

132133
---
133134

@@ -148,8 +149,8 @@ base_url = "https://api.openai.com/v1"
148149
command = "npx"
149150
args = ["-y", "@modelcontextprotocol/server-github"]
150151

151-
# Skills
152-
active_skills = ["./skills/doc-writing/SKILL.md"]
152+
# Skills (absolute paths to SKILL.md files)
153+
active_skills = ["/path/to/skills/doc-writing/SKILL.md"]
153154
```
154155

155156
---

‎README.zh.md‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Memo 诞生于一个简单的想法:**我想验证一个最简单的 Agent 是
5252
| **终端模式** | 终端 TUI 交互流畅 |
5353
| **智能上下文管理** | 自动压缩长会话上下文,支持配置压缩阈值,毫秒级 token 估算 |
5454
| **Skills 技能系统** | Skills 技能集成,自动发现 `SKILL.md`,支持按场景激活 |
55-
| **MCP 深度集成** | 支持本地/远程 MCP 服务器,OAuth 登录,运行时动态切换 |
55+
| **MCP 深度集成** | 支持本地/远程 MCP 服务器,OAuth 登录,会话级动态切换 |
5656
| **企业级安全** | 工具分级审批机制(自动批准/手动批准),支持单次/会话/拒绝三种模式 |
5757
| **OpenAI 兼容** | 支持任意 OpenAI 兼容 API,灵活配置多 Provider 切换 |
5858

@@ -93,29 +93,30 @@ memo
9393
```
9494
memo-code/
9595
├── packages/
96-
│ ├── core/ # 核心逻辑:Session 状态机、Config 处理
97-
│ ├── tools/ # Tool 路由、MCP Client管理、内置工具实现(exec_command, read_text_file, apply_patch...)
98-
│ ├── tui/ # 终端运行时:CLI 入口、交互式 TUI
99-
└── docs/ # 技术文档
96+
│ ├── core/ # 核心引擎:Session 状态机、LLM/工具循环、内置工具、MCP 客户端、技能
97+
│ └── tui/ # 终端运行时:CLI 入口、交互式 TUI (Ink)
98+
└── site/ # 文档网站(Next.js 静态导出)
10099
```
101100

102101
**技术亮点:**
103102

104-
- **架构**:清晰的 Core / Tools / TUI 分层,状态机驱动会话管理
105-
- **测试**:Core + Tools 覆盖率 > 70%,完整的单元 + 集成测试
103+
- **架构**:核心引擎内置工具路由,TUI 薄壳,状态机驱动会话管理
104+
- **测试**:单元 + 集成测试,覆盖率门槛 ≥70%
106105
- **协议**:原生支持 MCP (Model Context Protocol),可接入任意 MCP 工具服务器
107106
- **Token 估算**:基于 tiktoken 的实时上下文监控,支持可配置的自动压缩策略
108-
- **分发**:npm 包热加载无感知
107+
- **分发**:发布至 npm,CI 版本驱动自动发版
109108

110109
## 🔧 内置工具
111110

112111
- `exec_command` / `write_stdin` - 执行 Shell 命令
113-
- `apply_patch` - 字符串级代码编辑(单文件/批量)
112+
- `apply_patch` - 结构化补丁编辑(`*** Begin Patch`/`*** End Patch`)
114113
- `read_text_file` / `read_media_file` / `read_files` / `write_file` / `edit_file` / `list_directory` / `search_files` - 文件系统读写与检索
115114
- `webfetch` - 支持分页、Markdown 提取与策略防护的网页抓取
116-
- MCP 资源访问 - `list_mcp_resources`, `read_mcp_resource`
115+
- MCP 资源访问 - `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource`
117116
- `update_plan` - 结构化任务进度管理
117+
- `read_skill` - 按需加载技能指令
118118
- `get_memory` - 持久化记忆读取
119+
- Agent 协作 - `spawn_agent` / `send_message` / `followup_task` / `wait_agent` / `interrupt_agent` / `list_agents`
119120

120121
## ⚙️ 配置示例
121122

@@ -134,8 +135,8 @@ base_url = "https://api.openai.com/v1"
134135
command = "npx"
135136
args = ["-y", "@modelcontextprotocol/server-github"]
136137

137-
# Skills
138-
active_skills = ["./skills/doc-writing/SKILL.md"]
138+
# Skills(SKILL.md 的绝对路径)
139+
active_skills = ["/path/to/skills/doc-writing/SKILL.md"]
139140
```
140141

141142
---

‎package.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
"start": "tsx packages/tui/src/cli.tsx",
2424
"build": "tsup",
2525
"dev": "tsup --watch",
26-
"site:dev": "pnpm --filter @memo-code/site dev",
27-
"site:build": "pnpm --filter @memo-code/site build",
28-
"site:start": "pnpm --filter @memo-code/site start",
26+
"site:dev": "pnpm --dir site dev",
27+
"site:build": "pnpm --dir site build",
28+
"site:start": "pnpm --dir site start",
2929
"format": "oxfmt packages site package.json pnpm-workspace.yaml tsconfig.json tsup.config.ts vitest.config.ts vitest.setup.ts .oxfmtrc.json",
3030
"format:check": "oxfmt --check packages site package.json pnpm-workspace.yaml tsconfig.json tsup.config.ts vitest.config.ts vitest.setup.ts .oxfmtrc.json",
3131
"lint": "oxlint packages site",
@@ -67,7 +67,7 @@
6767
"string-width": "^7.2.0",
6868
"toml": "^3.0.0",
6969
"turndown": "^7.2.2",
70-
"undici": "^6.23.0",
70+
"undici": "^6.28.0",
7171
"zod": "^4.3.6"
7272
}
7373
}

‎packages/core/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"diff": "^8.0.3",
2727
"ignore": "^7.0.5",
2828
"js-tiktoken": "^1.0.21",
29-
"minimatch": "^10.0.1",
29+
"minimatch": "^10.2.6",
30+
"undici": "^6.28.0",
3031
"zod": "^4.3.6"
3132
},
3233
"devDependencies": {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { InputQueue } from './communication'
3+
4+
describe('InputQueue', () => {
5+
test('wakes event-driven waiters without polling', async () => {
6+
const queue = new InputQueue()
7+
const waiting = queue.waitForActivity(1_000)
8+
queue.enqueue({ author: '/root/a', recipient: '/root', content: 'done', triggerTurn: false })
9+
await expect(waiting).resolves.toBe('mailbox')
10+
})
11+
12+
test('only drains a triggered batch when it contains trigger work', () => {
13+
const queue = new InputQueue()
14+
queue.enqueue({ author: '/root', recipient: '/root/a', content: 'note', triggerTurn: false })
15+
expect(queue.drainTriggeredBatch()).toEqual([])
16+
queue.enqueue({ author: '/root', recipient: '/root/a', content: 'continue', triggerTurn: true })
17+
expect(queue.drainTriggeredBatch().map((message) => message.content)).toEqual(['note', 'continue'])
18+
})
19+
})
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
export type InterAgentCommunication = {
2+
author: string
3+
recipient: string
4+
content: string
5+
triggerTurn: boolean
6+
}
7+
8+
export type InputQueueActivity = 'mailbox' | 'timeout' | 'closed' | 'aborted'
9+
10+
type ActivityWaiter = (activity: InputQueueActivity) => void
11+
12+
export class InputQueue {
13+
private messages: InterAgentCommunication[] = []
14+
private waiters = new Set<ActivityWaiter>()
15+
private closed = false
16+
17+
enqueue(communication: InterAgentCommunication): void {
18+
if (this.closed) throw new Error('agent mailbox is closed')
19+
this.messages.push(communication)
20+
this.publish('mailbox')
21+
}
22+
23+
hasMessages(): boolean {
24+
return this.messages.length > 0
25+
}
26+
27+
hasTrigger(): boolean {
28+
return this.messages.some((message) => message.triggerTurn)
29+
}
30+
31+
drainAll(): InterAgentCommunication[] {
32+
if (this.messages.length === 0) return []
33+
const drained = this.messages
34+
this.messages = []
35+
return drained
36+
}
37+
38+
drainTriggeredBatch(): InterAgentCommunication[] {
39+
if (!this.hasTrigger()) return []
40+
return this.drainAll()
41+
}
42+
43+
async waitForActivity(timeoutMs: number, signal?: AbortSignal): Promise<InputQueueActivity> {
44+
if (this.hasMessages()) return 'mailbox'
45+
if (this.closed) return 'closed'
46+
if (signal?.aborted) return 'aborted'
47+
48+
return new Promise<InputQueueActivity>((resolve) => {
49+
let settled = false
50+
const finish = (activity: InputQueueActivity) => {
51+
if (settled) return
52+
settled = true
53+
clearTimeout(timer)
54+
signal?.removeEventListener('abort', onAbort)
55+
this.waiters.delete(finish)
56+
resolve(activity)
57+
}
58+
const onAbort = () => finish('aborted')
59+
const timer = setTimeout(() => finish('timeout'), timeoutMs)
60+
61+
this.waiters.add(finish)
62+
signal?.addEventListener('abort', onAbort, { once: true })
63+
64+
// Recheck after subscribing so an enqueue cannot be missed at the boundary.
65+
if (this.hasMessages()) finish('mailbox')
66+
else if (this.closed) finish('closed')
67+
})
68+
}
69+
70+
close(): void {
71+
if (this.closed) return
72+
this.closed = true
73+
this.publish('closed')
74+
}
75+
76+
private publish(activity: InputQueueActivity): void {
77+
for (const waiter of this.waiters) waiter(activity)
78+
}
79+
}
80+
81+
export function formatInterAgentCommunication(message: InterAgentCommunication): string {
82+
return `<agent_message author="${escapeAttribute(message.author)}" recipient="${escapeAttribute(
83+
message.recipient,
84+
)}">\n${message.content}\n</agent_message>`
85+
}
86+
87+
function escapeAttribute(value: string): string {
88+
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
89+
}

‎packages/core/src/agent/compact_prompt.test.ts‎

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ describe('compact_prompt', () => {
3535
const prompt = buildCompactionUserPrompt(messages)
3636
assert.ok(prompt.includes('[0] ASSISTANT (tool_calls: exec_command)'))
3737
assert.ok(prompt.includes('[1] TOOL (exec_command)'))
38-
// Truncation keeps the tail (tool result/error at the end carries the info).
39-
assert.ok(prompt.includes(`...${'x'.repeat(4_000)}`))
38+
// Long tool output is NOT truncated per-message: the budget selector is
39+
// the only truncation point, so the compaction model sees full outputs.
40+
assert.ok(prompt.includes(longToolOutput))
4041
assert.ok(prompt.includes('Return only the summary body in plain text. Do not add markdown fences.'))
4142
})
4243

@@ -93,13 +94,80 @@ describe('compact_prompt', () => {
9394
)
9495
})
9596

96-
test('selectCompactionMessages keeps the newest message even when it exceeds the budget', () => {
97+
test('selectCompactionMessages truncates a newest message that alone exceeds the budget', () => {
9798
const messages: ChatMessage[] = [
9899
{ role: 'user', content: 'old' },
99100
{ role: 'assistant', content: 'x'.repeat(500) },
100101
]
101102
const selected = selectCompactionMessages(messages, 10, (text) => text.length)
102-
assert.deepStrictEqual(selected, [messages[1]])
103+
assert.strictEqual(selected.length, 1)
104+
const content = selected[0]?.content
105+
assert.strictEqual(typeof content, 'string')
106+
// Tail kept (with the truncation marker), head dropped.
107+
assert.ok(String(content).startsWith('...'))
108+
assert.ok(String(content).endsWith('x'))
109+
assert.ok(String(content).length < 500)
110+
})
111+
112+
test('plan from update_plan tool results survives into the compaction prompt', () => {
113+
const planJson = JSON.stringify({
114+
message: 'Plan updated',
115+
plan: [
116+
{ step: 'Implement the parser', status: 'in_progress' },
117+
{ step: 'Wire up the CLI flag', status: 'pending' },
118+
{ step: 'Add tests for edge cases', status: 'pending' },
119+
],
120+
})
121+
const messages: ChatMessage[] = [
122+
{ role: 'user', content: 'Refactor the parser' },
123+
{
124+
role: 'assistant',
125+
content: [{ type: 'text', text: 'Let me update the plan' }],
126+
},
127+
{
128+
role: 'tool',
129+
content: [
130+
{
131+
type: 'tool-result',
132+
toolCallId: 'plan-1',
133+
toolName: 'update_plan',
134+
output: { type: 'text', value: planJson },
135+
},
136+
],
137+
},
138+
{ role: 'user', content: 'Continue with step two' },
139+
]
140+
141+
const selected = selectCompactionMessages(messages, 10_000, (text) => text.length)
142+
const prompt = buildCompactionUserPrompt(selected)
143+
assert.ok(prompt.includes('Implement the parser'), 'plan steps must reach the compaction model')
144+
assert.ok(prompt.includes('Wire up the CLI flag'))
145+
assert.ok(prompt.includes('update_plan'))
146+
})
147+
148+
test('selectCompactionMessages truncates oversized tool results keeping the tail', () => {
149+
const messages: ChatMessage[] = [
150+
{
151+
role: 'tool',
152+
content: [
153+
{
154+
type: 'tool-result',
155+
toolCallId: 'call-1',
156+
toolName: 'exec_command',
157+
output: { type: 'text', value: `head-noise\n${'y'.repeat(1_000)}` },
158+
},
159+
],
160+
},
161+
]
162+
const selected = selectCompactionMessages(messages, 20, (text) => text.length)
163+
assert.strictEqual(selected.length, 1)
164+
const part = selected[0]?.content
165+
assert.ok(Array.isArray(part))
166+
const value = part?.[0]?.type === 'tool-result' ? part[0].output.value : ''
167+
assert.ok(String(value).startsWith('...'))
168+
assert.ok(String(value).endsWith('y'))
169+
assert.ok(String(value).length < 1_000)
170+
assert.ok(!String(value).includes('head-noise'), 'tool output head is dropped')
103171
})
104172

105173
test('selectCompactionMessages returns empty for an empty array', () => {

0 commit comments

Comments
 (0)