diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e855201 --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 5c2dc03..0d8f064 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`。 ## 包结构 @@ -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` @@ -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 条 | diff --git a/README.md b/README.md index c14cff8..eecbc72 100644 --- a/README.md +++ b/README.md @@ -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/.jsonl`,可携带 token 计数与事件。 - 可配置 token 预算:本地 tiktoken 预估 + LLM usage 对账,支持提示超限预警/拒绝。 @@ -42,20 +42,20 @@ ## 外部 MCP Server - 配置文件:`~/.memo/config.toml`(可用 `MEMO_HOME` 覆盖)。在 `[mcp_servers.]` 下添加条目。 -- 本地 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,会在系统提示词中注入外部工具列表(工具名前会带 `_` 前缀)。 ## 项目结构 diff --git a/docs/core.md b/docs/core.md index 183b519..461990b 100644 --- a/docs/core.md +++ b/docs/core.md @@ -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/插件订阅生命周期。 简例: diff --git a/docs/design/hooks-and-middleware.md b/docs/design/hooks-and-middleware.md new file mode 100644 index 0000000..afd0b0f --- /dev/null +++ b/docs/design/hooks-and-middleware.md @@ -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 = (payload: T) => Promise | void + export type AgentHooks = { onTurnStart?: AgentHookHandler; ... } + 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 + 工具)做回归。 diff --git a/docs/dev-direction.md b/docs/dev-direction.md index 186c201..9b3e94e 100644 --- a/docs/dev-direction.md +++ b/docs/dev-direction.md @@ -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、可选远端(后续)。 diff --git a/docs/future-plan.md b/docs/future-plan.md index 98e2b07..3eea863 100644 --- a/docs/future-plan.md +++ b/docs/future-plan.md @@ -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 为薄壳 @@ -38,7 +38,7 @@ memo-cli 是一个基于 Bun + TypeScript 的 ReAct Agent CLI 工具,具有以 1. **知名度低**:缺乏宣传与社区 2. **工具安全性**:需要更强的输入校验与隔离(如路径白名单、只读模式) -3. **功能局限**:缺少一些常用工具(时间、环境变量、网络请求增强等) +3. **功能局限**:仍缺少一些常用工具(环境变量、文件大小、网络请求增强等) 4. **生态缺失**:插件系统、第三方工具集成尚未实现 ## 3. 产品与架构建议 @@ -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. **插件系统设计**: @@ -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. 编写入门教程与案例 diff --git a/docs/tool/run_bun.md b/docs/tool/run_bun.md index 2731928..fa6c0c1 100644 --- a/docs/tool/run_bun.md +++ b/docs/tool/run_bun.md @@ -15,16 +15,19 @@ ## 行为 -- 将代码写入临时目录的随机文件(尊重 `TMPDIR`,否则 `/tmp`),使用 `bun run .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= - stdout: - - stderr: - - ``` + ``` + exit= + stdout: + + stderr: + + ``` - 即使出现 runtime error 也会返回(exit 为非 0,stderr 包含错误);只在文件写入/进程创建等异常时标记 `isError=true`。 - 执行完会尝试删除临时文件(清理失败会被忽略)。 @@ -36,6 +39,7 @@ ## 注意 +- Linux 需要预装 `bwrap`,macOS 依赖系统自带的 `sandbox-exec`;否则需通过 `MEMO_RUN_BUN_SANDBOX` 指定自定义沙箱(命令以 JSON 数组形式配置)。 - 只能访问环境已有的依赖(未自动安装第三方包)。 -- 代码运行环境与 memo 进程同机,需注意安全与资源消耗。 -- 输出未经截断,长输出可能占用较多 token。*** +- 网络默认关闭,如确需联网请设置 `MEMO_RUN_BUN_ALLOW_NET=1` 并考虑额外的出口控制。 +- 输出未经截断,长输出可能占用较多 token。\*\*\* diff --git a/docs/tool/time.md b/docs/tool/time.md new file mode 100644 index 0000000..f2d97ac --- /dev/null +++ b/docs/tool/time.md @@ -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 )` 形式 + - `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`。 diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 097e8a7..1928d72 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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 +type SseServerConfig = Extract + +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 @@ -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') }) }) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 11e462f..7484aa4 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7e12f9d..8840884 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ +/** @file Core 包出口,汇聚运行时/配置/工具等公共 API。 */ export * from '@memo/core/types' export * from '@memo/core/runtime/prompt' export * from '@memo/core/runtime/history' diff --git a/packages/core/src/runtime/defaults.ts b/packages/core/src/runtime/defaults.ts index 6751d13..8b99225 100644 --- a/packages/core/src/runtime/defaults.ts +++ b/packages/core/src/runtime/defaults.ts @@ -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' diff --git a/packages/core/src/runtime/history.ts b/packages/core/src/runtime/history.ts index b849c25..c447c2d 100644 --- a/packages/core/src/runtime/history.ts +++ b/packages/core/src/runtime/history.ts @@ -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' diff --git a/packages/core/src/runtime/hooks.ts b/packages/core/src/runtime/hooks.ts new file mode 100644 index 0000000..8a9b094 --- /dev/null +++ b/packages/core/src/runtime/hooks.ts @@ -0,0 +1,72 @@ +/** @file Session 生命周期 Hook 聚合与运行辅助,方便统一扩展。 */ +import type { + ActionHookPayload, + AgentHookHandler, + AgentMiddleware, + AgentSessionDeps, + ChatMessage, + FinalHookPayload, + ObservationHookPayload, + TurnStartHookPayload, +} from '@memo/core/types' + +export type HookName = 'onTurnStart' | 'onAction' | 'onObservation' | 'onFinal' + +export type HookPayloadMap = { + onTurnStart: TurnStartHookPayload + onAction: ActionHookPayload + onObservation: ObservationHookPayload + onFinal: FinalHookPayload +} + +export type HookRunnerMap = { + [K in HookName]: AgentHookHandler[] +} + +function emptyHookMap(): HookRunnerMap { + return { + onTurnStart: [], + onAction: [], + onObservation: [], + onFinal: [], + } +} + +function registerMiddleware(target: HookRunnerMap, middleware?: AgentMiddleware) { + if (!middleware) return + if (middleware.onTurnStart) target.onTurnStart.push(middleware.onTurnStart) + if (middleware.onAction) target.onAction.push(middleware.onAction) + if (middleware.onObservation) target.onObservation.push(middleware.onObservation) + if (middleware.onFinal) target.onFinal.push(middleware.onFinal) +} + +export function buildHookRunners(deps: AgentSessionDeps): HookRunnerMap { + const map = emptyHookMap() + registerMiddleware(map, deps.hooks) + if (Array.isArray(deps.middlewares)) { + for (const middleware of deps.middlewares) { + registerMiddleware(map, middleware) + } + } + return map +} + +export async function runHook( + map: HookRunnerMap, + name: K, + payload: HookPayloadMap[K], +) { + const handlers = map[name] + if (!handlers.length) return + for (const handler of handlers) { + try { + await handler(payload) + } catch (err) { + console.warn(`Hook ${name} 执行失败: ${(err as Error).message}`) + } + } +} + +export function snapshotHistory(history: ChatMessage[]): ChatMessage[] { + return history.map((msg) => ({ ...msg })) +} diff --git a/packages/core/src/runtime/mcp_client.ts b/packages/core/src/runtime/mcp_client.ts index c739289..bb6c493 100644 --- a/packages/core/src/runtime/mcp_client.ts +++ b/packages/core/src/runtime/mcp_client.ts @@ -1,3 +1,4 @@ +/** @file 外部 MCP Server 客户端封装,负责加载远程工具列表。 */ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' @@ -128,7 +129,11 @@ function isCacheValid(entry: McpCacheEntry): boolean { return now - cachedAt < CACHE_TTL_MS } -function deserializeTool(serverName: string, serialized: SerializedMcpTool, client: Client): McpTool { +function deserializeTool( + serverName: string, + serialized: SerializedMcpTool, + client: Client, +): McpTool { return { name: serialized.name, description: serialized.description, diff --git a/packages/core/src/runtime/memory.test.ts b/packages/core/src/runtime/memory.test.ts index 6418144..03196c3 100644 --- a/packages/core/src/runtime/memory.test.ts +++ b/packages/core/src/runtime/memory.test.ts @@ -1,3 +1,4 @@ +/** @file 长期记忆注入系统提示词的回归测试。 */ import assert from 'node:assert' import { join } from 'node:path' import { tmpdir } from 'node:os' diff --git a/packages/core/src/runtime/prompt.md b/packages/core/src/runtime/prompt.md index 7508fcb..678c2ee 100644 --- a/packages/core/src/runtime/prompt.md +++ b/packages/core/src/runtime/prompt.md @@ -118,6 +118,7 @@ - **glob**: `{"pattern": "**/*.ts", "path": "/curr/dir"}` - **grep**: `{"pattern": "string", "path": "/dir", "glob": "*.ts", "-i": false, "-C": 2}` - **webfetch**: `{"url": "..."}` +- **time**: `{} // 返回当前系统时间(ISO/UTC/epoch/timezone JSON)` - **save_memory**: `{"fact": "..."}` - **todo**: - Add: `{"type": "add", "todos": [{"content": "string", "status": "pending"}]}` diff --git a/packages/core/src/runtime/prompt.ts b/packages/core/src/runtime/prompt.ts index c4256e3..8a19c8f 100644 --- a/packages/core/src/runtime/prompt.ts +++ b/packages/core/src/runtime/prompt.ts @@ -1,3 +1,4 @@ +/** @file 系统提示词加载:默认读取内置 Markdown 模板。 */ import prompt from './prompt.md' with { type: 'text' } /** diff --git a/packages/core/src/runtime/session.ts b/packages/core/src/runtime/session.ts index 2a57435..b77a585 100644 --- a/packages/core/src/runtime/session.ts +++ b/packages/core/src/runtime/session.ts @@ -1,17 +1,15 @@ -/** - * Session/Turn 运行时:负责多轮对话状态、工具调度、日志事件写入与 token 统计。 - */ +/** @file Session/Turn 运行时核心:负责 ReAct 循环、工具调度与事件记录。 */ import { randomUUID } from 'node:crypto' import { createHistoryEvent } from '@memo/core/runtime/history' import { withDefaultDeps } from '@memo/core/runtime/defaults' import { parseAssistant } from '@memo/core/utils/utils' import type { CallToolResult } from '@modelcontextprotocol/sdk/types' import type { + ChatMessage, AgentSession, AgentSessionDeps, AgentSessionOptions, AgentStepTrace, - ChatMessage, HistoryEvent, HistorySink, LLMResponse, @@ -19,10 +17,16 @@ import type { SessionMode, TokenCounter, TokenUsage, + ToolRegistry, TurnResult, TurnStatus, } from '@memo/core/types' -import type { ToolRegistry } from '@memo/core/types' +import { + buildHookRunners, + runHook, + snapshotHistory, + type HookRunnerMap, +} from '@memo/core/runtime/hooks' const DEFAULT_SESSION_MODE: SessionMode = 'interactive' @@ -106,6 +110,7 @@ class AgentSessionImpl implements AgentSession { private sessionUsage: TokenUsage = emptyUsage() private startedAt = Date.now() private maxSteps: number + private hooks: HookRunnerMap constructor( private deps: AgentSessionDeps & { @@ -123,6 +128,7 @@ class AgentSessionImpl implements AgentSession { this.tokenCounter = tokenCounter this.sinks = deps.historySinks ?? [] this.maxSteps = maxSteps + this.hooks = buildHookRunners(deps) } /** 写入 Session 启动事件,记录配置与 token 限制。 */ @@ -154,6 +160,12 @@ class AgentSessionImpl implements AgentSession { content: input, meta: { tokens: { prompt: promptTokens } }, }) + await runHook(this.hooks, 'onTurnStart', { + sessionId: this.id, + turn, + input, + history: snapshotHistory(this.history), + }) let finalText = '' let status: TurnStatus = 'ok' @@ -176,6 +188,16 @@ class AgentSessionImpl implements AgentSession { role: 'assistant', meta: { tokens: { prompt: estimatedPrompt } }, }) + await runHook(this.hooks, 'onFinal', { + sessionId: this.id, + turn, + step, + finalText: limitMessage, + status, + errorMessage, + turnUsage: { ...turnUsage }, + steps, + }) break } if (this.options.warnPromptTokens && estimatedPrompt > this.options.warnPromptTokens) { @@ -201,6 +223,16 @@ class AgentSessionImpl implements AgentSession { finalText = msg errorMessage = msg await this.emitEvent('final', { turn, content: msg, role: 'assistant' }) + await runHook(this.hooks, 'onFinal', { + sessionId: this.id, + turn, + step, + finalText, + status, + errorMessage, + turnUsage: { ...turnUsage }, + steps, + }) break } @@ -247,6 +279,16 @@ class AgentSessionImpl implements AgentSession { role: 'assistant', meta: { tokens: stepUsage }, }) + await runHook(this.hooks, 'onFinal', { + sessionId: this.id, + turn, + step, + finalText, + status, + tokenUsage: stepUsage, + turnUsage: { ...turnUsage }, + steps, + }) break } @@ -256,6 +298,13 @@ class AgentSessionImpl implements AgentSession { step, meta: { tool: parsed.action.tool, input: parsed.action.input }, }) + await runHook(this.hooks, 'onAction', { + sessionId: this.id, + turn, + step, + action: parsed.action, + history: snapshotHistory(this.history), + }) const tool = this.deps.tools[parsed.action.tool] let observation: string @@ -283,14 +332,20 @@ class AgentSessionImpl implements AgentSession { if (lastStep) { lastStep.observation = observation } - this.deps.onObservation?.(parsed.action.tool, observation, step) - await this.emitEvent('observation', { turn, step, content: observation, meta: { tool: parsed.action.tool }, }) + await runHook(this.hooks, 'onObservation', { + sessionId: this.id, + turn, + step, + tool: parsed.action.tool, + observation, + history: snapshotHistory(this.history), + }) continue } @@ -311,6 +366,15 @@ class AgentSessionImpl implements AgentSession { content: finalText, role: 'assistant', }) + await runHook(this.hooks, 'onFinal', { + sessionId: this.id, + turn, + finalText, + status, + errorMessage, + turnUsage: { ...turnUsage }, + steps, + }) } await this.emitEvent('turn_end', { diff --git a/packages/core/src/runtime/session_hooks.test.ts b/packages/core/src/runtime/session_hooks.test.ts new file mode 100644 index 0000000..d976abf --- /dev/null +++ b/packages/core/src/runtime/session_hooks.test.ts @@ -0,0 +1,79 @@ +/** @file Session Hook & Middleware 行为测试。 */ +import assert from 'node:assert' +import { describe, test } from 'bun:test' +import { z } from 'zod' +import { createAgentSession, createTokenCounter } from '@memo/core' +import type { McpTool } from '@memo/tools/tools/types' + +const echoTool: McpTool<{ text: string }> = { + name: 'echo', + description: 'echo input', + inputSchema: z.object({ text: z.string() }), + execute: async ({ text }) => ({ + content: [{ type: 'text', text: `echo:${text}` }], + }), +} + +describe('session hooks & middleware', () => { + test('invokes hooks and middlewares in order', async () => { + const outputs = ['```json\n{"tool":"echo","input":{"text":"foo"}}\n```', '{"final":"done"}'] + const hookLog: string[] = [] + const session = await createAgentSession( + { + tools: { echo: echoTool }, + callLLM: async () => ({ + content: outputs.shift() ?? JSON.stringify({ final: 'done' }), + }), + historySinks: [], + tokenCounter: createTokenCounter('cl100k_base'), + hooks: { + onTurnStart: ({ turn }) => { + hookLog.push(`hook:start:${turn}`) + }, + onAction: ({ step, action }) => { + hookLog.push(`hook:action:${step}:${action.tool}`) + }, + onObservation: ({ step, tool, observation }) => { + hookLog.push(`hook:obs:${step}:${tool}:${observation}`) + }, + onFinal: ({ status, finalText }) => { + hookLog.push(`hook:final:${status}:${finalText}`) + }, + }, + middlewares: [ + { + onTurnStart: ({ turn }) => { + hookLog.push(`mw:start:${turn}`) + }, + onAction: ({ step, action }) => { + hookLog.push(`mw:action:${step}:${action.tool}`) + }, + onObservation: ({ step, tool, observation }) => { + hookLog.push(`mw:obs:${step}:${tool}:${observation}`) + }, + onFinal: ({ status, finalText }) => { + hookLog.push(`mw:final:${status}:${finalText}`) + }, + }, + ], + }, + { maxSteps: 4 }, + ) + try { + const result = await session.runTurn('question') + assert.strictEqual(result.finalText, 'done') + assert.deepStrictEqual(hookLog, [ + 'hook:start:1', + 'mw:start:1', + 'hook:action:0:echo', + 'mw:action:0:echo', + 'hook:obs:0:echo:echo:foo', + 'mw:obs:0:echo:echo:foo', + 'hook:final:ok:done', + 'mw:final:ok:done', + ]) + } finally { + await session.close() + } + }) +}) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7d65c26..97f2415 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,3 +1,4 @@ +/** @file Core 与 Runtime 共享的公共类型声明(会被 UI/Tools 复用)。 */ import type { McpTool } from '@memo/tools/tools/types' /** @@ -95,8 +96,10 @@ export type AgentDeps = { loadPrompt?: () => Promise /** 每次 assistant 输出时的回调。 */ onAssistantStep?: (content: string, step: number) => void - /** 每次工具 observation 返回时的回调。 */ - onObservation?: (tool: string, observation: string, step: number) => void + /** Hook 集合:注入一次性的生命周期监听器。 */ + hooks?: AgentHooks + /** 中间件列表:可注册多个 Hook 实现。 */ + middlewares?: AgentMiddleware[] /** 资源释放回调(如关闭 MCP Client)。 */ dispose?: () => Promise } @@ -151,6 +154,55 @@ export type TurnResult = { tokenUsage: TokenUsage } +export type TurnStartHookPayload = { + sessionId: string + turn: number + input: string + history: ChatMessage[] +} + +export type ActionHookPayload = { + sessionId: string + turn: number + step: number + action: NonNullable + history: ChatMessage[] +} + +export type ObservationHookPayload = { + sessionId: string + turn: number + step: number + tool: string + observation: string + history: ChatMessage[] +} + +export type FinalHookPayload = { + sessionId: string + turn: number + step?: number + finalText: string + status: TurnStatus + errorMessage?: string + tokenUsage?: TokenUsage + turnUsage: TokenUsage + steps: AgentStepTrace[] +} + +export type AgentHookHandler = (payload: Payload) => Promise | void + +export type AgentHooks = { + onTurnStart?: AgentHookHandler + onAction?: AgentHookHandler + onObservation?: AgentHookHandler + onFinal?: AgentHookHandler +} + +export type AgentMiddleware = AgentHooks & { + name?: string +} + /** Session 对象,持有历史并可执行多轮对话。 */ export type AgentSession = { /** Session 唯一标识。 */ diff --git a/packages/core/src/utils/tokenizer.ts b/packages/core/src/utils/tokenizer.ts index 8c89e33..94c0324 100644 --- a/packages/core/src/utils/tokenizer.ts +++ b/packages/core/src/utils/tokenizer.ts @@ -1,3 +1,4 @@ +/** @file tiktoken 封装:用于提示词/回复 token 统计与编码管理。 */ import { encoding_for_model, get_encoding, type Tiktoken } from '@dqbd/tiktoken' import type { ChatMessage, TokenCounter } from '@memo/core/types' diff --git a/packages/core/src/utils/utils.ts b/packages/core/src/utils/utils.ts index 538dbd9..5d6d077 100644 --- a/packages/core/src/utils/utils.ts +++ b/packages/core/src/utils/utils.ts @@ -1,3 +1,4 @@ +/** @file Runtime 公用的解析辅助函数集合。 */ import type { ParsedAssistant } from '@memo/core/types' /** diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 3ca23e9..d6845a4 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -6,6 +6,7 @@ import { globTool } from '@memo/tools/tools/glob' import { grepTool } from '@memo/tools/tools/grep' import { saveMemoryTool } from '@memo/tools/tools/save_memory' import { todoTool } from '@memo/tools/tools/todo' +import { timeTool } from '@memo/tools/tools/time' import { readTool } from '@memo/tools/tools/read' import { writeTool } from '@memo/tools/tools/write' @@ -22,6 +23,7 @@ export const TOOLKIT: Record> = { grep: grepTool, webfetch: webfetchTool, save_memory: saveMemoryTool, + time: timeTool, todo: todoTool, } diff --git a/packages/tools/src/tools/run_bun.test.ts b/packages/tools/src/tools/run_bun.test.ts index 6bdde69..008c701 100644 --- a/packages/tools/src/tools/run_bun.test.ts +++ b/packages/tools/src/tools/run_bun.test.ts @@ -5,25 +5,40 @@ import { tmpdir } from 'node:os' import { flattenText } from '@memo/tools/tools/mcp' import { runBunTool } from '@memo/tools/tools/run_bun' -let originalTmpDir: string | undefined -let tempDir: string +const hasSandbox = (() => { + if (process.env.MEMO_RUN_BUN_SANDBOX) { + return true + } + if (process.platform === 'linux') { + return Boolean(Bun.which('bwrap')) + } + if (process.platform === 'darwin') { + return Boolean(Bun.which('sandbox-exec')) + } + return false +})() -beforeAll(async () => { - originalTmpDir = process.env.TMPDIR - tempDir = await mkdtemp(join(tmpdir(), 'memo-run-bun-test-')) - process.env.TMPDIR = tempDir -}) +const describeRunBun = hasSandbox ? describe : describe.skip -afterAll(async () => { - if (originalTmpDir === undefined) { - delete process.env.TMPDIR - } else { - process.env.TMPDIR = originalTmpDir - } - await rm(tempDir, { recursive: true, force: true }) -}) +describeRunBun('run_bun tool', () => { + let originalTmpDir: string | undefined + let tempDir: string + + beforeAll(async () => { + originalTmpDir = process.env.TMPDIR + tempDir = await mkdtemp(join(tmpdir(), 'memo-run-bun-test-')) + process.env.TMPDIR = tempDir + }) + + afterAll(async () => { + if (originalTmpDir === undefined) { + delete process.env.TMPDIR + } else { + process.env.TMPDIR = originalTmpDir + } + await rm(tempDir, { recursive: true, force: true }) + }) -describe('run_bun tool', () => { test('supports top-level await and TS syntax', async () => { const code = ` type User = { name: string } diff --git a/packages/tools/src/tools/run_bun.ts b/packages/tools/src/tools/run_bun.ts index f98769b..6dafd9b 100644 --- a/packages/tools/src/tools/run_bun.ts +++ b/packages/tools/src/tools/run_bun.ts @@ -1,8 +1,9 @@ import { z } from 'zod' -import { randomUUID } from 'crypto' import type { McpTool } from '@memo/tools/tools/types' import { textResult } from '@memo/tools/tools/mcp' -import { unlink } from 'node:fs/promises' +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' const BUN_INPUT_SCHEMA = z .object({ @@ -22,19 +23,28 @@ export const runBunTool: McpTool = { '在临时文件中运行 Bun (JS/TS) 代码。支持 top-level await。使用 console.log 输出结果。', inputSchema: BUN_INPUT_SCHEMA, execute: async ({ code }) => { - // 创建临时文件路径 - const tmpDir = process.env.TMPDIR || '/tmp' - const tmpFilePath = `${tmpDir}/memo-run-bun-${randomUUID()}.ts` + const baseTmp = process.env.TMPDIR || tmpdir() + const runDir = await mkdtemp(join(baseTmp, 'memo-run-bun-')) + const tmpFilePath = join(runDir, 'main.ts') + const allowNetwork = process.env.MEMO_RUN_BUN_ALLOW_NET === '1' try { // 将代码写入临时文件 await Bun.write(tmpFilePath, code) - // 启动 Bun 运行文件 - const proc = Bun.spawn(['bun', 'run', tmpFilePath], { + const sandboxEnv = createSandboxEnv(runDir, allowNetwork) + const sandbox = await resolveSandbox({ + entryFile: tmpFilePath, + runDir, + env: sandboxEnv, + allowNetwork, + }) + + // 启动 Bun 运行文件(在沙箱内) + const proc = Bun.spawn([sandbox.command, ...sandbox.args], { stdout: 'pipe', stderr: 'pipe', - env: { ...process.env, FORCE_COLOR: '0' }, // 禁用颜色以便更清晰地解析 + env: sandbox.env, }) const [stdout, stderr] = await Promise.all([ @@ -47,15 +57,184 @@ export const runBunTool: McpTool = { } catch (err) { return textResult(`run_bun failed: ${(err as Error).message}`, true) } finally { - // 清理:尝试删除临时文件 + // 清理:尝试删除临时目录 try { - const file = Bun.file(tmpFilePath) - if (await file.exists()) { - await unlink(tmpFilePath) - } + await rm(runDir, { recursive: true, force: true }) } catch { // 忽略清理错误 } } }, } + +type SandboxContext = { + entryFile: string + runDir: string + env: Record + allowNetwork: boolean +} + +type SandboxSpec = { + command: string + args: string[] + env: Record +} + +const createSandboxEnv = (runDir: string, allowNetwork: boolean): Record => { + const env = sanitizeEnv() + env.TMPDIR = runDir + env.HOME = runDir + env.FORCE_COLOR = '0' + env.MEMO_RUN_BUN_ALLOW_NET = allowNetwork ? '1' : '0' + return env +} + +const sanitizeEnv = (): Record => { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === 'string') { + env[key] = value + } + } + return env +} + +const resolveSandbox = async (context: SandboxContext): Promise => { + const custom = resolveCustomSandbox(context) + if (custom) { + return custom + } + + if (process.platform === 'linux') { + return resolveLinuxSandbox(context) + } + + if (process.platform === 'darwin') { + return resolveDarwinSandbox(context) + } + + throw new Error( + 'run_bun sandbox is not configured for this platform. Provide MEMO_RUN_BUN_SANDBOX or use Linux/macOS.', + ) +} + +const resolveCustomSandbox = (context: SandboxContext): SandboxSpec | null => { + const raw = process.env.MEMO_RUN_BUN_SANDBOX + if (!raw) { + return null + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error('MEMO_RUN_BUN_SANDBOX must be a JSON array of command and args') + } + + if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((item) => typeof item !== 'string')) { + throw new Error('MEMO_RUN_BUN_SANDBOX must describe command and args, e.g. ["/usr/bin/env","-i"]') + } + + const [command, ...args] = parsed as string[] + const replaced = [command, ...args].map((value) => applySandboxTemplate(value as string, context)) + + return { + command: replaced[0]!, + args: replaced.slice(1), + env: context.env, + } +} + +const resolveLinuxSandbox = ({ entryFile, runDir, env, allowNetwork }: SandboxContext): SandboxSpec => { + const bwrap = Bun.which('bwrap') + if (!bwrap) { + throw new Error( + 'run_bun requires bubblewrap (bwrap) on Linux or MEMO_RUN_BUN_SANDBOX for a custom sandbox runner', + ) + } + + const args = [ + '--die-with-parent', + '--unshare-user', + '--unshare-pid', + '--unshare-uts', + '--unshare-ipc', + '--ro-bind', + '/', + '/', + '--bind', + runDir, + runDir, + '--dev-bind', + '/dev', + '/dev', + '--proc', + '/proc', + '--tmpfs', + '/tmp', + '--chdir', + runDir, + ] + + if (!allowNetwork) { + args.push('--unshare-net') + } + + args.push('bun', 'run', entryFile) + + return { + command: bwrap, + args, + env, + } +} + +const resolveDarwinSandbox = async ({ + entryFile, + runDir, + env, + allowNetwork, +}: SandboxContext): Promise => { + const sandboxExec = Bun.which('sandbox-exec') + if (!sandboxExec) { + throw new Error( + 'sandbox-exec is required on macOS or specify MEMO_RUN_BUN_SANDBOX for a custom sandbox runner', + ) + } + + let resolvedDir = runDir + try { + resolvedDir = await realpath(runDir) + } catch { + // ignore realpath failures + } + + const escapedDir = escapeForSandboxProfile(resolvedDir) + const profileParts = [ + '(version 1)', + '(allow default)', + '(deny file-write*)', + `(allow file-write* (subpath "${escapedDir}"))`, + '(allow file-write* (literal "/dev/null"))', + ] + + if (!allowNetwork) { + profileParts.splice(2, 0, '(deny network*)') + } + + const profile = profileParts.join('\n') + + return { + command: sandboxExec, + args: ['-p', profile, 'bun', 'run', entryFile], + env, + } +} + +const applySandboxTemplate = (value: string, context: SandboxContext): string => + value + .replaceAll('{{entryFile}}', context.entryFile) + .replaceAll('{{runDir}}', context.runDir) + .replaceAll('{{allowNetwork}}', context.allowNetwork ? '1' : '0') + +const escapeForSandboxProfile = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') diff --git a/packages/tools/src/tools/time.test.ts b/packages/tools/src/tools/time.test.ts new file mode 100644 index 0000000..06d53ca --- /dev/null +++ b/packages/tools/src/tools/time.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert' +import { describe, test } from 'bun:test' +import { timeTool } from '@memo/tools/tools/time' + +describe('time tool', () => { + test('requires empty input object', () => { + const ok = timeTool.inputSchema.safeParse({}) + assert.strictEqual(ok.success, true) + + const invalid = timeTool.inputSchema.safeParse({ extra: true }) + assert.strictEqual(invalid.success, false) + }) + + test('returns iso/utc/timezone payload', async () => { + const before = Date.now() + const res = await timeTool.execute({}) + const after = Date.now() + + const text = res.content?.[0]?.type === 'text' ? res.content[0].text : '' + assert.ok(text.length > 0, 'should return payload text') + const payload = JSON.parse(text) + + assert.strictEqual(typeof payload.iso, 'string') + assert.ok(payload.iso.includes('T'), 'iso should contain T split') + assert.strictEqual(typeof payload.utc_iso, 'string') + assert.ok(payload.utc_iso.endsWith('Z'), 'utc iso should be Zulu time') + assert.strictEqual(typeof payload.epoch_ms, 'number') + assert.strictEqual(Math.floor(payload.epoch_ms / 1000), payload.epoch_seconds) + assert.ok( + payload.epoch_ms >= before - 1000 && payload.epoch_ms <= after + 1000, + 'timestamp should be near now', + ) + + const offsetFromEnv = -new Date().getTimezoneOffset() + const normalizedOffset = offsetFromEnv === 0 ? 0 : offsetFromEnv + assert.strictEqual(payload.timezone.offset_minutes, normalizedOffset) + assert.strictEqual(typeof payload.timezone.offset, 'string') + assert.ok(payload.iso.endsWith(payload.timezone.offset), 'local iso should include offset') + assert.strictEqual(typeof payload.timezone.name, 'string') + + assert.strictEqual(typeof payload.day_of_week, 'string') + assert.strictEqual(typeof payload.human_readable, 'string') + assert.ok( + payload.human_readable.includes('UTC'), + 'human readable should reference UTC offset', + ) + assert.ok( + payload.human_readable.includes(payload.timezone.offset), + 'human readable should include offset', + ) + assert.strictEqual(payload.source, 'local_system_clock') + }) +}) diff --git a/packages/tools/src/tools/time.ts b/packages/tools/src/tools/time.ts new file mode 100644 index 0000000..b735df0 --- /dev/null +++ b/packages/tools/src/tools/time.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' +import type { McpTool } from '@memo/tools/tools/types' +import { textResult } from '@memo/tools/tools/mcp' + +const TIME_INPUT_SCHEMA = z.object({}).strict() + +type TimeInput = z.infer + +function pad(value: number, length = 2) { + return String(value).padStart(length, '0') +} + +function formatOffset(minutes: number) { + const sign = minutes >= 0 ? '+' : '-' + const absMinutes = Math.abs(minutes) + const hours = pad(Math.floor(absMinutes / 60)) + const mins = pad(absMinutes % 60) + return `${sign}${hours}:${mins}` +} + +function formatLocalIso(date: Date, offsetMinutes: number) { + const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + const timePart = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}` + return `${datePart}T${timePart}${formatOffset(offsetMinutes)}` +} + +function formatDate(date: Date) { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +function formatTime(date: Date) { + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +} + +/** time: 返回当前系统时间(ISO/UTC/epoch/timezone 等多视图)。 */ +export const timeTool: McpTool = { + name: 'time', + description: '返回当前系统时间(ISO/UTC/epoch/timezone 等多视图)', + inputSchema: TIME_INPUT_SCHEMA, + execute: async () => { + const now = new Date() + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC' + const offsetMinutes = -now.getTimezoneOffset() + const offsetText = formatOffset(offsetMinutes) + const isoLocal = formatLocalIso(now, offsetMinutes) + const isoUtc = now.toISOString() + const epochMs = now.getTime() + const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(now) + const humanReadable = `${formatDate(now)} ${formatTime(now)} (${weekday}, UTC${offsetText} ${timezone})` + + const payload = { + iso: isoLocal, + utc_iso: isoUtc, + epoch_ms: epochMs, + epoch_seconds: Math.floor(epochMs / 1000), + timezone: { + name: timezone, + offset_minutes: offsetMinutes, + offset: offsetText, + }, + day_of_week: weekday, + human_readable: humanReadable, + source: 'local_system_clock', + } + + return textResult(JSON.stringify(payload)) + }, +} diff --git a/packages/tools/src/tools/todo.test.ts b/packages/tools/src/tools/todo.test.ts index af80165..e4cd092 100644 --- a/packages/tools/src/tools/todo.test.ts +++ b/packages/tools/src/tools/todo.test.ts @@ -44,8 +44,8 @@ describe('todo tool', () => { const addRes = await todoTool.execute({ type: 'add', todos: [ - { content: '修复认证bug', status: 'pending', activeForm: '正在修复认证bug' }, - { content: '运行测试', status: 'pending', activeForm: '正在运行测试' }, + { content: '修复认证bug', status: 'pending' }, + { content: '运行测试', status: 'pending' }, ], }) const addPayload = JSON.parse( @@ -56,7 +56,7 @@ describe('todo tool', () => { const updateRes = await todoTool.execute({ type: 'update', - todos: [{ id, content: '修复认证bug', status: 'in_progress', activeForm: '修复中' }], + todos: [{ id, content: '修复认证bug', status: 'in_progress' }], }) const updatePayload = JSON.parse( updateRes.content?.[0]?.type === 'text' ? updateRes.content[0].text : '{}', @@ -78,7 +78,6 @@ describe('todo tool', () => { const todos = Array.from({ length: 11 }, (_, i) => ({ content: `t${i}`, status: 'pending' as const, - activeForm: `doing t${i}`, })) const res = await todoTool.execute({ type: 'add', todos }) const text = res.content?.[0]?.type === 'text' ? res.content[0].text : '' @@ -88,7 +87,7 @@ describe('todo tool', () => { test('rejects update with missing id', async () => { const res = await todoTool.execute({ type: 'update', - todos: [{ id: 'missing', content: 'x', status: 'pending', activeForm: 'x' }], + todos: [{ id: 'missing', content: 'x', status: 'pending' }], }) const text = res.content?.[0]?.type === 'text' ? res.content[0].text : '' assert.ok(res.isError, 'should be error') @@ -98,7 +97,7 @@ describe('todo tool', () => { test('rejects invalid status enum', () => { const parsed = todoTool.inputSchema.safeParse({ type: 'add', - todos: [{ content: 'x', status: 'done', activeForm: 'doing' }], + todos: [{ content: 'x', status: 'done' }], }) assert.strictEqual(parsed.success, false) }) diff --git a/packages/tools/src/tools/webfetch.ts b/packages/tools/src/tools/webfetch.ts index f1189f3..d84edd3 100644 --- a/packages/tools/src/tools/webfetch.ts +++ b/packages/tools/src/tools/webfetch.ts @@ -59,7 +59,7 @@ const htmlToPlainText = (html: string) => { .split('\n') .map((line) => line.trim().replace(/[ \t]{2,}/g, ' ')) const normalizedLines = lines.filter( - (line, idx) => line.length > 0 || (idx > 0 && lines[idx - 1].length > 0), + (line, idx) => line.length > 0 || (idx > 0 && (lines[idx - 1]?.length ?? 0) > 0), ) return normalizedLines.join('\n').trim() } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2188ad0..b2ee8b1 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -116,8 +116,10 @@ async function runInteractive(parsed: ParsedArgs) { } process.stdout.write(text) }, - onObservation: (tool: string, observation: string, step: number) => { - console.log(`\n[第 ${step + 1} 步 工具=${tool}]\n${observation}\n`) + hooks: { + onObservation: ({ tool, step }) => { + console.log(`\n工具=${tool} step=${step}\n`) + }, }, } @@ -141,10 +143,8 @@ async function runInteractive(parsed: ParsedArgs) { break } - console.log(`\n用户: ${trimmed}`) + console.log(`\n用户: ${trimmed}\n`) const turnResult = await session.runTurn(trimmed) - console.log('\n=== 最终回答 ===') - console.log(turnResult.finalText) console.log( `\n[tokens] prompt=${turnResult.tokenUsage.prompt} completion=${turnResult.tokenUsage.completion} total=${turnResult.tokenUsage.total}`, )