Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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

---

Expand All @@ -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`

---

Expand All @@ -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"]
```

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

Expand Down Expand Up @@ -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`

## ⚙️ 配置示例

Expand All @@ -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"]
```

---
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/agent/communication.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
89 changes: 89 additions & 0 deletions packages/core/src/agent/communication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
export type InterAgentCommunication = {
author: string
recipient: string
content: string
triggerTurn: boolean
}

export type InputQueueActivity = 'mailbox' | 'timeout' | 'closed' | 'aborted'

type ActivityWaiter = (activity: InputQueueActivity) => void

export class InputQueue {
private messages: InterAgentCommunication[] = []
private waiters = new Set<ActivityWaiter>()
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<InputQueueActivity> {
if (this.hasMessages()) return 'mailbox'
if (this.closed) return 'closed'
if (signal?.aborted) return 'aborted'

return new Promise<InputQueueActivity>((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('timeout'), 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 `<agent_message author="${escapeAttribute(message.author)}" recipient="${escapeAttribute(
message.recipient,
)}">\n${message.content}\n</agent_message>`
}

function escapeAttribute(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
76 changes: 72 additions & 4 deletions packages/core/src/agent/compact_prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'))
})

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading