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
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## 架构原则

- 不要 fallback,而应该从最佳实践角度解决问题
- 少写代码, 遵循:
- KISS(Keep It Stupid Simple)
- YAGNI(You Ain't Gonna Need It)
- DRY(Don't Repeat Yourself)

- 写好代码, 遵循 SOLID 原则
- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
- 写对代码, 使用 ask-question tool
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## 项目概述

memo-cli 是基于 Bun + TypeScript 的终端 ReAct Agent(monorepo 结构,~2000 行核心代码)。支持多轮对话、JSONL 日志、9 个内置工具,通过 OpenAI 兼容接口调用 LLM(默认 DeepSeek),配置存储在 `~/.memo`。
memo-cli 是基于 Bun + TypeScript 的终端 ReAct Agent(monorepo 结构,~2000 行核心代码)。支持多轮对话、JSONL 日志、10 个内置工具,通过 OpenAI 兼容接口调用 LLM(默认 DeepSeek),配置存储在 `~/.memo`。

## 包结构

Expand All @@ -17,7 +17,7 @@ memo-cli 是基于 Bun + TypeScript 的终端 ReAct Agent(monorepo 结构,~2
- `utils/utils.ts`:JSON 输出解析(提取 action/final)
- `utils/tokenizer.ts`:Token 计数(tiktoken)

- **packages/tools** (~700 行):9 个工具(bash/read/write/edit/glob/grep/webfetch/save_memory/todo)
- **packages/tools** (~700 行):10 个工具(bash/read/write/edit/glob/grep/webfetch/time/save_memory/todo)
- 基于 MCP 协议,Zod 验证输入
- 统一导出为 `TOOLKIT`

Expand Down Expand Up @@ -75,6 +75,7 @@ base_url = "https://api.deepseek.com"
| glob | 文件匹配 | 基于 Bun.Glob |
| grep | 文本搜索 | 基于 ripgrep |
| webfetch | HTTP GET | 10s 超时,512KB 限制 |
| time | 当前时间 | ISO/UTC/epoch/timezone |
| save_memory | 长期记忆 | 追加到 ~/.memo/memo.md |
| todo | 待办清单 | 进程内,最多 10 条 |

Expand Down
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## 特性

- 多轮对话:交互式 REPL,`--once` 支持单轮退出。
- 工具驱动:内置 bash/run_bun/read/write/edit/glob/grep/webfetch(HTML 自动转纯文本)、save_memory、todo,按 ReAct 协议调用。
- 工具驱动:内置 bash/run_bun/read/write/edit/glob/grep/webfetch(HTML 自动转纯文本)、time、save_memory、todo,按 ReAct 协议调用。
- 结构化日志:默认写入 `history/<sessionId>.jsonl`,可携带 token 计数与事件。
- 可配置 token 预算:本地 tiktoken 预估 + LLM usage 对账,支持提示超限预警/拒绝。

Expand Down Expand Up @@ -42,20 +42,20 @@
## 外部 MCP Server

- 配置文件:`~/.memo/config.toml`(可用 `MEMO_HOME` 覆盖)。在 `[mcp_servers.<name>]` 下添加条目。
- 本地 stdio 服务器(已有可执行文件):
```toml
[mcp_servers.local_tools]
command = "/path/to/mcp-server"
args = []
```
- 远程 HTTP 服务器(Streamable HTTP,失败会自动回退 SSE):
```toml
[mcp_servers.bing_cn]
type = "streamable_http"
url = "https://mcp.api-inference.modelscope.net/496703c5b3ff47/mcp"
# 可选:headers = { Authorization = "Bearer xxx" }
# 可选:fallback_to_sse = true # 默认开启
```
- 本地 stdio 服务器(已有可执行文件):
```toml
[mcp_servers.local_tools]
command = "/path/to/mcp-server"
args = []
```
- 远程 HTTP 服务器(Streamable HTTP,失败会自动回退 SSE):
```toml
[mcp_servers.bing_cn]
type = "streamable_http"
url = "https://mcp.api-inference.modelscope.net/496703c5b3ff47/mcp"
# 可选:headers = { Authorization = "Bearer xxx" }
# 可选:fallback_to_sse = true # 默认开启
```
- 保存配置后重启 memo,会在系统提示词中注入外部工具列表(工具名前会带 `<server>_` 前缀)。

## 项目结构
Expand Down
2 changes: 1 addition & 1 deletion docs/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
- `createAgentSession(deps, options)` 返回 Session;`runTurn` 执行单轮 ReAct,UI 控制轮次(如 `--once` 只跑一轮)。
- 默认依赖补全:`tools`(内置工具集)、`callLLM`(基于 provider 的 OpenAI 客户端)、`loadPrompt`、`historySinks`(写 `~/.memo/sessions/...`)、`tokenCounter`、`maxSteps` 均可省略。
- 配置来源:`~/.memo/config.toml`(`MEMO_HOME` 可覆盖),字段 `current_provider`、`providers` 数组、`max_steps`。缺失时 UI 会交互式引导生成。
- 回调:`onAssistantStep`、`onObservation` 供 UI 实时渲染
- 回调:`onAssistantStep`(流式输出) + `hooks`/`middlewares`(`onTurnStart/onAction/onObservation/onFinal`),便于 UI/插件订阅生命周期

简例:

Expand Down
55 changes: 55 additions & 0 deletions docs/design/hooks-and-middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Session Hook & Middleware 设计

## 背景与问题

- 目前 Core 仅暴露 `onAssistantStep` 与一个简化的 `onObservation(tool, text, step)` 回调,无法覆盖 turn 开始、工具调度、最终响应等关键节点,UI/集成层想做审计或指标统计就只能解析 JSONL。
- 没有统一的“中间件”概念,同一个扩展场景必须堆多个回调,且无法在不同项目中复用;缺乏明确的调用顺序与失败保护。
- Hook 上下文信息不足,例如不知道 `sessionId`、`turnUsage`,也无法拿到完整 `history/steps`。

## 目标

1. 给出完整的 `onTurnStart/onAction/onObservation/onFinal` 事件流,包含必要上下文。
2. 在 Core 层定义可复用的 `AgentMiddleware`,一个中间件可同时实现多个 Hook。
3. 保持 API 简洁:默认无需写中间件,必要时传入 `deps.hooks` 或 `deps.middlewares` 即可。
4. 错误隔离:中间件异常不能中断 Session,只打印警告。
5. 所有扩展统一走 Hook/Middleware,彻底移除旧的 `deps.onObservation` 入口,避免多套回调并存。

## 方案概述

### Hook 类型

| Hook | 触发时机 | 上下文字段 |
| --------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `onTurnStart` | `runTurn` 接收到用户输入并写入 `turn_start` 事件后 | `sessionId`、`turn`、`input`、当前 `history` 快照 |
| `onAction` | 解析出 `action` 并准备执行工具时 | `sessionId`、`turn`、`step`、`action`(工具名+原始 input)、`history` |
| `onObservation` | 工具执行完 observation 写回历史后 | `sessionId`、`turn`、`step`、`tool`、`observation`、`history` |
| `onFinal` | 任何路径下得出最终回答并写入 `final` 事件时 | `sessionId`、`turn`、`step?`、`finalText`、`status`、`errorMessage?`、`tokenUsage?`(当前步)与 `turnUsage`、`steps` |

> `history` 为浅拷贝快照(`{ role, content }[]`),`turnUsage` 也会复制数值,调用方视为只读。

### API 设计

- 新增类型:
```ts
export type AgentHookHandler<T> = (payload: T) => Promise<void> | void
export type AgentHooks = { onTurnStart?: AgentHookHandler<TurnStartHookPayload>; ... }
export type AgentMiddleware = AgentHooks & { name?: string }
```
- `AgentDeps` 新增 `hooks?: AgentHooks`(简单模式)与 `middlewares?: AgentMiddleware[]`(多实例模式)。
- Hook 调用顺序:`deps.hooks` → `deps.middlewares`(顺序执行)。任何一个抛错只打印 `console.warn`,不中断主流程。

### 运行流程

1. 创建 Session 时聚合所有中间件,存成 `HookRunnerMap`。
2. 运行 Turn:
- 写入 `turn_start` 后触发 `onTurnStart`。
- 每次解析出 `action`,在执行工具前触发 `onAction`。
- 工具返回 observation 后写回历史并触发 `onObservation`。
- 任一路径确定 `finalText` 时立即触发 `onFinal`,并保证每次 `final` 事件对应一次 Hook 调用。
3. Hook 只读取上下文,不允许修改核心状态(文档中强调“视为只读”)。

## 迁移与测试

- 旧的 `deps.onObservation(tool, observation, step)` 已移除,如需监听工具反馈,应在 `hooks` 或 `middlewares` 中实现 `onObservation`。
- `onAssistantStep` 流式回调保持原样,可与 Hook 并行使用。
- 测试:使用 mock LLM + 假工具,断言 Hook/中间件依顺序触发、携带上下文,并运行 `bun test`(核心 runtime + 工具)做回归。
2 changes: 1 addition & 1 deletion docs/dev-direction.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

1. Core

- 完善 Session/Turn API:hook(onTurnStart/onAction/onObservation/onFinal)、可选上下文截断策略
- 完善 Session/Turn API:hook(onTurnStart/onAction/onObservation/onFinal)+ 中间件体系,下一步扩展上下文截断策略
- 提供摘要/截断策略接口,控制长上下文。
- 优化 token 计数:集中封装 tiktoken + usage 对账,暴露预算超限的策略钩子。
- 抽象历史 sink:文件 JSONL、stdout、可选远端(后续)。
Expand Down
10 changes: 5 additions & 5 deletions docs/future-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
memo-cli 是一个基于 Bun + TypeScript 的 ReAct Agent CLI 工具,具有以下核心特性:

- **多轮对话**:交互式 REPL 与单轮模式(`--once`)
- **工具驱动**:内置 bash/read/write/edit/glob/grep/webfetch/save_memory/todo 等工具,严格遵循 ReAct 协议
- **工具驱动**:内置 bash/read/write/edit/glob/grep/webfetch/time/save_memory/todo 等工具,严格遵循 ReAct 协议
- **结构化日志**:JSONL 格式记录完整会话历史,便于调试与审计
- **Token 预算管理**:本地 tiktoken 预估 + LLM usage 对账,支持超限预警
- **架构清晰**:Core/Tools/UI 三层分离,Core 提供纯函数接口,UI 为薄壳
Expand Down Expand Up @@ -38,7 +38,7 @@ memo-cli 是一个基于 Bun + TypeScript 的 ReAct Agent CLI 工具,具有以

1. **知名度低**:缺乏宣传与社区
2. **工具安全性**:需要更强的输入校验与隔离(如路径白名单、只读模式)
3. **功能局限**:缺少一些常用工具(时间、环境变量、网络请求增强等)
3. **功能局限**:仍缺少一些常用工具(环境变量、文件大小、网络请求增强等)
4. **生态缺失**:插件系统、第三方工具集成尚未实现

## 3. 产品与架构建议
Expand All @@ -54,14 +54,14 @@ memo-cli 是一个基于 Bun + TypeScript 的 ReAct Agent CLI 工具,具有以
### 3.2 架构改进

1. **强化 Core 钩子与事件系统**:
- 完善 `onTurnStart`/`onAction`/`onObservation`/`onFinal` 钩子,支持自定义中间件
- ✅ 已提供 `onTurnStart`/`onAction`/`onObservation`/`onFinal` 钩子与中间件体系,可插入自定义逻辑
- 提供上下文截断策略接口,支持多种摘要算法(如 LLM 摘要、关键句提取)
- 抽象历史 sink,支持 stdout、文件、远程服务(如 OpenTelemetry)

2. **工具安全与能力扩展**:
- 增加路径白名单配置,限制工具可访问目录
- 提供只读模式,防止意外修改
- 增加常用工具:`time`(获取时间)、`env`(读取环境变量)、`size`(文件大小)、`hash`(计算哈希)、`curl`(增强网络请求)
- 增加常用工具:`env`(读取环境变量)、`size`(文件大小)、`hash`(计算哈希)、`curl`(增强网络请求)
- 统一错误码与提示语,提高用户体验

3. **插件系统设计**:
Expand Down Expand Up @@ -123,7 +123,7 @@ memo-cli 是一个基于 Bun + TypeScript 的 ReAct Agent CLI 工具,具有以

1. 补强 Core hook 与事件系统,提供 stdout sink 选项
2. 增加工具安全校验(路径白名单、只读模式)
3. 添加 3-5 个常用工具(time、env、size 等)
3. 添加 3-5 个常用工具(env、size、hash 等)
4. 完善测试覆盖,CI 自动化
5. 编写入门教程与案例

Expand Down
26 changes: 15 additions & 11 deletions docs/tool/run_bun.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,19 @@

## 行为

- 将代码写入临时目录的随机文件(尊重 `TMPDIR`,否则 `/tmp`),使用 `bun run <tmp>.ts` 执行。
- 开启 `FORCE_COLOR=0`,避免彩色输出影响解析。
- 运行前会创建独立的临时目录(尊重 `TMPDIR`,否则 `os.tmpdir()`),将代码写入 `main.ts`,并在执行后递归删除整个目录。
- Linux 通过 [bubblewrap (`bwrap`)](https://github.com/containers/bubblewrap) 创建沙箱;macOS 使用 `sandbox-exec` profile。只有该临时目录被绑定为可写,其他系统路径保持只读。
- 默认 `MEMO_RUN_BUN_ALLOW_NET=0`(禁用网络);设置为 `1` 可打开网络转发。
- 可以通过 `MEMO_RUN_BUN_SANDBOX='["/path/to/runner","--flag","{{entryFile}}"]'` 自定义沙箱命令;支持 `{{entryFile}}`、`{{runDir}}`、`{{allowNetwork}}` 占位符。
- 执行命令时自动设置 `TMPDIR=HOME=<临时目录>`、`FORCE_COLOR=0`,避免污染宿主环境。
- 收集 stdout/stderr 文本及退出码,返回格式:
```
exit=<code>
stdout:
<stdout content>
stderr:
<stderr content>
```
```
exit=<code>
stdout:
<stdout content>
stderr:
<stderr content>
```
- 即使出现 runtime error 也会返回(exit 为非 0,stderr 包含错误);只在文件写入/进程创建等异常时标记 `isError=true`。
- 执行完会尝试删除临时文件(清理失败会被忽略)。

Expand All @@ -36,6 +39,7 @@

## 注意

- Linux 需要预装 `bwrap`,macOS 依赖系统自带的 `sandbox-exec`;否则需通过 `MEMO_RUN_BUN_SANDBOX` 指定自定义沙箱(命令以 JSON 数组形式配置)。
- 只能访问环境已有的依赖(未自动安装第三方包)。
- 代码运行环境与 memo 进程同机,需注意安全与资源消耗
- 输出未经截断,长输出可能占用较多 token。***
- 网络默认关闭,如确需联网请设置 `MEMO_RUN_BUN_ALLOW_NET=1` 并考虑额外的出口控制
- 输出未经截断,长输出可能占用较多 token。\*\*\*
52 changes: 52 additions & 0 deletions docs/tool/time.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Memo CLI `time` 工具

提供可信的当前时间视图,统一由进程系统时间返回多种格式(本地 ISO、UTC、时间戳、时区偏移等),解决模型对“现在时间”模糊的问题。

## 基本信息

- 工具名称:`time`
- 描述:返回当前系统时间(ISO/UTC/epoch/timezone 等多视图)
- 文件:`packages/tools/src/tools/time.ts`
- 确认:否

## 参数

- 无输入。调用时传入空对象 `{}` 即可。

## 行为

- 读取进程所在系统时间,计算:
- `iso`:本地时间的 ISO 8601 字符串(包含偏移,例如 `2025-01-01T20:15:00.123+08:00`)
- `utc_iso`:`Date.toISOString()` 结果(UTC)
- `epoch_ms` / `epoch_seconds`:UNIX 时间戳(毫秒与秒)
- `timezone`: `name`(IANA 时区名称)、`offset_minutes`(相对于 UTC 的分钟数)、`offset`(`±HH:MM` 形式)
- `day_of_week`:英文星期
- `human_readable`:`YYYY-MM-DD HH:mm:ss (Weekday, UTC±HH:MM <tz>)` 形式
- `source`: 固定为 `local_system_clock`
- 以上内容会以 JSON 字符串写入 `CallToolResult` 的文本内容,方便模型解析。
- 不涉及网络调用;如系统时钟被篡改则结果也会随之变化。

## 输出示例

```json
{
"iso": "2025-01-01T20:15:00.123+08:00",
"utc_iso": "2025-01-01T12:15:00.123Z",
"epoch_ms": 1735733700123,
"epoch_seconds": 1735733700,
"timezone": {
"name": "Asia/Shanghai",
"offset_minutes": 480,
"offset": "+08:00"
},
"day_of_week": "Wednesday",
"human_readable": "2025-01-01 20:15:00 (Wednesday, UTC+08:00 Asia/Shanghai)",
"source": "local_system_clock"
}
```

## 注意

- 仅返回当前时间快照,不提供倒计时或两个时间点的差值。
- 如果系统没有提供 IANA 时区名称,则 `timezone.name` 可能为 `UTC`。
- 输出为单行 JSON,模型在解析前可先调用 `JSON.parse`。
49 changes: 41 additions & 8 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,38 @@
/** @file 配置管理相关的读写与序列化测试。 */
import assert from 'node:assert'
import { mkdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeAll, afterAll, describe, test, expect } from 'bun:test'
import { buildSessionPath, loadMemoConfig, writeMemoConfig } from '@memo/core/config/config'
import {
buildSessionPath,
loadMemoConfig,
writeMemoConfig,
type MCPServerConfig,
} from '@memo/core/config/config'

type HttpServerConfig = Extract<MCPServerConfig, { url: string; type?: 'streamable_http' }>
type SseServerConfig = Extract<MCPServerConfig, { url: string; type: 'sse' }>

function expectHttpServer(
server: MCPServerConfig | undefined,
message = 'expected streamable_http server',
): asserts server is HttpServerConfig {
expect(server).toBeDefined()
if (!server || !('url' in server) || server.type === 'sse') {
throw new Error(message)
}
}

function expectSseServer(
server: MCPServerConfig | undefined,
message = 'expected sse server',
): asserts server is SseServerConfig {
expect(server).toBeDefined()
if (!server || !('url' in server) || server.type !== 'sse') {
throw new Error(message)
}
}

let originalCwd: string
let tempBase: string
Expand Down Expand Up @@ -130,12 +159,16 @@ url = "https://legacy.example.com/mcp"
await Bun.write(join(home, 'config.toml'), configText)

const loaded = await loadMemoConfig()
const servers = loaded.config.mcp_servers!
expect(servers.remote.type).toBe('streamable_http')
expect(servers.remote.url).toBe('https://example.com/mcp')
expect(servers.remote.headers?.Authorization).toBe('Bearer abc')
expect(servers.remote.fallback_to_sse).toBe(true)
expect(servers.legacy.type).toBe('sse')
expect(servers.legacy.url).toBe('https://legacy.example.com/mcp')
const servers = loaded.config.mcp_servers ?? {}
const remote = servers.remote
expectHttpServer(remote)
expect(remote.type ?? 'streamable_http').toBe('streamable_http')
expect(remote.url).toBe('https://example.com/mcp')
expect(remote.headers?.Authorization).toBe('Bearer abc')
expect(remote.fallback_to_sse).toBe(true)

const legacy = servers.legacy
expectSseServer(legacy)
expect(legacy.url).toBe('https://legacy.example.com/mcp')
})
})
1 change: 1 addition & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** @file 配置管理:读取/写入 ~/.memo/config.toml 及路径构造工具。 */
import { mkdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** @file Core 包出口,汇聚运行时/配置/工具等公共 API。 */
export * from '@memo/core/types'
export * from '@memo/core/runtime/prompt'
export * from '@memo/core/runtime/history'
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** @file Session 默认依赖装配:工具集、LLM、历史 sink、tokenizer 等。 */
import { TOOLKIT } from '@memo/tools'
import OpenAI from 'openai'
import { createTokenCounter } from '@memo/core/utils/tokenizer'
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime/history.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** @file 历史事件定义与 JSONL Sink 实现。 */
import { appendFile, mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
import type { HistoryEvent, HistorySink, Role } from '@memo/core/types'
Expand Down
Loading