diff --git a/CLAUDE.md b/CLAUDE.md index 0d8f064..f31ad27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ bun run format # 代码格式化 bun run format:check # 检查格式 # 调试 -bun run packages/ui/src/index.ts "问题" +bun run packages/ui/src/index.tsx "问题" ``` **环境变量**: @@ -168,7 +168,7 @@ main() → parseArgs() → ensureProviderConfig() ## 关键文件 -- `packages/ui/src/index.ts` - 入口 +- `packages/ui/src/index.tsx` - 入口 - `packages/core/src/runtime/session.ts` - ReAct 核心 - `packages/core/src/utils/utils.ts` - JSON 解析 - `packages/core/src/config/config.ts` - 配置管理 diff --git a/README.md b/README.md index eecbc72..00cfd45 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,286 @@ # memo-cli -在终端运行的 ReAct Agent,基于 Bun + TypeScript。支持多轮对话(Session/Turn)、JSONL 结构化日志、内置工具调用,默认使用 DeepSeek(OpenAI 兼容接口)。 +终端内的 ReAct Agent,基于 Bun + TypeScript。它附带 Session/Turn 状态机、标准 JSON 协议提示、结构化 JSONL 日志、内置工具编排,并默认对接 DeepSeek(OpenAI 兼容接口)。你可以按需接入任意 OpenAI 兼容 Provider 以及 MCP 工具。 -## 特性 +**全新 TUI 界面**:提供现代化的终端用户界面,支持实时流式输出、工具调用可视化、token 使用统计和交互式命令。 -- 多轮对话:交互式 REPL,`--once` 支持单轮退出。 -- 工具驱动:内置 bash/run_bun/read/write/edit/glob/grep/webfetch(HTML 自动转纯文本)、time、save_memory、todo,按 ReAct 协议调用。 -- 结构化日志:默认写入 `history/.jsonl`,可携带 token 计数与事件。 -- 可配置 token 预算:本地 tiktoken 预估 + LLM usage 对账,支持提示超限预警/拒绝。 +## 预备知识 + +- 需要 [Bun](https://bun.sh/)(建议 1.1+)和可用的 OpenAI 兼容 API Key。 +- 配置、历史日志与缓存写在 `~/.memo/`,设置 `MEMO_HOME` 可以迁移目录。 +- 第一次运行会引导生成 `~/.memo/config.toml` 并选择默认 Provider。 + +## 核心特性 + +- **现代化 TUI 界面**:基于 React + Ink 构建的终端用户界面,支持实时流式输出、工具调用可视化、token 使用统计和交互式命令。 +- **Session/Turn 多轮控制**:交互式 TUI + `--once` 单轮模式,支持会话恢复。 +- **JSON 协议 ReAct**:强制模型输出 `{"thought":"","action":{...}}` 或 `{"final":""}`,驱动工具调用与回答。 +- **结构化日志**:所有事件写入 JSONL(token 计数、工具 observation、LLM 元数据)。 +- **Token 预算**:用 `tiktoken` 估算 prompt,结合 LLM usage,对超限做提示或拒绝。 +- **内置工具 + MCP 扩展**:提供文件/系统/网络/记忆工具,并自动注入配置的 MCP 工具前缀。 +- **智能交互**:支持命令补全、输入历史、快捷键操作和丰富的 Slash 命令。 + +## 架构概览 + +采用清晰的关注点分离架构:核心逻辑位于 `packages/core`,UI 层负责交互与可视化。 + +1. **配置层**(`config/`):读取/写入 `~/.memo/config.toml`,选择 Provider,生成会话路径。 +2. **运行时**(`runtime/`):`session.ts` 执行 ReAct 循环,`history.ts` 写事件,`prompt.ts` 维护 JSON 协议提示。 +3. **默认依赖**(`runtime/defaults.ts`):拼装工具注册表、OpenAI SDK 的 `callLLM`、token counter、`maxSteps` 与日志 sink。 +4. **Hooks & Middlewares**:通过 `createAgentSession` 的 `onAssistantStep`、`hooks.onAction`、`onFinal` 等回调订阅生命周期。 +5. **UI 层**(`packages/ui/`):基于 React + Ink 的 TUI 界面,提供实时可视化、交互式命令和状态管理。 + +```ts +import { createAgentSession } from '@memo/core' + +const session = await createAgentSession({ onAssistantStep: console.log }) +await session.runTurn('你好') // 运行完整 ReAct 循环 +await session.close() +``` + +## TUI 界面特性 + +memo-cli 提供现代化的终端用户界面,包含以下特性: + +### 界面布局 + +- **HeaderBar**:显示会话信息、Provider/Model、当前目录 +- **MainContent**:展示对话历史、工具调用、流式输出 +- **StatusBar**:实时显示 Token 使用情况、步骤计数 +- **InputPrompt**:智能输入框,支持命令补全和历史搜索 + +### 可视化功能 + +- **实时流式输出**:助手回答逐字显示,支持打字机效果 +- **工具调用卡片**:工具执行状态可视化(pending/executing/success/error) +- **Token 统计**:实时显示 prompt/completion/total token 使用量 +- **执行时长**:显示每个 Turn 的处理时间 + +### 交互设计 + +- **快捷键支持**:Ctrl+C(中断/退出)、Ctrl+L(清屏)、上下键(历史) +- **Slash 命令**:`/help`、`/exit`、`/clear`、`/tools`、`/config` 等 +- **智能补全**:输入时自动提示可用命令和工具 +- **历史管理**:保存和检索输入历史 + +## 内置工具概览 + +- **文件系统**:`read` / `write` / `edit` / `glob` / `grep`,提供偏移、上下文、全局替换等能力。 +- **系统执行**:`bash` 直接运行 Shell;`run_bun` 在沙箱里执行 JS/TS(bubblewrap 或 `sandbox-exec`,可配置网络)。 +- **网络获取**:`webfetch` 支持 http/https/data,10 秒超时、512 KB 限制,自动清洗 HTML。 +- **辅助工具**:`save_memory`(写入 `~/.memo/memo.md`)、`todo` 管理、`time` 查询。 +- **MCP 外部工具**:支持 stdio 或 Streamable HTTP,工具名前会加 `_` 前缀自动注入系统提示词。 + +详见 `docs/tool/*.md`。 ## 快速开始 -1. 安装依赖 +1. **安装依赖** + ```bash bun install ``` -2. 配置 API Key(优先 OPENAI_API_KEY,回退 DEEPSEEK_API_KEY) - ```bash - export DEEPSEEK_API_KEY=your_key_here - ``` -3. 启动一次性对话 + +2. **配置 API Key** + ```bash - bun start "你的问题" --once + export OPENAI_API_KEY=your_key # 或 DEEPSEEK_API_KEY ``` -4. 进入交互式 REPL(多轮) + +3. **首次运行** + ```bash bun start - # 输入 /exit 退出 + # 将引导填写 provider/model/base_url,并在 ~/.memo/config.toml 保存 ``` -### CLI 参数 +## CLI 使用 -- `--once`:单轮对话后退出(默认交互式多轮)。 +memo-cli 支持两种运行模式,根据终端环境自动选择: -### 工具说明 +### 交互式 TUI 模式(默认) -- **run_bun**:代码解释器工具,可以在临时文件中运行任意 Bun (JS/TS) 代码,支持 top-level await,使用 `console.log` 输出结果。 -- **webfetch**:网页抓取工具,支持 http/https/data 协议,具有 10 秒超时和 512KB 大小限制,能自动将 HTML 转换为纯文本。 -- 更多工具详情请查看 `docs/tool/` 目录下的文档。 +在支持 TTY 的终端中,自动启动现代化 TUI 界面: -## 外部 MCP Server +```bash +bun start +``` -- 配置文件:`~/.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 # 默认开启 - ``` -- 保存配置后重启 memo,会在系统提示词中注入外部工具列表(工具名前会带 `_` 前缀)。 +**TUI 特性**: + +- 实时流式输出显示 +- 工具调用可视化 +- Token 使用统计 +- 交互式 Slash 命令 +- 输入历史和补全 + +### 单轮纯文本模式 + +使用 `--once` 参数或非 TTY 环境时,输出纯文本结果: + +```bash +bun start "你的问题" --once +``` + +**纯文本模式**: + +- 简洁的文本输出 +- 适合脚本集成 +- 便于日志记录 +- 保持向后兼容 + +### 构建与部署 + +```bash +# 构建 CLI 应用 +bun build + +# 生成独立二进制文件 +bun run build:binary # 输出 ./memo +``` + +常用参数:`--once` 控制单轮模式;`--session `(若 UI 已暴露)可恢复历史 session。 + +## TUI 快捷键与命令 + +### 快捷键 + +- **Enter**:提交输入 +- **Shift+Enter**:输入换行 +- **Up/Down**:浏览输入历史 +- **Ctrl+C**:中断当前操作或退出程序 +- **Ctrl+L**:清屏 + +### Slash 命令 + +- `/help`:显示帮助信息和可用命令 +- `/exit`:退出当前会话 +- `/clear`:清除屏幕内容 +- `/tools`:列出所有可用工具(内置 + MCP) +- `/config`:显示配置文件路径和当前 Provider 信息 +- `/memory`:显示记忆文件位置和摘要(如有) + +### 输入特性 + +- **智能补全**:输入时自动提示命令和工具名 +- **历史搜索**:支持输入历史检索 +- **多行输入**:支持 Shift+Enter 输入多行内容 + +## 配置详解 + +`~/.memo/config.toml` 管理 Provider、MCP 与运行选项,`MEMO_HOME` 可以重定向路径。 + +```toml +current_provider = "deepseek" +max_steps = 100 +stream_output = false + +[[providers]] +name = "deepseek" +env_api_key = "DEEPSEEK_API_KEY" +model = "deepseek-chat" +base_url = "https://api.deepseek.com" +``` + +MCP 服务器示例: + +```toml +[mcp_servers.local_tools] +command = "/path/to/mcp-server" +args = [] + +[mcp_servers.bing_cn] +type = "streamable_http" +url = "https://mcp.api-inference.modelscope.net/496703c5b3ff47/mcp" +# headers = { Authorization = "Bearer xxx" } +# fallback_to_sse = true # 默认开启 +``` + +API Key 优先级:`当前 provider 的 env_api_key` > `OPENAI_API_KEY` > `DEEPSEEK_API_KEY`。缺失时 CLI 会提示交互输入并写入配置。 + +## Session、日志与 Token + +- **日志路径**:`~/.memo/sessions//__.jsonl`。 +- **事件类型**:`session_start/turn_start/assistant/action/observation/final/turn_end/session_end`,可回放任意一步。 +- **Token 统计**:Prompt & completion 通过 `tiktoken` 估算,并在 UI 中展示本轮预算。 +- **Max Steps 防护**:默认 100,可在配置文件调整以避免无限工具循环。 ## 项目结构 -- `packages/core` - - `config/`:常量、配置加载(~/.memo/config.toml)、路径工具。 - - `runtime/`:Session/Turn 运行时(日志、提示词加载、历史事件、默认依赖补全)。 - - `llm/`:模型适配与 tokenizer(OpenAI 兼容 DeepSeek、tiktoken)。 - - `utils/`:解析工具。 -- `packages/tools`:内置工具集合,统一导出 `TOOLKIT`。 -- `packages/ui`:CLI 入口,组装 Core + Tools 并处理交互。 -- `docs/`:架构与设计文档。 - - `tool/`:每个工具的详细使用说明。 +``` +memo-cli/ +├── packages/ +│ ├── core/ # 配置、Session/Turn 状态机、LLM/工具适配 +│ ├── tools/ # 内置工具实现 +│ └── ui/ # CLI 入口和 TUI 界面 +│ ├── src/ +│ │ ├── tui/ # React + Ink TUI 组件 +│ │ │ ├── components/ # UI 组件 +│ │ │ ├── commands/ # Slash 命令处理 +│ │ │ └── utils/ # 工具函数 +│ │ └── index.tsx # CLI 入口点 +├── docs/ # 架构、设计、内置工具与未来计划 +├── history/ # 运行期生成的 JSONL 示例 +├── dist/ # bun build 输出 +└── memo # bun build --compile 生成的可执行文件 +``` ## 开发脚本 -- 安装依赖:`bun install` -- 运行 CLI:`bun start "问题" --once` -- 格式化:`bun run format` / `bun run format:check` -- 构建:`bun build` -- 构建二进制文件:`bun run build:binary` +### 基础开发 + +- `bun install`:安装所有依赖 +- `bun start`:启动交互式 TUI +- `bun start "问题" --once`:运行单轮纯文本模式 + +### 构建与部署 + +- `bun build`:构建 CLI 应用(产物位于 `dist/`) +- `bun run build:binary`:生成独立二进制文件 `./memo` + +### 代码质量 + +- `bun run format`:使用 Prettier 格式化代码 +- `bun run format:check`:检查代码格式 + +### UI 开发 + +- 修改 `packages/ui/src/tui/` 下的文件来调整 TUI 界面 +- 添加新的 Slash 命令到 `packages/ui/src/tui/commands.ts` +- 自定义组件样式在对应的组件文件中 + +## 文档索引 + +### 核心架构 + +- `docs/core.md`:核心状态机与 Session API +- `docs/multi-turn.md`:多轮策略与会话管理 +- `docs/token-counting.md`:token 计费与估算机制 + +### UI 与设计 + +- `docs/design/memo-cli-ui-design.md`:TUI 界面设计与实现 +- `docs/design/hooks-and-middleware.md`:Hooks 与中间件系统 +- `docs/design/gemini-cli.md`:设计参考与灵感 + +### 工具文档 + +- `docs/tool/*.md`:各内置工具的详细参数与返回值 +- `docs/tool/save_memory.md`:记忆管理工具 +- `docs/tool/glob.md`:文件搜索工具 + +### 未来发展 + +- `docs/future-plan.md`:项目路线图与计划 +- `docs/dev-direction.md`:开发方向与架构演进 + +## 安全特性 + +- `run_bun` 依赖 bubblewrap 或 `sandbox-exec`,并可控制网络访问。 +- `webfetch`、`bash` 等工具限制超时时间、输出大小与允许路径,降低风险。 +- MCP 工具统一通过配置注入,避免在提示词中硬编码密钥。 + +## 贡献与许可证 + +- 贡献流程参见 [CONTRIBUTING.md](CONTRIBUTING.md)。 +- 采用 MIT 许可证。 diff --git a/bun.lock b/bun.lock index 1fd638c..def0978 100644 --- a/bun.lock +++ b/bun.lock @@ -36,10 +36,20 @@ "dependencies": { "@memo/core": "workspace:*", "@memo/tools": "workspace:*", + "ink": "^5.0.0", + "marked": "^17.0.1", + "react": "^18.2.0", + "react-reconciler": "^0.29.0", + "string-width": "^7.2.0", + }, + "devDependencies": { + "@types/react": "^18.2.79", }, }, }, "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "https://registry.npmmirror.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], + "@dqbd/tiktoken": ["@dqbd/tiktoken@1.0.22", "https://registry.npmmirror.com/@dqbd/tiktoken/-/tiktoken-1.0.22.tgz", {}, "sha512-RYhO8xeHkMNX5Ixqf4M1Ve3siCYJY/dI0yLnlX4M4oIEDOvjMIQ+E+3OUpAaZcWTaMtQJzGcDAghYfllpx3i/w=="], "@memo/core": ["@memo/core@workspace:packages/core"], @@ -54,12 +64,24 @@ "@types/node": ["@types/node@24.10.1", "https://registry.npmmirror.com/@types/node/-/node-24.10.1.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.27", "https://registry.npmmirror.com/@types/react/-/react-18.3.27.tgz", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="], + "accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "ajv": ["ajv@8.17.1", "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-escapes": ["ansi-escapes@7.2.0", "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.2.0.tgz", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], + + "ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "auto-bind": ["auto-bind@5.0.1", "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "body-parser": ["body-parser@2.2.1", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.1.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], "bun-types": ["bun-types@1.3.3", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.3.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], @@ -70,10 +92,22 @@ "call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "cli-boxes": ["cli-boxes@3.0.0", "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-3.0.0.tgz", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-cursor": ["cli-cursor@4.0.0", "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-4.0.0.tgz", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-truncate": ["cli-truncate@4.0.0", "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-4.0.0.tgz", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], + + "code-excerpt": ["code-excerpt@4.0.0", "https://registry.npmmirror.com/code-excerpt/-/code-excerpt-4.0.0.tgz", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "content-disposition": ["content-disposition@1.0.1", "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.0.1.tgz", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "https://registry.npmmirror.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "cookie": ["cookie@0.7.2", "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -82,6 +116,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "depd": ["depd@2.0.0", "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -90,16 +126,24 @@ "ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "environment": ["environment@1.1.0", "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-toolkit": ["es-toolkit@1.43.0", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.43.0.tgz", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="], + "escape-html": ["escape-html@1.0.3", "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventsource": ["eventsource@3.0.7", "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -122,6 +166,8 @@ "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-east-asian-width": ["get-east-asian-width@1.4.0", "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], @@ -136,18 +182,32 @@ "iconv-lite": ["iconv-lite@0.7.0", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.0.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + "indent-string": ["indent-string@5.0.0", "https://registry.npmmirror.com/indent-string/-/indent-string-5.0.0.tgz", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ink": ["ink@5.2.1", "https://registry.npmmirror.com/ink/-/ink-5.2.1.tgz", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.22.0", "indent-string": "^5.0.0", "is-in-ci": "^1.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.29.0", "scheduler": "^0.23.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0", "react-devtools-core": "^4.19.1" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], + + "is-in-ci": ["is-in-ci@1.0.0", "https://registry.npmmirror.com/is-in-ci/-/is-in-ci-1.0.0.tgz", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], + "is-promise": ["is-promise@4.0.0", "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jose": ["jose@6.1.3", "https://registry.npmmirror.com/jose/-/jose-6.1.3.tgz", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "loose-envify": ["loose-envify@1.4.0", "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "marked": ["marked@17.0.1", "https://registry.npmmirror.com/marked/-/marked-17.0.1.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -158,6 +218,8 @@ "mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mimic-fn": ["mimic-fn@2.1.0", "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], @@ -170,10 +232,14 @@ "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@5.1.2", "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "openai": ["openai@6.10.0", "https://registry.npmmirror.com/openai/-/openai-6.10.0.tgz", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A=="], "parseurl": ["parseurl@1.3.3", "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "patch-console": ["patch-console@2.0.0", "https://registry.npmmirror.com/patch-console/-/patch-console-2.0.0.tgz", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-to-regexp": ["path-to-regexp@8.3.0", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], @@ -190,12 +256,20 @@ "raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@18.3.1", "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-reconciler": ["react-reconciler@0.29.2", "https://registry.npmmirror.com/react-reconciler/-/react-reconciler-0.29.2.tgz", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg=="], + "require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "restore-cursor": ["restore-cursor@4.0.0", "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-4.0.0.tgz", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + "router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.23.2", "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "send": ["send@1.2.0", "https://registry.npmmirror.com/send/-/send-1.2.0.tgz", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], "serve-static": ["serve-static@2.2.0", "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.0.tgz", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], @@ -214,12 +288,24 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "slice-ansi": ["slice-ansi@7.1.2", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-7.1.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "stack-utils": ["stack-utils@2.0.6", "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "statuses": ["statuses@2.0.2", "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "strip-ansi": ["strip-ansi@7.1.2", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "toml": ["toml@3.0.0", "https://registry.npmmirror.com/toml/-/toml-3.0.0.tgz", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "type-fest": ["type-fest@4.41.0", "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "type-is": ["type-is@2.0.1", "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -232,10 +318,22 @@ "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "widest-line": ["widest-line@5.0.0", "https://registry.npmmirror.com/widest-line/-/widest-line-5.0.0.tgz", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.18.3", "https://registry.npmmirror.com/ws/-/ws-8.18.3.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "yoga-layout": ["yoga-layout@3.2.1", "https://registry.npmmirror.com/yoga-layout/-/yoga-layout-3.2.1.tgz", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "zod": ["zod@4.1.13", "https://registry.npmmirror.com/zod/-/zod-4.1.13.tgz", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.0", "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="], + + "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-5.0.0.tgz", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], + + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], } } diff --git a/docs/design/gemini-cli.md b/docs/design/gemini-cli.md new file mode 100644 index 0000000..3ab5b82 --- /dev/null +++ b/docs/design/gemini-cli.md @@ -0,0 +1,33 @@ +## CLI 入口实现摘要(基于 packages/cli/index.ts 与 packages/cli/src/gemini.tsx) + +### 架构与流程 + +- `index.ts` 为 Node 可执行入口,调用 `main()` 并统一捕获 `FatalError`/未知异常,确保 `runExitCleanup()` 与退出码一致。 +- 启动阶段补丁 I/O 与异常处理:`patchStdio()`、`setupUnhandledRejectionHandler()`,并注册同步清理防止缓冲输出丢失。 +- 配置链路:`loadSettings()` → `migrateDeprecatedSettings()` → `parseArguments()` → `loadCliConfig()`,区分轻量初始化与完整初始化。 +- 运行分支:先处理 SANDBOX/子进程重启 → 完整初始化 → 分流交互 UI 或非交互执行。 +- 事件驱动:`coreEvents` 负责输出与日志流转;`ConsolePatcher` 将 console 输出映射到事件总线。 + +### 功能能力 + +- 参数与校验:支持模型、交互/非交互、输出格式、扩展与会话管理;对 stdin + `--prompt-interactive` 等冲突输入早失败。 +- 认证与安全:默认选择 auth 类型;进入 sandbox 前校验/刷新认证,避免 OAuth 回调受 sandbox 影响。 +- 主题与配置:启动时加载自定义主题并激活;`SettingsContext`/`Keypress`/`Mouse`/`Scroll` 等 Provider 统一管理状态。 +- 会话管理:支持 `--resume` 恢复会话、列出/删除会话;恢复后复用 sessionId 继续记录。 +- 维护任务:清理 checkpoints 与过期会话;启动时异步检测更新并触发自动更新。 + +### 交互与体验 + +- 交互式 UI:Ink 渲染 `AppContainer`,可进入 alternate buffer + 鼠标事件;raw mode 管理输入并设置窗口标题。 +- 终端能力:检测并启用 Kitty 键盘协议,按屏幕阅读器设置决定是否使用 alternate buffer。 +- 非交互:支持 stdin 输入、`/slash` 与 `@include` 预处理;流式输出中处理 tool call、多轮执行与结果回传。 +- 取消与容错:非交互通过 Ctrl+C + `AbortController` 取消;EPIPE 关闭时平滑退出。 +- 输出格式:`text/json/stream-json` 三种格式,`initializeOutputListenersAndFlush()` 确保无监听时仍可输出。 + +### 技术栈与依赖 + +- 运行时:Node.js(process/os/v8/dns/readline/path)。 +- TUI:React + Ink,Context/Hook 驱动终端 UI。 +- CLI 解析:yargs 参数解析与子命令管理。 +- 核心库:`@google/gemini-cli-core` 提供配置、事件、工具执行、遥测、认证与存储能力。 +- 终端控制:ANSI 转义、alternate screen、Kitty 协议检测。 diff --git a/docs/design/memo-cli-ui-design.md b/docs/design/memo-cli-ui-design.md new file mode 100644 index 0000000..52a925c --- /dev/null +++ b/docs/design/memo-cli-ui-design.md @@ -0,0 +1,221 @@ +# memo-cli UI 设计 + +## 1. 目标与范围 + +- 以 Gemini CLI 的 TUI 体验为参考,但保持 UI 薄壳,只消费 Core 的 Session/Turn 与 hooks。 +- 支持两种运行形态:交互式 TUI(默认)与非交互输出(--once 或非 TTY)。 +- 强化工具调用与 token 使用可视化,提升调试与可观测性。 +- 不修改 Core/Tools 协议与行为,UI 不侵入业务逻辑。 + +## 2. 当前能力分析(memo-cli) + +### 2.1 Core 运行时与协议 + +- Session/Turn 状态机,严格 JSON 协议(action 或 final),有 max_steps 保护。 +- hooks 完整:onTurnStart/onAction/onObservation/onFinal。 +- 通过 onAssistantStep 支持流式输出(stream 模式)。 +- JSONL 事件齐全:session_start/turn_start/assistant/action/observation/final/turn_end/session_end。 + +### 2.2 配置与默认依赖 + +- 配置文件:~/.memo/config.toml(可用 MEMO_HOME 覆盖)。 +- Provider 体系:current_provider、providers、model/base_url/env_api_key。 +- 默认装配:工具集、LLM client、tokenizer、history sink。 +- stream_output 由配置驱动;token 预算与阈值来自 SessionOptions。 + +### 2.3 工具与 MCP + +- 内置工具:bash/run_bun/read/write/edit/glob/grep/webfetch/save_memory/time/todo。 +- MCP 外部工具来自 config.mcp*servers,自动注入 prompt,命名为 *。 +- 工具返回以文本为主,UI 需做扁平化展示。 + +### 2.4 可观测性与限制 + +- tiktoken 计数,支持 warnPromptTokens/maxPromptTokens。 +- JSONL 按 session 记录事件,便于审计与回放。 +- Core 可脱离 UI 运行,UI 可独立演进。 + +### 2.5 现有 UI 状态与缺口 + +- 目前仅 readline,支持 --once、/exit、简单输出。 +- 无布局、无消息列表、无工具调用分组、无滚动管理。 +- 无命令补全/历史检索,交互效率低。 + +## 3. 可复用的 Gemini CLI UI 思路 + +- React + Ink 作为 TUI 渲染基础。 +- 类 AppContainer 的 UI 状态管理器。 +- Context/Hook 划分(输入、滚动、会话、快捷键)。 +- InputPrompt:命令补全 + 历史搜索 + 建议列表。 +- 工具调用分组与状态流转(pending/executing/success/error)。 +- alternate buffer 支持,退出时还原终端状态。 + +## 4. UI 架构方案(memo-cli) + +### 4.1 总体流程 + +1. CLI 入口完成配置引导与 provider 初始化。 +2. 创建 AgentSession(传入 hooks 与 onAssistantStep)。 +3. 交互模式启动 TUI 渲染器;非交互直接输出文本。 +4. hooks 触发 UI 状态更新,按 turn/step 渲染。 + +### 4.2 状态模型 + +- SessionState + - sessionId, mode, provider, model, streamOutput, startedAt +- TurnState + - index, userInput, steps[], status, tokenUsage, durationMs +- StepState + - index, assistantText(流式缓冲), action, observation, tokenUsage +- UIState + - focus, scrollOffset, suggestions, inputBuffer, notices + +### 4.3 Core 到 UI 的事件映射 + +| Core hook/回调 | UI 事件 | UI 结果 | +| --------------- | -------------- | -------------------------------- | +| onTurnStart | TurnStart | 新增 turn,显示用户输入 | +| onAssistantStep | AssistantChunk | 追加流式输出缓冲 | +| onAction | ToolCall | 渲染工具调用卡片,状态 executing | +| onObservation | ToolResult | 渲染工具结果,状态 success/error | +| onFinal | Final | 固化助手消息与 token 统计 | + +### 4.4 错误处理 + +- LLM/工具失败:在当前 turn 内展示错误块,保留上下文。 +- 未捕获错误:顶部红色提示条,尽量不退出 UI。 +- Ctrl+C:若有进行中的 turn 则中断;空闲则退出。 + +## 5. 布局与组件设计 + +### 5.1 交互式布局示意 + +``` +--------------------------------------------------------------+ +| memo-cli session: provider:model mode stream time | +--------------------------------------------------------------+ +| Turn 3 | +| User: summarize repo | +| Assistant: ... (streaming) | +| Tool: grep status=running | +| input: {"pattern":"TODO","path":"./"} | +| output: ... | +| Final: ... | +| | +| Turn 4 ... | +--------------------------------------------------------------+ +| tokens: p 1234 c 456 t 1690 step 2/100 | +| > 输入你的问题... | +--------------------------------------------------------------+ +``` + +### 5.2 组件划分 + +- AppShell + - HeaderBar + - MainContent + - StatusBar + - InputPrompt +- HeaderBar + - SessionBadge(id/mode) + - ProviderBadge(provider/model) + - StreamIndicator + - 运行时长或时钟 +- MainContent + - TurnGroup + - UserMessage + - AssistantMessage(流式) + - ToolCallPanel(可折叠) + - ObservationBlock + - FinalMessage +- StatusBar + - TokenUsageBadge + - StepCounter + - WarningIndicator(token 超限或工具错误) +- InputPrompt + - TextBuffer + - SuggestionList(命令/历史/路径) + - InlineHelp(可选) + +### 5.3 ToolCallPanel 行为 + +- 默认折叠:显示工具名 + 状态 + 简短入参摘要。 +- 展开:显示完整 JSON 入参与输出。 +- 快捷键(示例):Ctrl+O 展开/折叠最近工具调用。 + +### 5.4 流式渲染 + +- 流式输出直接追加到当前 AssistantMessage。 +- 非流式模式一次性展示完整回答。 +- 显示“typing”指示或动态光标。 + +## 6. 交互设计 + +### 6.1 输入与快捷键 + +- Enter:提交 +- Shift+Enter:换行 +- Up/Down:输入历史 +- Ctrl+R:历史搜索(可选) +- Ctrl+L:清屏 +- Ctrl+C:中断/退出 + +### 6.2 UI 级 Slash 命令 + +MVP: + +- /help:显示命令与快捷键 +- /exit:退出 session +- /clear:清屏 +- /tools:列出工具(内置 + MCP) +- /config:显示 config 路径与当前 provider + +后续: + +- /memory:显示 memo.md 位置与摘要 +- /sessions:列出 JSONL 会话文件 +- /log:tail 当前 JSONL +- /theme:切换主题 +- /model:切换 provider/model(下次 turn 生效) + +## 7. 视觉与可用性规范 + +- 仅使用 3-4 种语义色:用户/助手/工具/错误。 +- 保持输出稳定可复制,不依赖复杂 ANSI 动效。 +- 屏幕狭窄时自动压缩 header 与工具卡片。 +- 非 TTY 下禁用 TUI,输出纯文本。 + +## 8. 非交互模式 + +- --once 或非 TTY:不启动 Ink。 +- 输出顺序: + - 用户输入(可选) + - assistant final + - token 汇总 +- 工具调用与 observation 以纯文本块打印,便于审计。 + +## 9. 实施阶段 + +Phase 1(MVP): + +- Ink 布局:Header/Main/Status/Input。 +- 流式输出与基础工具卡片。 +- /help、/exit、/clear、/tools、/config。 + +Phase 2: + +- 命令补全与建议列表。 +- 工具卡片折叠与错误徽标。 +- token 预警在 StatusBar 提示。 + +Phase 3: + +- 会话浏览与日志查看。 +- 主题与配色配置。 +- 鼠标与滚动支持(若 Ink 可控)。 + +## 10. 待确认问题 + +- Bun 运行 Ink 是否稳定?是否需要 Node 模式兜底? +- 是否引入“高危工具确认”机制(例如写文件/执行命令)? +- 是否需要专门的 headless 输出模式(更适配 CI/piping)? diff --git a/docs/multi-turn.md b/docs/multi-turn.md index aa986c0..5faa814 100644 --- a/docs/multi-turn.md +++ b/docs/multi-turn.md @@ -4,7 +4,7 @@ ## 背景与痛点 -- 当前 `packages/ui/src/index.ts` 只接受一次问题,调用 `runAgent` 后直接退出,无法继续追问或补充上下文。 +- 当前 `packages/ui/src/index.tsx` 只接受一次问题,调用 `runAgent` 后直接退出,无法继续追问或补充上下文。 - 日志仅有 `history.xml`(旧方案)时无法按消息维度索引/分析;现统一写 JSONL。 - 缺少 Session/Turn 概念,无法区分同一进程内的多轮、或复盘某一轮的工具轨迹。 @@ -31,7 +31,7 @@ ## 运行模式与 CLI 行为 - **交互模式(默认)**: - - `bun start` 或 `bun run packages/ui/src/index.ts` 启动 Session,打印 `sessionId`。 + - `bun start` 或 `bun run packages/ui/src/index.tsx` 启动 Session,打印 `sessionId`。 - 可选第一个问题来自 argv,其余问题从 stdin 读取;支持 `/exit`、`/help` 之类的指令退出或查看状态。 - 每个 Turn 结束后继续等待输入,直到用户退出或遇到致命错误。 - **一次性模式(`--once`)**: diff --git a/package.json b/package.json index 3c48c96..b28ff11 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "type": "module", "private": true, "scripts": { - "start": "bun run packages/ui/src/index.ts", - "build": "bun build packages/ui/src/index.ts --target bun --outdir dist --minify", - "build:binary": "bun build packages/ui/src/index.ts --target=bun --compile --outfile=memo", + "start": "bun run packages/ui/src/index.tsx", + "build": "bun build packages/ui/src/index.tsx --target bun --outdir dist --minify", + "build:binary": "bun build packages/ui/src/index.tsx --target=bun --compile --outfile=memo", "format": "bunx prettier --write .", "format:check": "bunx prettier --check ." }, diff --git a/packages/core/src/runtime/defaults.ts b/packages/core/src/runtime/defaults.ts index 8b99225..429815c 100644 --- a/packages/core/src/runtime/defaults.ts +++ b/packages/core/src/runtime/defaults.ts @@ -74,11 +74,11 @@ export async function withDefaultDeps( if (await file.exists()) { const memory = (await file.text()).trim() if (memory) { - return `${basePrompt}\n\n# 长期记忆\n${memory}` + return `${basePrompt}\n\n# Long-Term Memory\n${memory}` } } } catch (err) { - console.warn(`读取 memo 失败: ${(err as Error).message}`) + console.warn(`Failed to read memo: ${(err as Error).message}`) } return basePrompt } @@ -99,7 +99,7 @@ export async function withDefaultDeps( process.env.DEEPSEEK_API_KEY if (!apiKey) { throw new Error( - `缺少环境变量 ${provider.env_api_key}(或 OPENAI_API_KEY/DEEPSEEK_API_KEY)`, + `Missing env var ${provider.env_api_key} (or OPENAI_API_KEY/DEEPSEEK_API_KEY)`, ) } const client = new OpenAI({ @@ -130,7 +130,7 @@ export async function withDefaultDeps( }) const content = data.choices?.[0]?.message?.content if (typeof content !== 'string') { - throw new Error('OpenAI 兼容接口返回内容为空') + throw new Error('OpenAI-compatible API returned empty content') } return { content, diff --git a/packages/core/src/runtime/hooks.ts b/packages/core/src/runtime/hooks.ts index 8a9b094..5033ae9 100644 --- a/packages/core/src/runtime/hooks.ts +++ b/packages/core/src/runtime/hooks.ts @@ -62,7 +62,7 @@ export async function runHook( try { await handler(payload) } catch (err) { - console.warn(`Hook ${name} 执行失败: ${(err as Error).message}`) + console.warn(`Hook ${name} failed: ${(err as Error).message}`) } } } diff --git a/packages/core/src/runtime/mcp_client.ts b/packages/core/src/runtime/mcp_client.ts index bb6c493..cee2d8c 100644 --- a/packages/core/src/runtime/mcp_client.ts +++ b/packages/core/src/runtime/mcp_client.ts @@ -110,7 +110,7 @@ async function loadMcpCache(): Promise { return JSON.parse(content) as McpCache } } catch (err) { - console.warn(`读取 MCP 缓存失败: ${(err as Error).message}`) + console.warn(`Failed to read MCP cache: ${(err as Error).message}`) } return { servers: {} } } @@ -119,7 +119,7 @@ async function saveMcpCache(cache: McpCache): Promise { try { await Bun.write(MCP_CACHE_FILE, JSON.stringify(cache, null, 2)) } catch (err) { - console.warn(`写入 MCP 缓存失败: ${(err as Error).message}`) + console.warn(`Failed to write MCP cache: ${(err as Error).message}`) } } diff --git a/packages/core/src/runtime/memory.test.ts b/packages/core/src/runtime/memory.test.ts index 03196c3..412d77c 100644 --- a/packages/core/src/runtime/memory.test.ts +++ b/packages/core/src/runtime/memory.test.ts @@ -50,7 +50,7 @@ describe('memory injection', () => { try { const systemPrompt = session.history[0]?.content ?? '' assert.ok( - systemPrompt.includes('长期记忆'), + systemPrompt.includes('Long-Term Memory'), 'system prompt should include memory section', ) assert.ok( diff --git a/packages/core/src/runtime/prompt.md b/packages/core/src/runtime/prompt.md index 678c2ee..fc81b64 100644 --- a/packages/core/src/runtime/prompt.md +++ b/packages/core/src/runtime/prompt.md @@ -14,8 +14,8 @@ 您与用户的交互方式是**自然语言**。就像在与同事通过 IM 聊天一样。 -- **思考与交流**:直接输出文本。在采取行动之前,简要解释您要根据当前状态做什么(Preamble)。 -- **使用工具**:当需要执行操作时,输出一个 **Markdown JSON 代码块**。 +- **普通回复**:直接输出文本。 +- **使用工具**:当需要执行操作时,只输出一个 **Markdown JSON 代码块**,不要输出任何额外文字。 - **最终回答**:当任务完成或需要回复用户时,直接输出您的回答(支持 Markdown),**不要**再调用工具。 ## 工具调用示例 @@ -30,28 +30,12 @@ 注意: 1. 一次回复依然建议只调用**一个**工具,然后等待结果。 -2. 只要您**不**输出 JSON 工具块,您的所有文本都会直接展示给用户作为最终回答。 +2. 调用工具时,回复中只能包含 JSON 工具块,不能包含其它文本。 +3. 只要您**不**输出 JSON 工具块,您的所有文本都会直接展示给用户作为最终回答。 # 行为准则与设定 (Guidelines) -## 1. Preamble (前导消息) - -在调用工具前,必须先输出一段简短的“前导消息”(Preamble)。 - -- **原则**: - - **逻辑分组**:如果这一步是为了某个大目标,请说明上下文。 - - **简洁**:1-2 句话,8-12 个字为佳。 - - **连贯**:关联之前的步骤,让用户感到进度顺畅。 - - **友好**:保持轻松、好奇的语调。 -- **示例**: - - "我已经浏览了仓库;现在正在检查 API 路由定义。" - - "接下来,我将修补配置并更新相关的测试。" - - "我即将构建 CLI 命令和辅助函数。" - - "配置看起来很整洁。接下来是修补帮助程序以保持同步。" - - "完成了对数据库网关的探查。现在我将处理错误处理。" - - "发现了一个聪明的缓存实用程序;现在正在寻找它的使用位置。" - -## 2. AGENTS.md 规范 +## 1. AGENTS.md 规范 - 仓库中可能存在 `AGENTS.md` 文件(根目录或子目录)。 - **规则**: @@ -59,7 +43,7 @@ - 更深层目录的 `AGENTS.md` 优先级高于浅层的。 - 本 Prompt 中的指令优先级高于 `AGENTS.md`。 -## 3. 规划 (Planning) (对应 `todo` 工具) +## 2. 规划 (Planning) (对应 `todo` 工具) 使用 `todo` 工具来管理复杂任务的计划。 @@ -80,7 +64,7 @@ - 步骤具体、逻辑清晰。 - 既然使用了工具,请确保 `content` 字段包含序号和简述。 -## 4. 任务执行与验证 (Execution & Verification) +## 3. 任务执行与验证 (Execution & Verification) - **精准手术**:对现有代码库的修改应最小化并专注于任务,不要随意更改风格或变量名。 - **根本原因**:解决根本问题,而不是打补丁。 @@ -90,12 +74,12 @@ - 先做单元测试,再做集成测试。 - 如果没有测试,且风险较低,可以尝试编写包含 Assert 的临时脚本来验证。 -## 5. Shell 使用规范 +## 4. Shell 使用规范 - 搜索文件优先使用 `glob` 或 `grep` (rg),这比递归列出目录要快且准。 - 不要 `cat` (read) 巨大的文件或整个目录树,这会浪费 Token。 -## 6. 最终回答格式 (Markdown in `final`) +## 5. 最终回答格式 (Markdown in `final`) 在 `"final"` 字段的内容中,遵循 BunJS 的 Markdown 风格: diff --git a/packages/core/src/runtime/session.ts b/packages/core/src/runtime/session.ts index b77a585..962d74a 100644 --- a/packages/core/src/runtime/session.ts +++ b/packages/core/src/runtime/session.ts @@ -60,7 +60,7 @@ async function emitEventToSinks(event: HistoryEvent, sinks: HistorySink[]) { try { await sink.append(event) } catch (err) { - console.error(`写入历史事件失败: ${(err as Error).message}`) + console.error(`Failed to write history event: ${(err as Error).message}`) } } } @@ -92,8 +92,8 @@ function parseToolInput(tool: ToolRegistry[string], rawInput: unknown) { if (!parsed.success) { const issue = parsed.error.issues[0] const path = issue?.path?.join('.') || 'input' - const message = issue?.message || '参数不合法' - return { ok: false as const, error: `${tool.name} 参数不合法: ${path} ${message}` } + const message = issue?.message || 'Invalid input' + return { ok: false as const, error: `${tool.name} invalid input: ${path} ${message}` } } return { ok: true as const, data: parsed.data } } @@ -111,6 +111,7 @@ class AgentSessionImpl implements AgentSession { private startedAt = Date.now() private maxSteps: number private hooks: HookRunnerMap + private closed = false constructor( private deps: AgentSessionDeps & { @@ -175,7 +176,7 @@ class AgentSessionImpl implements AgentSession { for (let step = 0; step < this.maxSteps; step++) { const estimatedPrompt = this.tokenCounter.countMessages(this.history) if (this.options.maxPromptTokens && estimatedPrompt > this.options.maxPromptTokens) { - const limitMessage = `上下文 tokens (${estimatedPrompt}) 超出限制,请缩短输入或重启对话。` + const limitMessage = `Context tokens (${estimatedPrompt}) exceed the limit. Please shorten the input or restart the session.` const finalPayload = JSON.stringify({ final: limitMessage }) this.history.push({ role: 'assistant', content: finalPayload }) status = 'prompt_limit' @@ -201,7 +202,7 @@ class AgentSessionImpl implements AgentSession { break } if (this.options.warnPromptTokens && estimatedPrompt > this.options.warnPromptTokens) { - console.warn(`提示 tokens 已接近上限: ${estimatedPrompt}`) + console.warn(`Prompt tokens are near the limit: ${estimatedPrompt}`) } let assistantText = '' @@ -216,7 +217,7 @@ class AgentSessionImpl implements AgentSession { usageFromLLM = normalized.usage streamed = Boolean(normalized.streamed) } catch (err) { - const msg = `LLM 调用失败: ${(err as Error).message}` + const msg = `LLM call failed: ${(err as Error).message}` const finalPayload = JSON.stringify({ final: msg }) this.history.push({ role: 'assistant', content: finalPayload }) status = 'error' @@ -239,7 +240,15 @@ class AgentSessionImpl implements AgentSession { if (!streamed) { this.deps.onAssistantStep?.(assistantText, step) } - this.history.push({ role: 'assistant', content: assistantText }) + + const parsed: ParsedAssistant = parseAssistant(assistantText) + const historyContent = parsed.action + ? JSON.stringify({ + tool: parsed.action.tool, + input: parsed.action.input, + }) + : assistantText + this.history.push({ role: 'assistant', content: historyContent }) // 将本地 tokenizer 与 LLM usage(若有)结合,记录 step 级 token 数据。 const completionTokens = this.tokenCounter.countText(assistantText) @@ -254,7 +263,6 @@ class AgentSessionImpl implements AgentSession { accumulateUsage(turnUsage, stepUsage) accumulateUsage(this.sessionUsage, stepUsage) - const parsed: ParsedAssistant = parseAssistant(assistantText) steps.push({ index: step, assistantText, @@ -315,13 +323,13 @@ class AgentSessionImpl implements AgentSession { observation = parsedInput.error } else { const result = await tool.execute(parsedInput.data) - observation = flattenCallToolResult(result) || '(工具无输出)' + observation = flattenCallToolResult(result) || '(no tool output)' } } else { - observation = `未知工具: ${parsed.action.tool}` + observation = `Unknown tool: ${parsed.action.tool}` } } catch (err) { - observation = `工具执行失败: ${(err as Error).message}` + observation = `Tool execution failed: ${(err as Error).message}` } this.history.push({ @@ -357,7 +365,7 @@ class AgentSessionImpl implements AgentSession { if (status === 'ok') { status = steps.length >= this.maxSteps ? 'max_steps' : 'error' } - finalText = '未能生成最终回答,请重试或调整问题。' + finalText = 'Unable to produce a final answer. Please retry or adjust the request.' errorMessage = finalText const payload = JSON.stringify({ final: finalText }) this.history.push({ role: 'assistant', content: payload }) @@ -397,6 +405,8 @@ class AgentSessionImpl implements AgentSession { } async close() { + if (this.closed) return + this.closed = true await this.emitEvent('session_end', { meta: { durationMs: Date.now() - this.startedAt, @@ -408,7 +418,7 @@ class AgentSessionImpl implements AgentSession { try { await sink.flush() } catch (err) { - console.error(`历史 flush 失败: ${(err as Error).message}`) + console.error(`History flush failed: ${(err as Error).message}`) } } } diff --git a/packages/core/src/utils/utils.ts b/packages/core/src/utils/utils.ts index 5d6d077..6ad9e83 100644 --- a/packages/core/src/utils/utils.ts +++ b/packages/core/src/utils/utils.ts @@ -24,25 +24,10 @@ export function parseAssistant(content: string): ParsedAssistant { const obj = JSON.parse(jsonText) // 检查是否是合法的 action 结构 - // 兼容 { "tool": ... } (Codex Style) 或 { "action": { "tool": ... } } (Old Style) - let toolName: string | undefined - let toolInput: unknown - - if (obj.tool) { - // Format: { "tool": "name", "input": ... } - toolName = obj.tool - toolInput = obj.input - } else if (obj.action && typeof obj.action === 'object') { - // Format: { "action": { "tool": "name", "input": ... } } - const actionObj = obj.action as Record - toolName = actionObj.tool as string - toolInput = actionObj.input - } - - if (toolName && typeof toolName === 'string') { + if (obj && typeof obj === 'object' && typeof obj.tool === 'string') { parsed.action = { - tool: toolName.trim(), - input: toolInput, + tool: obj.tool.trim(), + input: obj.input, } return parsed } diff --git a/packages/ui/package.json b/packages/ui/package.json index 6714e8c..ecf2a84 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,10 +1,18 @@ { "name": "@memo/ui", "type": "module", - "module": "src/index.ts", + "module": "src/index.tsx", "private": true, "dependencies": { "@memo/core": "workspace:*", - "@memo/tools": "workspace:*" + "@memo/tools": "workspace:*", + "ink": "^5.0.0", + "marked": "^17.0.1", + "react": "^18.2.0", + "react-reconciler": "^0.29.0", + "string-width": "^7.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.79" } } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts deleted file mode 100644 index b2ee8b1..0000000 --- a/packages/ui/src/index.ts +++ /dev/null @@ -1,169 +0,0 @@ -// CLI 入口:提供交互式/一次性两种模式,负责 Session 管理与日志输出。 -import { randomUUID } from 'node:crypto' -import { createInterface } from 'node:readline/promises' -import { stdin as input, stdout as output } from 'node:process' -import { - createAgentSession, - loadMemoConfig, - writeMemoConfig, - type AgentSessionDeps, - type AgentSessionOptions, - type MemoConfig, -} from '@memo/core' - -type CliOptions = { - once: boolean -} - -type ParsedArgs = { - question: string - options: CliOptions -} - -/** 简易 argv 解析,仅支持 --once 开关。 */ -function parseArgs(argv: string[]): ParsedArgs { - const options: CliOptions = { - once: false, - } - const questionParts: string[] = [] - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] - if (arg === undefined) { - continue - } - if (arg === '--once') { - options.once = true - continue - } - questionParts.push(arg) - } - - return { question: questionParts.join(' '), options } -} - -async function ensureProviderConfig() { - const loaded = await loadMemoConfig() - if (!loaded.needsSetup) return loaded - - const defaultProvider = loaded.config.providers[0] - const envCandidates = [ - defaultProvider?.env_api_key, - 'OPENAI_API_KEY', - 'DEEPSEEK_API_KEY', - ].filter(Boolean) as string[] - - const hasEnvKey = envCandidates.some((key) => Boolean(process.env[key])) - - if (defaultProvider && hasEnvKey) { - await writeMemoConfig(loaded.configPath, loaded.config) - console.log( - `检测到环境变量,已使用默认 provider (${defaultProvider.name}) 写入配置: ${loaded.configPath}`, - ) - return { ...loaded, needsSetup: false } - } - - const rl = createInterface({ input, output }) - const ask = async (prompt: string, fallback: string) => { - const ans = (await rl.question(prompt)).trim() - return ans || fallback - } - - try { - console.log('未检测到可用的 provider 配置,请按提示输入:') - const name = await ask('Provider 名称 [deepseek]: ', 'deepseek') - const envKey = await ask('API Key 环境变量名 [DEEPSEEK_API_KEY]: ', 'DEEPSEEK_API_KEY') - const model = await ask('模型名称 [deepseek-chat]: ', 'deepseek-chat') - const baseUrl = await ask( - 'Base URL [https://api.deepseek.com]: ', - 'https://api.deepseek.com', - ) - - const config: MemoConfig = { - current_provider: name, - max_steps: loaded.config.max_steps ?? 100, - providers: [ - { - name, - env_api_key: envKey, - model, - base_url: baseUrl || undefined, - }, - ], - } - await writeMemoConfig(loaded.configPath, config) - console.log(`配置已写入 ${loaded.configPath}\n`) - return { ...loaded, config, needsSetup: false } - } finally { - rl.close() - } -} - -async function runInteractive(parsed: ParsedArgs) { - await ensureProviderConfig() - const sessionId = randomUUID() - const sessionOptions: AgentSessionOptions = { - sessionId, - mode: parsed.options.once ? 'once' : 'interactive', - } - - const streamedSteps = new Set() - const deps: AgentSessionDeps = { - onAssistantStep: (text: string, step: number) => { - if (!streamedSteps.has(step)) { - streamedSteps.add(step) - process.stdout.write(`\n[LLM 第 ${step + 1} 轮输出]\n`) - } - process.stdout.write(text) - }, - hooks: { - onObservation: ({ tool, step }) => { - console.log(`\n工具=${tool} step=${step}\n`) - }, - }, - } - - const session = await createAgentSession(deps, sessionOptions) - - const rl = createInterface({ input, output }) - let nextQuestion = parsed.question - if (parsed.options.once && !nextQuestion) { - nextQuestion = '给我做一个自我介绍' - } - - try { - while (true) { - const userInput = nextQuestion || (await rl.question('> ')) - nextQuestion = '' - const trimmed = userInput.trim() - if (!trimmed) { - continue - } - if (trimmed === '/exit') { - break - } - - console.log(`\n用户: ${trimmed}\n`) - const turnResult = await session.runTurn(trimmed) - console.log( - `\n[tokens] prompt=${turnResult.tokenUsage.prompt} completion=${turnResult.tokenUsage.completion} total=${turnResult.tokenUsage.total}`, - ) - - if (parsed.options.once) { - break - } - } - } catch (err) { - console.error(`运行失败: ${(err as Error).message}`) - } finally { - rl.close() - await session.close() - } -} - -async function main() { - const parsed = parseArgs(process.argv.slice(2)) - await runInteractive(parsed) -} - -void main() diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx new file mode 100644 index 0000000..ccee46a --- /dev/null +++ b/packages/ui/src/index.tsx @@ -0,0 +1,212 @@ +// CLI entry: interactive/one-off modes with session management and logs. +import { randomUUID } from 'node:crypto' +import { createInterface } from 'node:readline/promises' +import { stdin as input, stdout as output } from 'node:process' +import { render } from 'ink' +import { + createAgentSession, + loadMemoConfig, + writeMemoConfig, + selectProvider, + type AgentSessionDeps, + type AgentSessionOptions, + type MemoConfig, +} from '@memo/core' +import { App } from './tui/App' + +type CliOptions = { + once: boolean +} + +type ParsedArgs = { + question: string + options: CliOptions +} + +/** Minimal argv parsing, supports --once only. */ +function parseArgs(argv: string[]): ParsedArgs { + const options: CliOptions = { + once: false, + } + const questionParts: string[] = [] + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === undefined) { + continue + } + if (arg === '--once') { + options.once = true + continue + } + questionParts.push(arg) + } + + return { question: questionParts.join(' '), options } +} + +async function ensureProviderConfig() { + const loaded = await loadMemoConfig() + if (!loaded.needsSetup) return loaded + + const defaultProvider = loaded.config.providers[0] + const envCandidates = [ + defaultProvider?.env_api_key, + 'OPENAI_API_KEY', + 'DEEPSEEK_API_KEY', + ].filter(Boolean) as string[] + + const hasEnvKey = envCandidates.some((key) => Boolean(process.env[key])) + + if (defaultProvider && hasEnvKey) { + await writeMemoConfig(loaded.configPath, loaded.config) + console.log( + `Detected API key in env. Wrote default provider (${defaultProvider.name}) to ${loaded.configPath}`, + ) + return { ...loaded, needsSetup: false } + } + + const rl = createInterface({ input, output }) + const ask = async (prompt: string, fallback: string) => { + const ans = (await rl.question(prompt)).trim() + return ans || fallback + } + + try { + console.log('No provider config found. Please answer the prompts:') + const name = await ask('Provider name [deepseek]: ', 'deepseek') + const envKey = await ask('API key env var [DEEPSEEK_API_KEY]: ', 'DEEPSEEK_API_KEY') + const model = await ask('Model name [deepseek-chat]: ', 'deepseek-chat') + const baseUrl = await ask( + 'Base URL [https://api.deepseek.com]: ', + 'https://api.deepseek.com', + ) + + const config: MemoConfig = { + current_provider: name, + max_steps: loaded.config.max_steps ?? 100, + providers: [ + { + name, + env_api_key: envKey, + model, + base_url: baseUrl || undefined, + }, + ], + } + await writeMemoConfig(loaded.configPath, config) + console.log(`Config written to ${loaded.configPath}\n`) + return { ...loaded, config, needsSetup: false } + } finally { + rl.close() + } +} + +async function runPlainMode(parsed: ParsedArgs) { + const loaded = await ensureProviderConfig() + const provider = selectProvider(loaded.config) + const sessionId = randomUUID() + const sessionOptions: AgentSessionOptions = { + sessionId, + mode: 'once', + stream: loaded.config.stream_output ?? false, + } + + const deps: AgentSessionDeps = { + onAssistantStep: (text: string) => { + process.stdout.write(text) + }, + hooks: { + onAction: ({ action }) => { + console.log(`\n[tool] ${action.tool}`) + if (action.input !== undefined) { + console.log(`[input] ${JSON.stringify(action.input)}`) + } + }, + onObservation: ({ tool, observation }) => { + console.log(`\n[tool-result] ${tool}\n${observation}`) + }, + }, + } + + const session = await createAgentSession(deps, sessionOptions) + + let question = parsed.question + if (!question && !process.stdin.isTTY) { + question = await readStdin() + } + if (!question && parsed.options.once) { + question = 'Give me a quick self-introduction.' + } + if (!question) { + console.error('No input provided. Pass a question or use stdin.') + await session.close() + return + } + + try { + console.log(`User: ${question}\n`) + const turnResult = await session.runTurn(question) + if (!loaded.config.stream_output) { + console.log(`\n${turnResult.finalText}`) + } + console.log( + `\n[tokens] prompt=${turnResult.tokenUsage.prompt} completion=${turnResult.tokenUsage.completion} total=${turnResult.tokenUsage.total}`, + ) + console.log(`\nprovider=${provider.name} model=${provider.model}`) + } catch (err) { + console.error(`Run failed: ${(err as Error).message}`) + } finally { + await session.close() + } +} + +async function runInteractiveTui(parsed: ParsedArgs) { + const loaded = await ensureProviderConfig() + const provider = selectProvider(loaded.config) + const sessionId = randomUUID() + const sessionOptions: AgentSessionOptions = { + sessionId, + mode: 'interactive', + stream: loaded.config.stream_output ?? false, + } + + const app = render( + , + { exitOnCtrlC: false }, + ) + await app.waitUntilExit() +} + +async function main() { + const parsed = parseArgs(process.argv.slice(2)) + const isInteractive = process.stdin.isTTY && process.stdout.isTTY + if (!isInteractive || parsed.options.once) { + await runPlainMode(parsed) + return + } + await runInteractiveTui(parsed) +} + +void main() + +async function readStdin(): Promise { + return new Promise((resolve) => { + let data = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', (chunk) => { + data += chunk + }) + process.stdin.on('end', () => { + resolve(data.trim()) + }) + process.stdin.resume() + }) +} diff --git a/packages/ui/src/tui/App.tsx b/packages/ui/src/tui/App.tsx new file mode 100644 index 0000000..46dcf17 --- /dev/null +++ b/packages/ui/src/tui/App.tsx @@ -0,0 +1,260 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Box, useApp } from 'ink' +import { + createAgentSession, + type AgentSession, + type AgentSessionDeps, + type AgentSessionOptions, +} from '@memo/core' +import type { SystemMessage, TurnView } from './types' +import { HeaderBar } from './components/layout/HeaderBar' +import { TokenBar } from './components/layout/TokenBar' +import { MainContent } from './components/layout/MainContent' +import { InputPrompt } from './components/layout/InputPrompt' +import { inferToolStatus, formatTokenUsage } from './utils' +import { resolveSlashCommand } from './commands' + +export type AppProps = { + sessionOptions: AgentSessionOptions + providerName: string + model: string + configPath: string + mcpServerNames: string[] + cwd: string +} + +function createEmptyTurn(index: number): TurnView { + return { index, userInput: '', steps: [] } +} + +export function App({ + sessionOptions, + providerName, + model, + configPath, + mcpServerNames, + cwd, +}: AppProps) { + const { exit } = useApp() + const [session, setSession] = useState(null) + const [turns, setTurns] = useState([]) + const [systemMessages, setSystemMessages] = useState([]) + const [statusMessage, setStatusMessage] = useState(null) + const [busy, setBusy] = useState(false) + const currentTurnRef = useRef(null) + const [inputHistory, setInputHistory] = useState([]) + + const appendSystemMessage = useCallback((title: string, content: string) => { + const id = `${Date.now()}-${Math.random().toString(16).slice(2)}` + setSystemMessages((prev) => [...prev, { id, title, content }]) + }, []) + + const updateTurn = useCallback((turnIndex: number, updater: (turn: TurnView) => TurnView) => { + setTurns((prev) => { + const next = [...prev] + let idx = next.findIndex((turn) => turn.index === turnIndex) + if (idx === -1) { + next.push(createEmptyTurn(turnIndex)) + idx = next.length - 1 + } + const existing = next[idx] + if (!existing) return next + next[idx] = updater(existing) + return next + }) + }, []) + + const deps = useMemo( + () => ({ + onAssistantStep: (chunk: string, step: number) => { + const turnIndex = currentTurnRef.current + if (!turnIndex) return + updateTurn(turnIndex, (turn) => { + const steps = turn.steps.slice() + while (steps.length <= step) { + steps.push({ index: steps.length, assistantText: '' }) + } + const target = steps[step] + if (!target) return turn + const updated = { + ...target, + assistantText: target.assistantText + chunk, + } + steps[step] = updated + return { ...turn, steps } + }) + }, + hooks: { + onTurnStart: ({ turn, input }) => { + currentTurnRef.current = turn + updateTurn(turn, (existing) => ({ + ...existing, + index: turn, + userInput: input, + steps: [], + startedAt: Date.now(), + })) + }, + onAction: ({ turn, step, action }) => { + updateTurn(turn, (turnState) => { + const steps = turnState.steps.slice() + while (steps.length <= step) { + steps.push({ index: steps.length, assistantText: '' }) + } + const target = steps[step] + if (!target) return turnState + steps[step] = { + ...target, + action, + toolStatus: 'executing', + } + return { ...turnState, steps } + }) + }, + onObservation: ({ turn, step, observation }) => { + updateTurn(turn, (turnState) => { + const steps = turnState.steps.slice() + while (steps.length <= step) { + steps.push({ index: steps.length, assistantText: '' }) + } + const target = steps[step] + if (!target) return turnState + steps[step] = { + ...target, + observation, + toolStatus: inferToolStatus(observation), + } + return { ...turnState, steps } + }) + }, + onFinal: ({ turn, finalText, status, turnUsage }) => { + updateTurn(turn, (turnState) => { + const startedAt = turnState.startedAt ?? Date.now() + const durationMs = Math.max(0, Date.now() - startedAt) + return { + ...turnState, + finalText, + status, + tokenUsage: turnUsage, + startedAt, + durationMs, + } + }) + setBusy(false) + }, + }, + }), + [updateTurn], + ) + + useEffect(() => { + let active = true + ;(async () => { + const created = await createAgentSession(deps, sessionOptions) + if (!active) { + await created.close() + return + } + setSession(created) + })() + return () => { + active = false + } + }, [deps, sessionOptions]) + + useEffect(() => { + return () => { + if (session) { + void session.close() + } + } + }, [session]) + + const handleExit = useCallback(async () => { + if (session) { + await session.close() + } + exit() + }, [exit, session]) + + const handleClear = useCallback(() => { + setTurns([]) + setSystemMessages([]) + setStatusMessage(null) + }, []) + + const handleCommand = useCallback( + async (raw: string) => { + const result = resolveSlashCommand(raw, { + configPath, + providerName, + model, + mcpServerNames, + }) + if (result.kind === 'exit') { + await handleExit() + return + } + if (result.kind === 'clear') { + handleClear() + return + } + appendSystemMessage(result.title, result.content) + }, + [ + appendSystemMessage, + configPath, + handleClear, + handleExit, + mcpServerNames, + model, + providerName, + ], + ) + + const handleSubmit = useCallback( + async (value: string) => { + if (!session || busy) return + setStatusMessage(null) + if (value.startsWith('/')) { + await handleCommand(value) + return + } + setInputHistory((prev) => [...prev, value]) + setBusy(true) + try { + await session.runTurn(value) + } catch (err) { + setStatusMessage(`Failed: ${(err as Error).message}`) + setBusy(false) + } + }, + [busy, handleCommand, session], + ) + + const lastTurn = turns[turns.length - 1] + const statusLine = statusMessage ?? (!session ? 'Initializing...' : busy ? 'Running' : 'Ready') + const statusKind = + statusMessage !== null ? 'error' : !session ? 'initializing' : busy ? 'running' : 'ready' + const tokenLine = formatTokenUsage(lastTurn?.tokenUsage) + + return ( + + + + + + + ) +} diff --git a/packages/ui/src/tui/commands.ts b/packages/ui/src/tui/commands.ts new file mode 100644 index 0000000..7b15912 --- /dev/null +++ b/packages/ui/src/tui/commands.ts @@ -0,0 +1,46 @@ +import { TOOLKIT } from '@memo/tools' +import { HELP_TEXT } from './constants' + +export type SlashCommandContext = { + configPath: string + providerName: string + model: string + mcpServerNames: string[] +} + +export type SlashCommandResult = + | { kind: 'exit' } + | { kind: 'clear' } + | { kind: 'message'; title: string; content: string } + +export function resolveSlashCommand(raw: string, context: SlashCommandContext): SlashCommandResult { + const [command] = raw.trim().slice(1).split(/\s+/) + switch (command) { + case 'help': + return { kind: 'message', title: 'help', content: HELP_TEXT } + case 'exit': + return { kind: 'exit' } + case 'clear': + return { kind: 'clear' } + case 'tools': { + const builtin = Object.keys(TOOLKIT).sort() + const external = + context.mcpServerNames.length > 0 + ? `MCP servers: ${context.mcpServerNames.join(', ')}` + : 'MCP servers: (none)' + return { + kind: 'message', + title: 'tools', + content: `Built-in tools (${builtin.length}): ${builtin.join(', ')}\n${external}`, + } + } + case 'config': + return { + kind: 'message', + title: 'config', + content: `config: ${context.configPath}\nprovider: ${context.providerName}\nmodel: ${context.model}`, + } + default: + return { kind: 'message', title: 'unknown', content: `Unknown command: ${raw}` } + } +} diff --git a/packages/ui/src/tui/components/layout/HeaderBar.tsx b/packages/ui/src/tui/components/layout/HeaderBar.tsx new file mode 100644 index 0000000..7f406b5 --- /dev/null +++ b/packages/ui/src/tui/components/layout/HeaderBar.tsx @@ -0,0 +1,37 @@ +import { Box, Text } from 'ink' +import os from 'node:os' + +type HeaderBarProps = { + providerName: string + model: string + cwd: string +} + +function formatCwd(cwd: string) { + const home = os.homedir() + if (!home) return cwd + return cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd +} + +export function HeaderBar({ providerName, model, cwd }: HeaderBarProps) { + const displayCwd = formatCwd(cwd) + return ( + + + {'> Memo CLI'} + (local) + + + model: + {model} + provider: + {providerName} + /config to view + + + directory: + {displayCwd} + + + ) +} diff --git a/packages/ui/src/tui/components/layout/InputPrompt.tsx b/packages/ui/src/tui/components/layout/InputPrompt.tsx new file mode 100644 index 0000000..c37c59f --- /dev/null +++ b/packages/ui/src/tui/components/layout/InputPrompt.tsx @@ -0,0 +1,106 @@ +import { Box, Text, useInput, useStdout } from 'ink' +import { useState } from 'react' +import { USER_PREFIX } from '../../constants' +import { buildPaddedLine } from '../../utils' + +type InputPromptProps = { + disabled: boolean + onSubmit: (value: string) => void + onExit: () => void + onClear: () => void + history: string[] +} + +export function InputPrompt({ disabled, onSubmit, onExit, onClear, history }: InputPromptProps) { + const { stdout } = useStdout() + const [value, setValue] = useState('') + const [historyIndex, setHistoryIndex] = useState(null) + const [draft, setDraft] = useState('') + + useInput((input, key) => { + if (key.ctrl && input === 'c') { + onExit() + return + } + if (key.ctrl && input === 'l') { + setValue('') + setHistoryIndex(null) + setDraft('') + onClear() + return + } + + if (disabled) { + return + } + + if (key.return) { + const trimmed = value.trim() + if (trimmed) { + onSubmit(trimmed) + setValue('') + setHistoryIndex(null) + setDraft('') + } + return + } + + if (key.upArrow) { + if (!history.length) return + if (historyIndex === null) { + setDraft(value) + const nextIndex = history.length - 1 + setHistoryIndex(nextIndex) + setValue(history[nextIndex] ?? '') + return + } + const nextIndex = Math.max(0, historyIndex - 1) + setHistoryIndex(nextIndex) + setValue(history[nextIndex] ?? '') + return + } + + if (key.downArrow) { + if (historyIndex === null) return + const nextIndex = historyIndex + 1 + if (nextIndex >= history.length) { + setHistoryIndex(null) + setValue(draft) + setDraft('') + return + } + setHistoryIndex(nextIndex) + setValue(history[nextIndex] ?? '') + return + } + + if (key.backspace || key.delete) { + setValue((prev) => prev.slice(0, Math.max(0, prev.length - 1))) + return + } + + if (input) { + setValue((prev) => prev + input) + } + }) + + const placeholder = disabled ? 'Running...' : 'Input...' + const displayText = value || placeholder + const lineColor = value && !disabled ? 'white' : 'gray' + const { line, blankLine } = buildPaddedLine( + `${USER_PREFIX} ${displayText}`, + stdout?.columns ?? 80, + 1, + ) + const verticalPadding = 1 + + return ( + + {verticalPadding > 0 ? {blankLine} : null} + + {line} + + {verticalPadding > 0 ? {blankLine} : null} + + ) +} diff --git a/packages/ui/src/tui/components/layout/MainContent.tsx b/packages/ui/src/tui/components/layout/MainContent.tsx new file mode 100644 index 0000000..13456d5 --- /dev/null +++ b/packages/ui/src/tui/components/layout/MainContent.tsx @@ -0,0 +1,31 @@ +import { Box } from 'ink' +import type { SystemMessage, TurnView as TurnViewType } from '../../types' +import { SystemMessageView } from '../messages/SystemMessageView' +import { TurnView } from '../turns/TurnView' +import { StatusMessage } from '../messages/StatusMessage' + +type MainContentProps = { + systemMessages: SystemMessage[] + turns: TurnViewType[] + statusText: string + statusKind: 'initializing' | 'running' | 'ready' | 'error' +} + +export function MainContent({ systemMessages, turns, statusText, statusKind }: MainContentProps) { + const lastTurnIndex = turns.length - 1 + return ( + + {systemMessages.map((message) => ( + + ))} + {turns.map((turn, index) => ( + + ))} + + + ) +} diff --git a/packages/ui/src/tui/components/layout/TokenBar.tsx b/packages/ui/src/tui/components/layout/TokenBar.tsx new file mode 100644 index 0000000..01f0456 --- /dev/null +++ b/packages/ui/src/tui/components/layout/TokenBar.tsx @@ -0,0 +1,13 @@ +import { Box, Text } from 'ink' + +type TokenBarProps = { + tokenLine: string +} + +export function TokenBar({ tokenLine }: TokenBarProps) { + return ( + + {tokenLine} + + ) +} diff --git a/packages/ui/src/tui/components/messages/AssistantMessage.tsx b/packages/ui/src/tui/components/messages/AssistantMessage.tsx new file mode 100644 index 0000000..cb03cad --- /dev/null +++ b/packages/ui/src/tui/components/messages/AssistantMessage.tsx @@ -0,0 +1,20 @@ +import { Box, Text } from 'ink' +import { ASSISTANT_PREFIX } from '../../constants' +import { MarkdownMessage } from './MarkdownMessage' + +type AssistantMessageProps = { + text: string + tone?: 'normal' | 'muted' +} + +export function AssistantMessage({ text, tone = 'normal' }: AssistantMessageProps) { + const prefixColor = tone === 'muted' ? 'gray' : 'white' + return ( + + {ASSISTANT_PREFIX} + + + + + ) +} diff --git a/packages/ui/src/tui/components/messages/MarkdownMessage.tsx b/packages/ui/src/tui/components/messages/MarkdownMessage.tsx new file mode 100644 index 0000000..66f156e --- /dev/null +++ b/packages/ui/src/tui/components/messages/MarkdownMessage.tsx @@ -0,0 +1,261 @@ +import { Box, Text } from 'ink' +import { marked } from 'marked' +import type { ReactNode } from 'react' +import type { Token, Tokens, TokensList } from 'marked' + +type MarkdownMessageProps = { + text: string + tone?: 'normal' | 'muted' +} + +type RenderPalette = { + textColor?: string + codeColor: string + linkColor: string + muted: boolean +} + +const INLINE_CODE_BACKGROUND = '#2b2b2b' + +function inlineTokensFromText(text: string): Token[] { + if (!text) return [] + return [ + { + type: 'text', + raw: text, + text, + } as Tokens.Text, + ] +} + +function renderInlineToken( + token: Token, + palette: RenderPalette, + key: string, +): ReactNode | ReactNode[] { + switch (token.type) { + case 'text': { + if (token.tokens && token.tokens.length > 0) { + return renderInlineTokens(token.tokens, palette, `${key}-text`) + } + return token.text + } + case 'escape': + return token.text + case 'strong': + return ( + + {renderInlineTokens(token.tokens, palette, `${key}-strong`)} + + ) + case 'em': + return ( + + {renderInlineTokens(token.tokens, palette, `${key}-em`)} + + ) + case 'codespan': + return ( + + {token.text} + + ) + case 'del': + return ( + + {renderInlineTokens(token.tokens, palette, `${key}-del`)} + + ) + case 'link': { + const label = + token.tokens && token.tokens.length > 0 + ? renderInlineTokens(token.tokens, palette, `${key}-link`) + : token.text + const suffix = + token.href && token.text && token.text !== token.href ? ` (${token.href})` : '' + return ( + + {label} + {suffix} + + ) + } + case 'image': { + const altText = token.text || 'image' + return ( + + [{altText}]({token.href}) + + ) + } + case 'br': + return '\n' + case 'checkbox': + return token.checked ? '[x]' : '[ ]' + case 'html': + return token.text + default: + return 'text' in token ? token.text : token.raw + } +} + +function renderInlineTokens( + tokens: Token[] | undefined, + palette: RenderPalette, + keyPrefix: string, +): ReactNode[] { + if (!tokens || tokens.length === 0) return [] + return tokens.flatMap((token, index) => { + const key = `${keyPrefix}-${index}` + const rendered = renderInlineToken(token, palette, key) + return Array.isArray(rendered) ? rendered : [rendered] + }) +} + +function listItemInlineTokens(item: Tokens.ListItem): Token[] { + const firstToken = item.tokens[0] + if (firstToken?.type === 'paragraph' || firstToken?.type === 'text') { + return firstToken.tokens ?? inlineTokensFromText(firstToken.text) + } + return item.tokens.length > 0 ? item.tokens : inlineTokensFromText(item.text) +} + +function formatCodeBlock(text: string): string { + return text + .split('\n') + .map((line) => (line.length > 0 ? ` ${line}` : '')) + .join('\n') + .trimEnd() +} + +function renderTable(token: Tokens.Table): string { + const header = token.header.map((cell) => cell.text).join(' | ') + const separator = token.header.map(() => '---').join(' | ') + const rows = token.rows.map((row) => row.map((cell) => cell.text).join(' | ')) + return [header, separator, ...rows].join('\n') +} + +function isTableToken(token: Token): token is Tokens.Table { + return token.type === 'table' && 'header' in token && 'rows' in token && 'align' in token +} + +function renderBlockToken(token: Token, palette: RenderPalette, key: string): ReactNode | null { + switch (token.type) { + case 'space': + case 'def': + return null + case 'heading': + return ( + + {renderInlineTokens(token.tokens, palette, `${key}-heading`)} + + ) + case 'paragraph': { + const inlineTokens = token.tokens ?? inlineTokensFromText(token.text) + return ( + + {renderInlineTokens(inlineTokens, palette, `${key}-para`)} + + ) + } + case 'text': { + const inlineTokens = token.tokens ?? inlineTokensFromText(token.text) + return ( + + {renderInlineTokens(inlineTokens, palette, `${key}-text`)} + + ) + } + case 'code': { + const codeText = formatCodeBlock(token.text) + return ( + + {codeText} + + ) + } + case 'list': { + const startIndex = typeof token.start === 'number' ? token.start : 1 + return ( + + {token.items.map((item: Tokens.ListItem, index: number) => { + const bullet = token.ordered ? `${startIndex + index}.` : '-' + const taskPrefix = item.task ? (item.checked ? '[x] ' : '[ ] ') : '' + const inlineTokens = listItemInlineTokens(item) + return ( + + {bullet} + + {taskPrefix} + {renderInlineTokens( + inlineTokens, + palette, + `${key}-item-${index}`, + )} + + + ) + })} + + ) + } + case 'blockquote': + return ( + + > {token.text.trim()} + + ) + case 'hr': + return ( + + --- + + ) + case 'table': + return isTableToken(token) ? ( + + {renderTable(token)} + + ) : ( + + {'text' in token ? token.text : token.raw} + + ) + case 'html': + return ( + + {token.text} + + ) + default: + return ( + + {'text' in token ? token.text : token.raw} + + ) + } +} + +function renderBlocks(tokens: TokensList, palette: RenderPalette, keyPrefix: string): ReactNode[] { + return tokens.flatMap((token, index) => { + const rendered = renderBlockToken(token, palette, `${keyPrefix}-${index}`) + return rendered ? [rendered] : [] + }) +} + +export function MarkdownMessage({ text, tone = 'normal' }: MarkdownMessageProps) { + const palette: RenderPalette = { + textColor: tone === 'muted' ? 'gray' : undefined, + codeColor: tone === 'muted' ? 'gray' : 'cyan', + linkColor: tone === 'muted' ? 'gray' : 'blue', + muted: tone === 'muted', + } + const tokens = marked.lexer(text, { gfm: true, breaks: true }) + const blocks = renderBlocks(tokens, palette, 'markdown') + + return ( + + {blocks} + + ) +} diff --git a/packages/ui/src/tui/components/messages/StatusMessage.tsx b/packages/ui/src/tui/components/messages/StatusMessage.tsx new file mode 100644 index 0000000..c1aece4 --- /dev/null +++ b/packages/ui/src/tui/components/messages/StatusMessage.tsx @@ -0,0 +1,41 @@ +import { Box, Text } from 'ink' +import { useEffect, useState } from 'react' + +type StatusMessageProps = { + text: string + kind: 'initializing' | 'running' | 'ready' | 'error' +} + +const STATUS_COLOR: Record = { + initializing: 'gray', + running: 'yellow', + ready: 'green', + error: 'red', +} + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + +function useSpinner(active: boolean) { + const [index, setIndex] = useState(0) + useEffect(() => { + if (!active) return + const timer = setInterval(() => { + setIndex((prev) => (prev + 1) % SPINNER_FRAMES.length) + }, 80) + return () => clearInterval(timer) + }, [active]) + return SPINNER_FRAMES[index] ?? SPINNER_FRAMES[0] +} + +export function StatusMessage({ text, kind }: StatusMessageProps) { + const spinner = useSpinner(kind === 'running') + const icon = + kind === 'running' ? spinner : kind === 'ready' ? '●' : kind === 'initializing' ? '○' : '✕' + return ( + + + {icon} {text} + + + ) +} diff --git a/packages/ui/src/tui/components/messages/SystemMessageView.tsx b/packages/ui/src/tui/components/messages/SystemMessageView.tsx new file mode 100644 index 0000000..c204a0d --- /dev/null +++ b/packages/ui/src/tui/components/messages/SystemMessageView.tsx @@ -0,0 +1,15 @@ +import { Box, Text } from 'ink' +import type { SystemMessage } from '../../types' + +type SystemMessageViewProps = { + message: SystemMessage +} + +export function SystemMessageView({ message }: SystemMessageViewProps) { + return ( + + System: {message.title} + {message.content} + + ) +} diff --git a/packages/ui/src/tui/components/messages/UserMessage.tsx b/packages/ui/src/tui/components/messages/UserMessage.tsx new file mode 100644 index 0000000..a5042c7 --- /dev/null +++ b/packages/ui/src/tui/components/messages/UserMessage.tsx @@ -0,0 +1,24 @@ +import { Box, Text, useStdout } from 'ink' +import { USER_PREFIX } from '../../constants' +import { buildPaddedLine } from '../../utils' + +type UserMessageProps = { + text: string +} + +export function UserMessage({ text }: UserMessageProps) { + const { stdout } = useStdout() + const terminalWidth = stdout?.columns ?? 80 + const verticalPadding = 1 + const { line, blankLine } = buildPaddedLine(`${USER_PREFIX} ${text}`, terminalWidth, 1) + + return ( + + {verticalPadding > 0 ? {blankLine} : null} + + {line} + + {verticalPadding > 0 ? {blankLine} : null} + + ) +} diff --git a/packages/ui/src/tui/components/turns/StepView.tsx b/packages/ui/src/tui/components/turns/StepView.tsx new file mode 100644 index 0000000..711d8d7 --- /dev/null +++ b/packages/ui/src/tui/components/turns/StepView.tsx @@ -0,0 +1,28 @@ +import { Box, Text } from 'ink' +import type { StepView as StepViewType } from '../../types' +import { safeStringify, stripToolCallArtifacts } from '../../utils' +import { AssistantMessage } from '../messages/AssistantMessage' + +type StepViewProps = { + step: StepViewType +} + +export function StepView({ step }: StepViewProps) { + const assistantText = step.action + ? stripToolCallArtifacts(step.assistantText) + : step.assistantText + const shouldRenderAssistant = assistantText.trim().length > 0 + return ( + + {shouldRenderAssistant ? : null} + {step.action ? ( + + + Tool: {step.action.tool} [{step.toolStatus ?? 'pending'}] + + params: {safeStringify(step.action.input ?? {})} + + ) : null} + + ) +} diff --git a/packages/ui/src/tui/components/turns/TurnView.tsx b/packages/ui/src/tui/components/turns/TurnView.tsx new file mode 100644 index 0000000..d65e2f9 --- /dev/null +++ b/packages/ui/src/tui/components/turns/TurnView.tsx @@ -0,0 +1,35 @@ +import { Box, Text } from 'ink' +import type { TurnView as TurnViewType } from '../../types' +import { StepView } from './StepView' +import { UserMessage } from '../messages/UserMessage' +import { AssistantMessage } from '../messages/AssistantMessage' + +type TurnViewProps = { + turn: TurnViewType + showDuration?: boolean +} + +export function TurnView({ turn, showDuration = false }: TurnViewProps) { + const lastStepText = turn.steps[turn.steps.length - 1]?.assistantText?.trim() ?? '' + const finalText = turn.finalText?.trim() ?? '' + const shouldRenderFinal = finalText.length > 0 && finalText !== lastStepText + const durationSeconds = + typeof turn.durationMs === 'number' ? Math.max(1, Math.round(turn.durationMs / 1000)) : null + const shouldShowDuration = showDuration && durationSeconds !== null + + return ( + + + {turn.steps.map((step) => ( + + ))} + {shouldShowDuration ? ( + — Worked for {durationSeconds}s — + ) : null} + {shouldRenderFinal ? : null} + {turn.status && turn.status !== 'ok' ? ( + Status: {turn.status} + ) : null} + + ) +} diff --git a/packages/ui/src/tui/constants.ts b/packages/ui/src/tui/constants.ts new file mode 100644 index 0000000..4688e29 --- /dev/null +++ b/packages/ui/src/tui/constants.ts @@ -0,0 +1,16 @@ +export const HELP_TEXT = [ + 'Available commands:', + ' /help Show help and shortcuts', + ' /exit Exit the session', + ' /clear Clear the screen', + ' /tools List tools', + ' /config Show config and provider info', + '', + 'Shortcuts:', + ' Enter Send', + ' Ctrl+L Clear', + ' Ctrl+C Exit', +].join('\n') + +export const USER_PREFIX = '>' +export const ASSISTANT_PREFIX = '•' diff --git a/packages/ui/src/tui/types.ts b/packages/ui/src/tui/types.ts new file mode 100644 index 0000000..d781f82 --- /dev/null +++ b/packages/ui/src/tui/types.ts @@ -0,0 +1,28 @@ +import type { TokenUsage, TurnStatus } from '@memo/core' + +export type ToolStatus = 'pending' | 'executing' | 'success' | 'error' + +export type StepView = { + index: number + assistantText: string + action?: { tool: string; input: unknown } + observation?: string + toolStatus?: ToolStatus +} + +export type TurnView = { + index: number + userInput: string + steps: StepView[] + status?: TurnStatus + tokenUsage?: TokenUsage + finalText?: string + startedAt?: number + durationMs?: number +} + +export type SystemMessage = { + id: string + title: string + content: string +} diff --git a/packages/ui/src/tui/utils.ts b/packages/ui/src/tui/utils.ts new file mode 100644 index 0000000..d13242b --- /dev/null +++ b/packages/ui/src/tui/utils.ts @@ -0,0 +1,71 @@ +import type { TokenUsage } from '@memo/core' +import stringWidth from 'string-width' +import type { ToolStatus } from './types' + +type ToolCallShape = { tool: string; input?: unknown } + +function isToolCallShape(value: unknown): value is ToolCallShape { + if (!value || typeof value !== 'object') return false + const record = value as Record + return typeof record.tool === 'string' +} + +function isToolCallJson(text: string): boolean { + try { + const parsed = JSON.parse(text) + return isToolCallShape(parsed) + } catch { + return false + } +} + +export function stripToolCallArtifacts(text: string): string { + if (!text.trim()) return text + let output = text + + const fencedRegex = /```(?:json)?\s*([\s\S]*?)```/g + output = output.replace(fencedRegex, (full, body) => { + const candidate = typeof body === 'string' ? body.trim() : '' + if (!candidate.startsWith('{') || !candidate.endsWith('}')) return full + return isToolCallJson(candidate) ? '' : full + }) + + const trimmed = output.trim() + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + if (isToolCallJson(trimmed)) return '' + } + + return output.replace(/\n{3,}/g, '\n\n').trim() +} + +export function buildPaddedLine(content: string, width: number, paddingX = 1) { + const safeWidth = Math.max(1, width) + const padded = `${' '.repeat(paddingX)}${content}${' '.repeat(paddingX)}` + const padding = Math.max(0, safeWidth - stringWidth(padded)) + const line = padding > 0 ? `${padded}${' '.repeat(padding)}` : padded + return { line, blankLine: ' '.repeat(safeWidth) } +} + +export function safeStringify(input: unknown): string { + if (typeof input === 'string') return input + try { + const serialized = JSON.stringify(input) + return serialized ?? String(input) + } catch { + return String(input) + } +} + +export function inferToolStatus(observation?: string): ToolStatus { + if (!observation) return 'success' + const lowered = observation.toLowerCase() + if (lowered.includes('error') || lowered.includes('unknown') || lowered.includes('failed')) { + return 'error' + } + return 'success' +} + +export function formatTokenUsage(usage?: TokenUsage) { + if (!usage) return 'tokens: -' + return `Tokens: prompt ${usage.prompt} completion ${usage.completion} total ${usage.total}` +}