203 lines
808 KiB
JSON
203 lines
808 KiB
JSON
{"type":"session","version":3,"id":"019e92bf-8a8d-7240-a5fd-ab932eb6fd2a","timestamp":"2026-06-04T13:08:06.925Z","cwd":"/shumengya/project/agent/sproutclaw"}
|
||
{"type":"model_change","id":"e2a0fe07","parentId":null,"timestamp":"2026-06-04T13:08:07.297Z","provider":"xiaomimimo-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"2e3b10ad","parentId":"e2a0fe07","timestamp":"2026-06-04T13:08:07.297Z","thinkingLevel":"high"}
|
||
{"type":"message","id":"197d0378","parentId":"2e3b10ad","timestamp":"2026-06-04T13:08:52.345Z","message":{"role":"user","content":[{"type":"text","text":"帮我查看/shumengya/project/agent/sproutclaw这个项目"}],"timestamp":1780578532344}}
|
||
{"type":"message","id":"078aac1f","parentId":"197d0378","timestamp":"2026-06-04T13:08:54.880Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户想要了解 /shumengya/project/agent/sproutclaw 这个项目。我需要先查看这个项目的结构。让我先列出项目根目录的内容。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_041626c9e9d3425088782786","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":686,"output":74,"cacheRead":12288,"cacheWrite":0,"totalTokens":13048,"cost":{"input":0.0002744,"output":0.000148,"cacheRead":0.0009830400000000001,"cacheWrite":0,"total":0.00140544}},"stopReason":"toolUse","timestamp":1780578532345,"responseId":"1e99ee3aeb3d4f8eafa18531a8f24c0a"}}
|
||
{"type":"message","id":"b5f7ae9d","parentId":"078aac1f","timestamp":"2026-06-04T13:08:54.890Z","message":{"role":"toolResult","toolCallId":"call_041626c9e9d3425088782786","toolName":"bash","content":[{"type":"text","text":"总计 492\ndrwxr-xr-x 11 root root 4096 6月 2日 12:26 .\ndrwxr-xr-x 4 www www 4096 5月12日 16:53 ..\n-rw-r--r-- 1 root root 9179 5月22日 20:05 AGENTS.md\n-rw-r--r-- 1 root root 788 5月22日 20:05 biome.json\n-rw-r--r-- 1 root root 151289 5月 6日 20:54 bun.lock\ndrwxr-xr-x 2 root root 4096 5月 8日 17:47 .claude\n-rw-r--r-- 1 root root 6950 5月12日 14:46 context.md\n-rw-r--r-- 1 root root 4177 5月 6日 20:53 CONTRIBUTING.md\ndrwxr-xr-x 8 root root 4096 5月22日 20:45 .git\n-rw-r--r-- 1 root root 382 5月 6日 20:53 .gitattributes\ndrwxr-xr-x 4 root root 4096 5月22日 20:05 .github\n-rw-r--r-- 1 root root 939 5月30日 16:26 .gitignore\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 .husky\n-rw-r--r-- 1 root root 1069 5月 6日 20:53 LICENSE\ndrwxr-xr-x 247 root root 20480 6月 1日 12:27 node_modules\n-rw-r--r-- 1 root root 34 5月22日 20:05 .npmrc\n-rw-r--r-- 1 root root 2663 6月 1日 12:27 package.json\n-rw-r--r-- 1 root root 195425 6月 1日 12:27 package-lock.json\ndrwxr-xr-x 7 root root 4096 5月 6日 20:53 packages\ndrwxr-xr-x 3 root root 4096 5月30日 14:06 .pi\n-rwxr-xr-x 1 root root 1585 5月30日 16:26 pi-built.sh\n-rw-r--r-- 1 root root 352 5月22日 20:05 pi-test.bat\n-rw-r--r-- 1 root root 1741 5月22日 20:05 pi-test.ps1\n-rwxr-xr-x 1 root root 1680 5月22日 20:05 pi-test.sh\n-rw-r--r-- 1 root root 518 5月16日 01:00 progress.md\n-rw-r--r-- 1 root root 4876 5月30日 16:26 README.md\ndrwxr-xr-x 2 root root 4096 5月22日 20:38 scripts\n-rwxr-xr-x 1 root root 1830 5月22日 20:05 test.sh\ndrwxr-xr-x 2 root root 4096 6月 1日 12:22 tmp\n-rw-r--r-- 1 root root 625 5月22日 20:05 tsconfig.base.json\n-rw-r--r-- 1 root root 1217 5月22日 20:05 tsconfig.json\n"}],"isError":false,"timestamp":1780578534890}}
|
||
{"type":"message","id":"46e3cc90","parentId":"b5f7ae9d","timestamp":"2026-06-04T13:08:56.698Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"看起来这是一个 Node.js 项目,有 packages 目录,可能是 monorepo 结构。让我查看一下 package.json 来了解项目的基本信息。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_8e7f8317b9e840bc821f467c","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/package.json"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1166,"output":66,"cacheRead":12928,"cacheWrite":0,"totalTokens":14160,"cost":{"input":0.00046640000000000006,"output":0.00013199999999999998,"cacheRead":0.00103424,"cacheWrite":0,"total":0.00163264}},"stopReason":"toolUse","timestamp":1780578534891,"responseId":"d59d2fc4eae4488daa4f026f6cfe2ea4"}}
|
||
{"type":"message","id":"b2ac311e","parentId":"46e3cc90","timestamp":"2026-06-04T13:08:56.701Z","message":{"role":"toolResult","toolCallId":"call_8e7f8317b9e840bc821f467c","toolName":"read","content":[{"type":"text","text":"{\n\t\"name\": \"pi-monorepo\",\n\t\"private\": true,\n\t\"type\": \"module\",\n\t\"workspaces\": [\n\t\t\"packages/*\",\n\t\t\"packages/coding-agent/examples/extensions/with-deps\",\n\t\t\"packages/coding-agent/examples/extensions/custom-provider-anthropic\",\n\t\t\"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo\",\n\t\t\"packages/coding-agent/examples/extensions/sandbox\"\n\t],\n\t\"scripts\": {\n\t\t\"clean\": \"npm run clean --workspaces\",\n\t\t\"build\": \"cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build\",\n\t\t\"check\": \"biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && tsgo --noEmit && npm run check:browser-smoke\",\n\t\t\"check:browser-smoke\": \"node scripts/check-browser-smoke.mjs\",\n\t\t\"check:pinned-deps\": \"node scripts/check-pinned-deps.mjs\",\n\t\t\"check:shrinkwrap\": \"node scripts/generate-coding-agent-shrinkwrap.mjs --check\",\n\t\t\"check:ts-imports\": \"node scripts/check-ts-relative-imports.mjs\",\n\t\t\"profile:tui\": \"node scripts/profile-coding-agent-node.mjs --mode tui\",\n\t\t\"profile:rpc\": \"node scripts/profile-coding-agent-node.mjs --mode rpc\",\n\t\t\"test\": \"npm run test --workspaces --if-present\",\n\t\t\"version:patch\": \"npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only\",\n\t\t\"version:minor\": \"npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only\",\n\t\t\"version:major\": \"npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only\",\n\t\t\"version:set\": \"npm version -ws\",\n\t\t\"prepublishOnly\": \"npm run clean && npm run build && npm run check\",\n\t\t\"publish\": \"npm run prepublishOnly && npm publish -ws --access public\",\n\t\t\"publish:dry\": \"npm run prepublishOnly && npm publish -ws --access public --dry-run\",\n\t\t\"release:local\": \"node scripts/local-release.mjs\",\n\t\t\"shrinkwrap:coding-agent\": \"node scripts/generate-coding-agent-shrinkwrap.mjs\",\n\t\t\"release:patch\": \"node scripts/release.mjs patch\",\n\t\t\"release:minor\": \"node scripts/release.mjs minor\",\n\t\t\"release:major\": \"node scripts/release.mjs major\",\n\t\t\"prepare\": \"husky\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@anthropic-ai/sandbox-runtime\": \"0.0.26\",\n\t\t\"@biomejs/biome\": \"2.3.5\",\n\t\t\"@types/node\": \"22.19.19\",\n\t\t\"@typescript/native-preview\": \"7.0.0-dev.20260120.1\",\n\t\t\"esbuild\": \"0.28.0\",\n\t\t\"husky\": \"9.1.7\",\n\t\t\"jiti\": \"2.7.0\",\n\t\t\"shx\": \"0.4.0\",\n\t\t\"tsx\": \"4.22.1\",\n\t\t\"typescript\": \"5.9.3\"\n\t},\n\t\"engines\": {\n\t\t\"node\": \">=22.19.0\"\n\t},\n\t\"version\": \"0.0.3\",\n\t\"overrides\": {\n\t\t\"rimraf\": \"6.1.2\",\n\t\t\"gaxios\": {\n\t\t\t\"rimraf\": \"6.1.2\"\n\t\t}\n\t}\n}\n"}],"isError":false,"timestamp":1780578536701}}
|
||
{"type":"message","id":"a8add726","parentId":"b2ac311e","timestamp":"2026-06-04T13:08:59.885Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"这是一个名为 \"pi-monorepo\" 的 monorepo 项目,使用 npm workspaces。让我查看一下 packages 目录来了解具体的包结构。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_8fb4fe57c4ad4930958896f5","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":956,"output":72,"cacheRead":14080,"cacheWrite":0,"totalTokens":15108,"cost":{"input":0.0003824,"output":0.000144,"cacheRead":0.0011264,"cacheWrite":0,"total":0.0016528}},"stopReason":"toolUse","timestamp":1780578536702,"responseId":"8b4567a4069147ddb562a0b94c3c8869"}}
|
||
{"type":"message","id":"b689321c","parentId":"a8add726","timestamp":"2026-06-04T13:08:59.893Z","message":{"role":"toolResult","toolCallId":"call_8fb4fe57c4ad4930958896f5","toolName":"bash","content":[{"type":"text","text":"总计 28\ndrwxr-xr-x 7 root root 4096 5月 6日 20:53 .\ndrwxr-xr-x 11 root root 4096 6月 2日 12:26 ..\ndrwxr-xr-x 7 root root 4096 5月22日 20:05 agent\ndrwxr-xr-x 7 root root 4096 5月22日 20:05 ai\ndrwxr-xr-x 9 root root 4096 5月22日 20:05 coding-agent\ndrwxr-xr-x 7 root root 4096 5月22日 20:05 tui\ndrwxr-xr-x 5 root root 4096 5月22日 20:05 web-ui\n"}],"isError":false,"timestamp":1780578539893}}
|
||
{"type":"message","id":"e9d926b1","parentId":"b689321c","timestamp":"2026-06-04T13:09:02.010Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"这个项目有5个包:agent、ai、coding-agent、tui和web-ui。让我查看一下README.md来获取更多项目信息。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_28b3ff74dfe8429e8c294ae4","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/README.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":366,"output":66,"cacheRead":14976,"cacheWrite":0,"totalTokens":15408,"cost":{"input":0.0001464,"output":0.00013199999999999998,"cacheRead":0.00119808,"cacheWrite":0,"total":0.00147648}},"stopReason":"toolUse","timestamp":1780578539894,"responseId":"b0a97ce6c3e743d4810eb4740f507880"}}
|
||
{"type":"message","id":"ac0da286","parentId":"e9d926b1","timestamp":"2026-06-04T13:09:02.012Z","message":{"role":"toolResult","toolCallId":"call_28b3ff74dfe8429e8c294ae4","toolName":"read","content":[{"type":"text","text":"# sproutclaw\n\n`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 [pi-mono](https://github.com/badlogic/pi-mono) 改造,重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。\n\n## 定位\n\n- 服务器运维助手:协助 SSH 登录、服务状态检查、日志分析、配置调整和部署排障。\n- 开发协作助手:理解本地项目结构,执行代码修改、测试验证和 Git 工作流。\n- Docker 部署助手:按项目目录组织 `docker compose` 服务,关注数据持久化、端口规划和资源限制。\n- 内网服务助手:适配 smallmengya、bigmengya、alycd 等内网服务器使用习惯。\n- WebUI/TUI 双入口:保留控制台 TUI,同时扩展网页前端,方便在浏览器里管理会话和模型。\n\n## 项目结构\n\n| 路径 | 说明 |\n| --- | --- |\n| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体(含 sproutclaw 定制补丁) |\n| `packages/agent` | Agent 运行时、工具调用和状态管理 |\n| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |\n| `packages/tui` | 终端 UI 渲染库 |\n| `.pi/agent/extensions/webui` | 网页对话入口(React 前端 + HTTP 服务) |\n| `.pi/agent/extensions/*.ts` | sproutclaw 本地扩展(中文启动页、命令安装等) |\n| `scripts/sync-upstream.sh` | 合并上游 pi-mono 更新 |\n| `scripts/push-sproutclaw.sh` | 推送到 GitHub / Gitea |\n\n## 常用命令\n\n```bash\nnpm install --ignore-scripts\nnpm run build\nnpm run check\n./test.sh\n./pi-test.sh\n```\n\n运行本地源码版 Agent:\n\n```bash\n./pi-test.sh\n```\n\n启动 WebUI 时,在 TUI 中执行:\n\n```text\n/webui on 19133\n```\n\n关闭 WebUI:\n\n```text\n/webui off\n```\n\nWebUI 前端单独构建:\n\n```bash\ncd .pi/agent/extensions/webui/frontend && npm install && npm run build\n```\n\n## Git 分支策略\n\n本仓库是 pi-mono 的 fork,采用三层分支,同时维护**上游同步**和 **sproutclaw 定制**。\n\n| 分支 | 用途 |\n| --- | --- |\n| `main` | 稳定可跑版本:已合并的上游 + sproutclaw 定制,推送到 GitHub |\n| `upstream-sync` | 临时同步分支:专门用来 `merge upstream/main`,解决冲突后再合回 `main` |\n| `feature/*` | 日常开发:WebUI、扩展、补丁等,完成后 merge 进 `main` |\n\n### Remote 配置\n\n```text\norigin → https://github.com/shumengya/sproutclaw.git (你的 GitHub)\nupstream → https://github.com/badlogic/pi-mono.git (上游 pi)\ngitea → ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git (内网备份,可选)\n```\n\n首次配置 upstream:\n\n```bash\ngit remote add upstream https://github.com/badlogic/pi-mono.git\n```\n\nGitHub 网络不稳定时:\n\n```bash\nexport GIT_HTTP_VERSION=1.1\n```\n\n### 日常开发(feature 分支)\n\n```bash\n./scripts/new-feature.sh webui-avatar-fix # 从 main 创建 feature/webui-avatar-fix\n# ... 开发、测试 ...\ngit add <改动的具体文件> # 不要用 git add -A\ngit commit -m \"fix(webui): agent avatar during tool calls\"\ngit checkout main\ngit merge feature/webui-avatar-fix\n./scripts/push-sproutclaw.sh\ngit branch -d feature/webui-avatar-fix # 合并后可删\n```\n\n### 推送本地更新到 GitHub\n\n```bash\ngit checkout main\n./scripts/push-sproutclaw.sh\n```\n\n### 合并上游 pi-mono 更新\n\n```bash\n./scripts/sync-upstream.sh\n# 若有冲突,按下方规则解决后:\ngit add <resolved-files>\ngit commit\ngit checkout main && git merge upstream-sync\n./scripts/push-sproutclaw.sh\n```\n\n**合并冲突处理惯例:**\n\n| 文件 | 处理方式 |\n| --- | --- |\n| `README.md`、sproutclaw 定制代码 | 保留本仓库版本 |\n| `package-lock.json` | 采用 upstream,再执行 `npm install --ignore-scripts` |\n| `*.generated.ts` | 采用 upstream |\n| `.pi/agent/extensions/` | 保留 sproutclaw 版本 |\n\n合并后必做:\n\n```bash\nnpm install --ignore-scripts\nnpm run build\n./pi-test.sh\n```\n\n### sproutclaw 相对 upstream 的 intentional 改动\n\n合并上游时,以下定制**不要误删**:\n\n- `showChangelogOnStartup` 设置项(关闭启动 What's New)\n- `.pi/agent/extensions/webui/` 网页 UI\n- `.pi/agent/extensions/startup-chinese.ts` 等本地扩展\n- 本 README(仅 sproutclaw 说明,不含上游文档)\n\n## 本地配置\n\n`.pi/agent/` 中以下内容**不进 Git**(含密钥/内网地址):\n\n- `settings.json` → 参考 `settings.example.json` 本地复制\n- `models.json`、`auth.json`\n- `sessions/`\n\n首次部署:\n\n```bash\ncp .pi/agent/settings.example.json .pi/agent/settings.json\n# 编辑 settings.json 填入 provider / model\n```\n\n## 说明\n\n这个仓库是自用 fork,默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono,通过 `upstream-sync` 分支定期同步,sproutclaw 定制在 `feature/*` 开发后合入 `main`。\n"}],"isError":false,"timestamp":1780578542012}}
|
||
{"type":"message","id":"becd6714","parentId":"ac0da286","timestamp":"2026-06-04T13:09:04.310Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我对这个项目有了清晰的了解。这是一个基于 pi-mono 改造的 Agent 助手项目,主要用于服务器运维和开发工作。让我再看看一些关键目录的结构,比如 .pi/agent/extensions 和 packages/coding-agent 的结构。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_50118a8496ad4011871e28a9","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/.pi/agent"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1520,"output":96,"cacheRead":15296,"cacheWrite":0,"totalTokens":16912,"cost":{"input":0.000608,"output":0.000192,"cacheRead":0.00122368,"cacheWrite":0,"total":0.00202368}},"stopReason":"toolUse","timestamp":1780578542013,"responseId":"94ed9a0efb4948868e9ea8f9171fd2cf"}}
|
||
{"type":"message","id":"1cad7a2d","parentId":"becd6714","timestamp":"2026-06-04T13:09:04.319Z","message":{"role":"toolResult","toolCallId":"call_50118a8496ad4011871e28a9","toolName":"bash","content":[{"type":"text","text":"总计 88\ndrwx------ 10 root root 4096 6月 4日 21:08 .\ndrwxr-xr-x 3 root root 4096 5月30日 14:06 ..\nlrwxrwxrwx 1 root root 35 5月30日 15:20 AGENTS.md -> /shumengya/project/prompt/AGENTS.md\n-rw------- 1 root root 2 3月18日 09:06 auth.json\ndrwxr-xr-x 2 root root 4096 5月12日 15:00 bin\ndrwxr-xr-x 10 root root 4096 6月 1日 15:06 extensions\n-rw-r--r-- 1 root root 16305 6月 3日 22:26 mcp-cache.json\n-rw-r--r-- 1 root root 441 6月 2日 23:53 mcp.json\n-rw-r--r-- 1 root root 296 5月11日 19:58 mcp-npx-cache.json\n-rw-r--r-- 1 root root 6543 6月 3日 14:53 models.json\ndrwxr-xr-x 3 root root 4096 5月22日 21:19 npm\ndrwxr-xr-x 2 root root 4096 6月 3日 08:47 prompts\n-rw-r--r-- 1 root root 221 5月12日 14:46 run-history.jsonl\ndrwxr-xr-x 7 root root 4096 5月27日 00:15 sessions\n-rw-r--r-- 1 root root 315 5月30日 16:26 settings.example.json\n-rw-r--r-- 1 root root 1136 6月 3日 15:02 settings.json\ndrwxr-xr-x 16 root root 4096 6月 3日 12:13 skills\ndrwxr-xr-x 2 www www 4096 6月 3日 12:13 skills-disabled\ndrwxr-xr-x 2 root root 4096 5月 7日 08:24 taskplane\n"}],"isError":false,"timestamp":1780578544319}}
|
||
{"type":"message","id":"7f4deca0","parentId":"1cad7a2d","timestamp":"2026-06-04T13:09:07.417Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我看看 extensions 目录的结构,了解有哪些扩展。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_23e33c19a387465d8cfb4183","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/.pi/agent/extensions"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":787,"output":56,"cacheRead":16768,"cacheWrite":0,"totalTokens":17611,"cost":{"input":0.0003148,"output":0.000112,"cacheRead":0.00134144,"cacheWrite":0,"total":0.00176824}},"stopReason":"toolUse","timestamp":1780578544319,"responseId":"c78e1ea11ca64012a37adc921d373f1a"}}
|
||
{"type":"message","id":"4c06297d","parentId":"7f4deca0","timestamp":"2026-06-04T13:09:07.426Z","message":{"role":"toolResult","toolCallId":"call_23e33c19a387465d8cfb4183","toolName":"bash","content":[{"type":"text","text":"总计 40\ndrwxr-xr-x 10 root root 4096 6月 1日 15:06 .\ndrwx------ 10 root root 4096 6月 4日 21:08 ..\ndrwxr-xr-x 2 root root 4096 6月 1日 15:04 exit-command\ndrwxr-xr-x 2 root root 4096 6月 1日 15:05 prompt-url-widget\ndrwxr-xr-x 2 root root 4096 6月 1日 15:05 redraws\ndrwxr-xr-x 2 root root 4096 6月 1日 15:05 sproutclaw-setup\ndrwxr-xr-x 2 root root 4096 6月 1日 15:06 startup-chinese\ndrwxr-xr-x 2 root root 4096 6月 1日 15:06 status-line\ndrwxr-xr-x 2 root root 4096 6月 1日 15:06 tps\ndrwxr-xr-x 7 root root 4096 6月 3日 13:19 webui\n"}],"isError":false,"timestamp":1780578547425}}
|
||
{"type":"message","id":"f3879a7d","parentId":"4c06297d","timestamp":"2026-06-04T13:09:09.499Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我看看 packages/coding-agent 的结构,这是项目的主要部分。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_19e008541620424abf95242c","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages/coding-agent"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":409,"output":56,"cacheRead":17536,"cacheWrite":0,"totalTokens":18001,"cost":{"input":0.00016360000000000002,"output":0.000112,"cacheRead":0.00140288,"cacheWrite":0,"total":0.00167848}},"stopReason":"toolUse","timestamp":1780578547426,"responseId":"d4910f28ea6b427899b6938a1145ea77"}}
|
||
{"type":"message","id":"ba90c766","parentId":"f3879a7d","timestamp":"2026-06-04T13:09:09.507Z","message":{"role":"toolResult","toolCallId":"call_19e008541620424abf95242c","toolName":"bash","content":[{"type":"text","text":"总计 516\ndrwxr-xr-x 9 root root 4096 5月22日 20:05 .\ndrwxr-xr-x 7 root root 4096 5月 6日 20:53 ..\n-rw-r--r-- 1 root root 380368 5月22日 20:05 CHANGELOG.md\ndrwxr-xr-x 7 root root 4096 5月 6日 20:59 dist\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 docs\ndrwxr-xr-x 4 root root 4096 5月 8日 19:40 examples\n-rw-r--r-- 1 root root 12 5月 6日 20:53 .gitignore\ndrwxr-xr-x 8 root root 4096 5月 6日 21:01 node_modules\n-rw-r--r-- 1 root root 60486 5月22日 20:05 npm-shrinkwrap.json\n-rw-r--r-- 1 root root 3461 5月22日 20:05 package.json\n-rw-r--r-- 1 root root 27170 5月22日 20:05 README.md\ndrwxr-xr-x 2 root root 4096 5月 6日 20:53 scripts\ndrwxr-xr-x 7 root root 4096 5月22日 20:05 src\ndrwxr-xr-x 5 root root 4096 5月22日 20:05 test\n-rw-r--r-- 1 root root 196 5月22日 20:05 tsconfig.build.json\n-rw-r--r-- 1 root root 484 5月 8日 19:40 tsconfig.examples.json\n-rw-r--r-- 1 root root 990 5月 8日 19:40 vitest.config.ts\n"}],"isError":false,"timestamp":1780578549507}}
|
||
{"type":"message","id":"67853dd5","parentId":"ba90c766","timestamp":"2026-06-04T13:09:11.563Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 packages/coding-agent/src 目录的结构,这是主要源码目录。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_20a3ef9efd304034bee126ea","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages/coding-agent/src"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":641,"output":63,"cacheRead":17920,"cacheWrite":0,"totalTokens":18624,"cost":{"input":0.0002564,"output":0.000126,"cacheRead":0.0014336,"cacheWrite":0,"total":0.001816}},"stopReason":"toolUse","timestamp":1780578549507,"responseId":"58ef4e457abe488e9e5d8b22f4aecc3e"}}
|
||
{"type":"message","id":"27dbce8a","parentId":"67853dd5","timestamp":"2026-06-04T13:09:11.571Z","message":{"role":"toolResult","toolCallId":"call_20a3ef9efd304034bee126ea","toolName":"bash","content":[{"type":"text","text":"总计 120\ndrwxr-xr-x 7 root root 4096 5月22日 20:05 .\ndrwxr-xr-x 9 root root 4096 5月22日 20:05 ..\ndrwxr-xr-x 2 root root 4096 5月22日 20:05 bun\ndrwxr-xr-x 2 root root 4096 5月22日 20:05 cli\n-rw-r--r-- 1 root root 682 5月22日 20:05 cli.ts\n-rw-r--r-- 1 root root 17678 5月22日 20:05 config.ts\ndrwxr-xr-x 6 root root 4096 5月22日 20:38 core\n-rw-r--r-- 1 root root 8572 5月22日 20:05 index.ts\n-rw-r--r-- 1 root root 23634 5月22日 20:05 main.ts\n-rw-r--r-- 1 root root 9244 5月22日 20:05 migrations.ts\ndrwxr-xr-x 4 root root 4096 5月22日 20:05 modes\n-rw-r--r-- 1 root root 17275 5月22日 20:05 package-manager-cli.ts\ndrwxr-xr-x 2 root root 4096 5月22日 20:05 utils\n"}],"isError":false,"timestamp":1780578551571}}
|
||
{"type":"message","id":"5d146683","parentId":"27dbce8a","timestamp":"2026-06-04T13:09:13.585Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我看看 git log 来了解项目的最近开发情况。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_0ec4348d989748c5b3e1ec4b","name":"bash","arguments":{"command":"cd /shumengya/project/agent/sproutclaw && git log --oneline -10"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":553,"output":57,"cacheRead":18496,"cacheWrite":0,"totalTokens":19106,"cost":{"input":0.0002212,"output":0.00011399999999999999,"cacheRead":0.00147968,"cacheWrite":0,"total":0.00181488}},"stopReason":"toolUse","timestamp":1780578551572,"responseId":"ae8fe597588b4e5081cc75d8945c629c"}}
|
||
{"type":"message","id":"6d1bba7c","parentId":"5d146683","timestamp":"2026-06-04T13:09:13.597Z","message":{"role":"toolResult","toolCallId":"call_0ec4348d989748c5b3e1ec4b","toolName":"bash","content":[{"type":"text","text":"11c3a3a3 fix(ci): satisfy ts-imports and regenerate image models\n09227912 fix(webui): pin frontend deps for CI check:pinned-deps\n0a91cc99 chore: add sproutclaw git workflow and track local extensions\na8daa1d8 merge: sync upstream pi-mono main into sproutclaw\n7426ce97 Tighten AGENTS.md and extract LLM provider checklist to skill\nc100620b fix(coding-agent): Clean up Path Handling (#4873)\nbf56a86e fix(coding-agent): reconcile git package refs\n1a2a536d chore: update PR prompt template\nced73b39 docs: note Node 20 rescue release\n11c3da4f fix(ai): set bedrock claude default max tokens\n"}],"isError":false,"timestamp":1780578553597}}
|
||
{"type":"message","id":"b5783279","parentId":"6d1bba7c","timestamp":"2026-06-04T13:09:15.019Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我看看项目的当前状态,包括分支信息。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_eff34fcc597c46178997224a","name":"bash","arguments":{"command":"cd /shumengya/project/agent/sproutclaw && git status"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":305,"output":49,"cacheRead":19008,"cacheWrite":0,"totalTokens":19362,"cost":{"input":0.00012200000000000001,"output":0.000098,"cacheRead":0.00152064,"cacheWrite":0,"total":0.00174064}},"stopReason":"toolUse","timestamp":1780578553598,"responseId":"e93ba432c9f44f3d82847ca6f3c012d5"}}
|
||
{"type":"message","id":"4eeb8768","parentId":"b5783279","timestamp":"2026-06-04T13:09:15.039Z","message":{"role":"toolResult","toolCallId":"call_eff34fcc597c46178997224a","toolName":"bash","content":[{"type":"text","text":"位于分支 main\n您的分支与上游分支 'origin/main' 一致。\n\n尚未暂存以备提交的变更:\n (使用 \"git add/rm <文件>...\" 更新要提交的内容)\n (使用 \"git restore <文件>...\" 丢弃工作区的改动)\n\t类型变更: .pi/agent/AGENTS.md\n\t删除: .pi/agent/extensions/exit-command.ts\n\t删除: .pi/agent/extensions/prompt-url-widget.ts\n\t删除: .pi/agent/extensions/redraws.ts\n\t删除: .pi/agent/extensions/sproutclaw-setup.ts\n\t删除: .pi/agent/extensions/startup-chinese.ts\n\t删除: .pi/agent/extensions/status-line.ts\n\t删除: .pi/agent/extensions/tps.ts\n\t修改: .pi/agent/extensions/webui/.gitignore\n\t删除: .pi/agent/extensions/webui/db.ts\n\t修改: .pi/agent/extensions/webui/frontend/.gitignore\n\t修改: .pi/agent/extensions/webui/frontend/index.html\n\t修改: .pi/agent/extensions/webui/frontend/package-lock.json\n\t修改: .pi/agent/extensions/webui/frontend/package.json\n\t修改: .pi/agent/extensions/webui/frontend/src/App.tsx\n\t修改: .pi/agent/extensions/webui/frontend/src/api/chat.ts\n\t修改: .pi/agent/extensions/webui/frontend/src/api/client.ts\n\t修改: .pi/agent/extensions/webui/frontend/src/api/sessions.ts\n\t修改: .pi/agent/extensions/webui/frontend/src/api/settings.ts\n\t修改: .pi/agent/extensions/webui/frontend/src/components/chat/ChatInput.tsx\n\t修改: .pi/agent/extensions/webui/frontend/src/components/chat/ExportMenu.module.css\n\t修改: .pi/agent/extensions/webui/frontend/src/components/chat/ExportMenu.tsx\n\t修改: .pi/agent/extensions/webui/frontend/src/components/chat/MessageBubble.tsx\n\t修改: .pi/agent/extensions/webui/frontend/src/components/chat/MessageList.module.css\n\t修改: .pi/agent/extensions/webui/frontend/src/components/chat/MessageList.tsx\n\t修改: .pi/agent/extensions/webui/frontend/src/components/chat/SessionContextBar.module.css\n\t修改: .pi/agent/extensions/webui/frontend/src/components/chat/SessionContextBar.tsx\n\t修改: .pi/agent/extensions/webui/frontend/src/components/chat/ToolCallBlock.module.css\n\t修改: .pi/agent/extensions/webui/frontend/src/components/layout/Header.module.css\n\t修改: .pi/agent/extensions/webui/frontend/src/components/layout/Header.tsx\n\t修改: .pi/agent/extensions/webui/frontend/src/components/session/SessionList.module.css\n\t修改: .pi/agent/extensions/webui/frontend/src/components/session/SessionList.tsx\n\t修改: .pi/agent/extensions/webui/frontend/src/context/ChatContext.tsx\n\t修改: .pi/agent/extensions/webui/frontend/src/main.tsx\n\t修改: .pi/agent/extensions/webui/frontend/src/routes/SettingsPage.module.css\n\t修改: .pi/agent/extensions/webui/frontend/src/routes/SettingsPage.tsx\n\t修改: .pi/agent/extensions/webui/frontend/src/styles/global.css\n\t修改: .pi/agent/extensions/webui/frontend/src/styles/variables.css\n\t修改: .pi/agent/extensions/webui/frontend/src/types/events.ts\n\t修改: .pi/agent/extensions/webui/frontend/src/types/message.ts\n\t修改: .pi/agent/extensions/webui/frontend/src/utils/exportConversation.ts\n\t修改: .pi/agent/extensions/webui/frontend/src/utils/sessionTitle.ts\n\t修改: .pi/agent/extensions/webui/frontend/src/utils/toolCall.ts\n\t修改: .pi/agent/extensions/webui/frontend/src/vite-env.d.ts\n\t修改: .pi/agent/extensions/webui/frontend/vite.config.ts\n\t修改: .pi/agent/extensions/webui/index.ts\n\t修改: .pi/agent/extensions/webui/server.ts\n\t删除: .pi/prompts/is.md\n\t删除: .pi/prompts/pr.md\n\t删除: .pi/prompts/wr.md\n\t删除: .pi/skills/add-llm-provider.md\n\t修改: package-lock.json\n\t修改: packages/agent/src/harness/skills.ts\n\t修改: packages/agent/test/harness/skills.test.ts\n\t修改: packages/ai/src/image-models.generated.ts\n\t修改: packages/ai/src/models.generated.ts\n\t修改: packages/coding-agent/src/core/skills.ts\n\t修改: packages/coding-agent/src/modes/interactive/interactive-mode.ts\n\t修改: packages/coding-agent/test/interactive-mode-status.test.ts\n\t修改: packages/coding-agent/test/skills.test.ts\n\t修改: scripts/update-source-imports-to-ts.sh\n\n未跟踪的文件:\n (使用 \"git add <文件>...\" 以包含要提交的内容)\n\t.pi/agent/bin/\n\t.pi/agent/extensions/exit-command/\n\t.pi/agent/extensions/prompt-url-widget/\n\t.pi/agent/extensions/redraws/\n\t.pi/agent/extensions/sproutclaw-setup/\n\t.pi/agent/extensions/startup-chinese/\n\t.pi/agent/extensions/status-line/\n\t.pi/agent/extensions/tps/\n\t.pi/agent/extensions/webui/backend/\n\t.pi/agent/extensions/webui/frontend/.env.desktop\n\t.pi/agent/extensions/webui/frontend/src/api/base.ts\n\t.pi/agent/extensions/webui/frontend/src/api/commands.ts\n\t.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.module.css\n\t.pi/agent/extensions/webui/frontend/src/components/chat/BashOutputBlock.tsx\n\t.pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.module.css\n\t.pi/agent/extensions/webui/frontend/src/components/chat/SlashCommandMenu.tsx\n\t.pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.module.css\n\t.pi/agent/extensions/webui/frontend/src/components/chat/ThinkingBlock.tsx\n\t.pi/agent/extensions/webui/frontend/src/types/commands.ts\n\t.pi/agent/extensions/webui/frontend/src/utils/bash.ts\n\t.pi/agent/extensions/webui/frontend/src/utils/slashCommands.ts\n\t.pi/agent/extensions/webui/scripts/\n\t.pi/agent/extensions/webui/systemd/\n\t.pi/agent/mcp-npx-cache.json\n\t.pi/agent/mcp.json\n\t.pi/agent/prompts/\n\t.pi/agent/skills/\n\t.pi/agent/taskplane/\n\tpackages/coding-agent/test/fixtures/skills/skills-dir-readme/\n\tpi-built.sh\n\tprogress.md\n\n修改尚未加入提交(使用 \"git add\" 和/或 \"git commit -a\")\n"}],"isError":false,"timestamp":1780578555039}}
|
||
{"type":"message","id":"730137e6","parentId":"4eeb8768","timestamp":"2026-06-04T13:09:19.628Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下项目的进度文件,了解当前的开发状态。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_a892ac1568734410b95b687a","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/progress.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1575,"output":48,"cacheRead":19264,"cacheWrite":0,"totalTokens":20887,"cost":{"input":0.00063,"output":0.000096,"cacheRead":0.00154112,"cacheWrite":0,"total":0.00226712}},"stopReason":"toolUse","timestamp":1780578555041,"responseId":"b39773350de94f9e96b6bf2fd0bb0efa"}}
|
||
{"type":"message","id":"8cc6a8b3","parentId":"730137e6","timestamp":"2026-06-04T13:09:19.631Z","message":{"role":"toolResult","toolCallId":"call_a892ac1568734410b95b687a","toolName":"read","content":[{"type":"text","text":"# Progress\n\n## Task: 搜索 2026-05-13 全球 AI 研究论文、突破与开源项目\n\nStatus: ❌ 受阻 — 无可用网络搜索工具\n\n- [x] 任务启动\n- [ ] 执行网络搜索 — 受阻:当前子代理仅暴露了 `read`/`write` 工具,无 `web_search`/`web_fetch` 工具\n- [ ] 整理发现\n- [ ] 撰写报告\n\n## 原因\n\n子代理工具集仅包含文件读写(`read`, `write`),缺少执行网络搜索所需的工具。需要父编排器提供搜索数据,或赋予子代理网络访问能力。\n"}],"isError":false,"timestamp":1780578559631}}
|
||
{"type":"message","id":"863ca61d","parentId":"8cc6a8b3","timestamp":"2026-06-04T13:09:21.593Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我看看 packages/coding-agent/src/core 目录的结构,了解核心模块。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_eaee0b505a1041c49306000f","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages/coding-agent/src/core"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":262,"output":62,"cacheRead":20800,"cacheWrite":0,"totalTokens":21124,"cost":{"input":0.00010480000000000001,"output":0.000124,"cacheRead":0.0016640000000000001,"cacheWrite":0,"total":0.0018928}},"stopReason":"toolUse","timestamp":1780578559631,"responseId":"56d1375213964712ab4d67e99d8b2374"}}
|
||
{"type":"message","id":"4297c45c","parentId":"863ca61d","timestamp":"2026-06-04T13:09:21.601Z","message":{"role":"toolResult","toolCallId":"call_eaee0b505a1041c49306000f","toolName":"bash","content":[{"type":"text","text":"总计 568\ndrwxr-xr-x 6 root root 4096 5月22日 20:38 .\ndrwxr-xr-x 7 root root 4096 5月22日 20:05 ..\n-rw-r--r-- 1 root root 14132 5月22日 20:05 agent-session-runtime.ts\n-rw-r--r-- 1 root root 6771 5月22日 20:05 agent-session-services.ts\n-rw-r--r-- 1 root root 103318 5月22日 20:05 agent-session.ts\n-rw-r--r-- 1 root root 869 5月22日 20:05 auth-guidance.ts\n-rw-r--r-- 1 root root 14188 5月22日 20:05 auth-storage.ts\n-rw-r--r-- 1 root root 4487 5月22日 20:05 bash-executor.ts\ndrwxr-xr-x 2 root root 4096 5月22日 20:05 compaction\n-rw-r--r-- 1 root root 132 5月 8日 19:40 defaults.ts\n-rw-r--r-- 1 root root 452 5月 6日 20:53 diagnostics.ts\n-rw-r--r-- 1 root root 795 5月 6日 20:53 event-bus.ts\n-rw-r--r-- 1 root root 2419 5月22日 20:05 exec.ts\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 export-html\ndrwxr-xr-x 2 root root 4096 5月22日 20:05 extensions\n-rw-r--r-- 1 root root 10410 5月22日 20:05 footer-data-provider.ts\n-rw-r--r-- 1 root root 1732 5月22日 20:05 http-dispatcher.ts\n-rw-r--r-- 1 root root 2143 5月22日 20:05 index.ts\n-rw-r--r-- 1 root root 11339 5月22日 20:05 keybindings.ts\n-rw-r--r-- 1 root root 5180 5月 8日 19:40 messages.ts\n-rw-r--r-- 1 root root 33132 5月22日 20:05 model-registry.ts\n-rw-r--r-- 1 root root 21201 5月22日 20:05 model-resolver.ts\n-rw-r--r-- 1 root root 2009 5月 6日 20:53 output-guard.ts\n-rw-r--r-- 1 root root 76913 5月22日 20:05 package-manager.ts\n-rw-r--r-- 1 root root 8156 5月22日 20:05 prompt-templates.ts\n-rw-r--r-- 1 root root 1063 5月 9日 13:48 provider-display-names.ts\n-rw-r--r-- 1 root root 4420 5月22日 20:05 resolve-config-value.ts\n-rw-r--r-- 1 root root 32271 5月22日 20:05 resource-loader.ts\n-rw-r--r-- 1 root root 13874 5月22日 20:05 sdk.ts\n-rw-r--r-- 1 root root 1628 5月 6日 20:53 session-cwd.ts\n-rw-r--r-- 1 root root 44502 5月22日 20:05 session-manager.ts\n-rw-r--r-- 1 root root 34423 5月30日 16:26 settings-manager.ts\n-rw-r--r-- 1 root root 14430 5月30日 16:26 skills.ts\n-rw-r--r-- 1 root root 1972 5月22日 20:05 slash-commands.ts\n-rw-r--r-- 1 root root 852 5月22日 20:05 source-info.ts\n-rw-r--r-- 1 root root 6387 5月22日 20:05 system-prompt.ts\n-rw-r--r-- 1 root root 515 5月22日 20:05 telemetry.ts\n-rw-r--r-- 1 root root 841 5月 6日 20:53 timings.ts\ndrwxr-xr-x 2 root root 4096 5月22日 20:05 tools\n"}],"isError":false,"timestamp":1780578561601}}
|
||
{"type":"message","id":"9348fe8f","parentId":"4297c45c","timestamp":"2026-06-04T13:09:23.124Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 packages/agent 目录的结构,了解 Agent 运行时部分。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_358330afcc7a4261a45e010e","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages/agent"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1373,"output":61,"cacheRead":21056,"cacheWrite":0,"totalTokens":22490,"cost":{"input":0.0005492,"output":0.000122,"cacheRead":0.0016844800000000001,"cacheWrite":0,"total":0.00235568}},"stopReason":"toolUse","timestamp":1780578561602,"responseId":"01e4d591fcc74dd5853d140bdb09ee3d"}}
|
||
{"type":"message","id":"217279ff","parentId":"9348fe8f","timestamp":"2026-06-04T13:09:23.132Z","message":{"role":"toolResult","toolCallId":"call_358330afcc7a4261a45e010e","toolName":"bash","content":[{"type":"text","text":"总计 76\ndrwxr-xr-x 7 root root 4096 5月22日 20:05 .\ndrwxr-xr-x 7 root root 4096 5月 6日 20:53 ..\n-rw-r--r-- 1 root root 13953 5月22日 20:05 CHANGELOG.md\ndrwxr-xr-x 3 root root 4096 5月22日 20:09 dist\ndrwxr-xr-x 2 root root 4096 5月22日 20:05 docs\ndrwxr-xr-x 8 root root 4096 5月 6日 21:00 node_modules\n-rw-r--r-- 1 root root 1387 5月22日 20:05 package.json\n-rw-r--r-- 1 root root 15981 5月 8日 19:40 README.md\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 src\ndrwxr-xr-x 5 root root 4096 5月22日 20:05 test\n-rw-r--r-- 1 root root 209 5月 6日 20:53 tsconfig.build.json\n-rw-r--r-- 1 root root 184 5月 6日 20:53 vitest.config.ts\n-rw-r--r-- 1 root root 435 5月22日 20:05 vitest.harness.config.ts\n"}],"isError":false,"timestamp":1780578563131}}
|
||
{"type":"message","id":"f26607fc","parentId":"217279ff","timestamp":"2026-06-04T13:09:24.691Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 packages/ai 目录的结构,了解 LLM 接入层。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_ef90ce1cc0b542f6bce441c2","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages/ai"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":520,"output":61,"cacheRead":22400,"cacheWrite":0,"totalTokens":22981,"cost":{"input":0.00020800000000000001,"output":0.000122,"cacheRead":0.001792,"cacheWrite":0,"total":0.0021219999999999998}},"stopReason":"toolUse","timestamp":1780578563132,"responseId":"0a9897c2b5a74b458c00dba392de6eea"}}
|
||
{"type":"message","id":"b9013fa0","parentId":"f26607fc","timestamp":"2026-06-04T13:09:24.699Z","message":{"role":"toolResult","toolCallId":"call_ef90ce1cc0b542f6bce441c2","toolName":"bash","content":[{"type":"text","text":"总计 200\ndrwxr-xr-x 7 root root 4096 5月22日 20:05 .\ndrwxr-xr-x 7 root root 4096 5月 6日 20:53 ..\n-rw-r--r-- 1 root root 44 5月 6日 20:53 bedrock-provider.d.ts\n-rw-r--r-- 1 root root 44 5月 6日 20:53 bedrock-provider.js\n-rw-r--r-- 1 root root 94536 5月22日 20:05 CHANGELOG.md\ndrwxr-xr-x 4 root root 4096 5月12日 14:06 dist\ndrwxr-xr-x 11 root root 4096 5月 6日 21:00 node_modules\n-rw-r--r-- 1 root root 2734 5月22日 20:05 package.json\n-rw-r--r-- 1 root root 54025 5月22日 20:05 README.md\ndrwxr-xr-x 2 root root 4096 5月22日 20:05 scripts\ndrwxr-xr-x 4 root root 4096 5月22日 20:45 src\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 test\n-rw-r--r-- 1 root root 208 5月 6日 20:53 tsconfig.build.json\n-rw-r--r-- 1 root root 190 5月 6日 20:53 vitest.config.ts\n"}],"isError":false,"timestamp":1780578564699}}
|
||
{"type":"message","id":"b2873eaa","parentId":"b9013fa0","timestamp":"2026-06-04T13:09:27.997Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 packages/ai/src 目录的结构,了解具体的 LLM 模型实现。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_e3b2719cbd684377973bcd4e","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages/ai/src"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":544,"output":64,"cacheRead":22912,"cacheWrite":0,"totalTokens":23520,"cost":{"input":0.00021760000000000003,"output":0.000128,"cacheRead":0.00183296,"cacheWrite":0,"total":0.00217856}},"stopReason":"toolUse","timestamp":1780578564700,"responseId":"7e106c3618a84124abd9f1376825a493"}}
|
||
{"type":"message","id":"d6cb8b0a","parentId":"b2873eaa","timestamp":"2026-06-04T13:09:28.005Z","message":{"role":"toolResult","toolCallId":"call_e3b2719cbd684377973bcd4e","toolName":"bash","content":[{"type":"text","text":"总计 528\ndrwxr-xr-x 4 root root 4096 5月22日 20:45 .\ndrwxr-xr-x 7 root root 4096 5月22日 20:05 ..\n-rw-r--r-- 1 root root 2563 5月22日 20:05 api-registry.ts\n-rw-r--r-- 1 root root 165 5月22日 20:05 bedrock-provider.ts\n-rw-r--r-- 1 root root 3881 5月22日 20:05 cli.ts\n-rw-r--r-- 1 root root 7778 5月22日 20:05 env-api-keys.ts\n-rw-r--r-- 1 root root 12376 6月 3日 13:23 image-models.generated.ts\n-rw-r--r-- 1 root root 1667 5月22日 20:05 image-models.ts\n-rw-r--r-- 1 root root 1640 5月22日 20:05 images-api-registry.ts\n-rw-r--r-- 1 root root 727 5月22日 20:05 images.ts\n-rw-r--r-- 1 root root 1851 5月22日 20:05 index.ts\n-rw-r--r-- 1 root root 428219 6月 3日 13:23 models.generated.ts\n-rw-r--r-- 1 root root 3609 5月22日 20:05 models.ts\n-rw-r--r-- 1 root root 40 5月22日 20:05 oauth.ts\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 providers\n-rw-r--r-- 1 root root 671 5月 6日 20:53 session-resources.ts\n-rw-r--r-- 1 root root 1486 5月22日 20:05 stream.ts\n-rw-r--r-- 1 root root 21541 5月22日 20:05 types.ts\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 utils\n"}],"isError":false,"timestamp":1780578568005}}
|
||
{"type":"message","id":"a8a60ebd","parentId":"d6cb8b0a","timestamp":"2026-06-04T13:09:30.570Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 packages/ai/src/providers 目录,了解支持的 provider。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_492a12c6745944e5b061a6da","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages/ai/src/providers"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":729,"output":60,"cacheRead":23424,"cacheWrite":0,"totalTokens":24213,"cost":{"input":0.00029160000000000004,"output":0.00011999999999999999,"cacheRead":0.00187392,"cacheWrite":0,"total":0.00228552}},"stopReason":"toolUse","timestamp":1780578568006,"responseId":"360800bbd95d4482932207bb7dfe9251"}}
|
||
{"type":"message","id":"6c288af1","parentId":"a8a60ebd","timestamp":"2026-06-04T13:09:30.578Z","message":{"role":"toolResult","toolCallId":"call_492a12c6745944e5b061a6da","toolName":"bash","content":[{"type":"text","text":"总计 336\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 .\ndrwxr-xr-x 4 root root 4096 5月22日 20:45 ..\n-rw-r--r-- 1 root root 32715 5月22日 20:05 amazon-bedrock.ts\n-rw-r--r-- 1 root root 37322 5月22日 20:05 anthropic.ts\n-rw-r--r-- 1 root root 9838 5月22日 20:05 azure-openai-responses.ts\n-rw-r--r-- 1 root root 1531 5月22日 20:05 cloudflare.ts\n-rw-r--r-- 1 root root 15332 5月22日 20:05 faux.ts\n-rw-r--r-- 1 root root 1184 5月22日 20:05 github-copilot-headers.ts\n-rw-r--r-- 1 root root 11883 5月22日 20:05 google-shared.ts\n-rw-r--r-- 1 root root 14819 5月22日 20:05 google.ts\n-rw-r--r-- 1 root root 16724 5月22日 20:05 google-vertex.ts\ndrwxr-xr-x 2 root root 4096 5月22日 20:05 images\n-rw-r--r-- 1 root root 20312 5月22日 20:05 mistral.ts\n-rw-r--r-- 1 root root 43563 5月22日 20:05 openai-codex-responses.ts\n-rw-r--r-- 1 root root 40585 5月22日 20:05 openai-completions.ts\n-rw-r--r-- 1 root root 358 5月22日 20:05 openai-prompt-cache.ts\n-rw-r--r-- 1 root root 19120 5月22日 20:05 openai-responses-shared.ts\n-rw-r--r-- 1 root root 10193 5月22日 20:05 openai-responses.ts\n-rw-r--r-- 1 root root 14520 5月22日 20:05 register-builtins.ts\n-rw-r--r-- 1 root root 1763 5月22日 20:05 simple-options.ts\n-rw-r--r-- 1 root root 6837 5月22日 20:05 transform-messages.ts\n"}],"isError":false,"timestamp":1780578570578}}
|
||
{"type":"message","id":"87fb3be4","parentId":"6c288af1","timestamp":"2026-06-04T13:09:32.434Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 packages/tui 目录的结构,了解终端 UI 部分。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_3a54a73bd94b4197bdbf1528","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages/tui"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":810,"output":61,"cacheRead":24128,"cacheWrite":0,"totalTokens":24999,"cost":{"input":0.000324,"output":0.000122,"cacheRead":0.00193024,"cacheWrite":0,"total":0.00237624}},"stopReason":"toolUse","timestamp":1780578570579,"responseId":"13bc01eb21b94ffe8923f3236cfec1df"}}
|
||
{"type":"message","id":"4a8bfa48","parentId":"87fb3be4","timestamp":"2026-06-04T13:09:32.442Z","message":{"role":"toolResult","toolCallId":"call_3a54a73bd94b4197bdbf1528","toolName":"bash","content":[{"type":"text","text":"总计 112\ndrwxr-xr-x 7 root root 4096 5月22日 20:05 .\ndrwxr-xr-x 7 root root 4096 5月 6日 20:53 ..\n-rw-r--r-- 1 root root 46373 5月22日 20:05 CHANGELOG.md\ndrwxr-xr-x 3 root root 4096 5月 6日 20:54 dist\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 native\ndrwxr-xr-x 5 root root 4096 5月 6日 20:58 node_modules\n-rw-r--r-- 1 root root 1037 5月22日 20:05 package.json\n-rw-r--r-- 1 root root 23668 5月 8日 19:40 README.md\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 src\ndrwxr-xr-x 2 root root 4096 5月22日 20:05 test\n-rw-r--r-- 1 root root 175 5月 6日 20:53 tsconfig.build.json\n-rw-r--r-- 1 root root 133 5月 6日 20:53 vitest.config.ts\n"}],"isError":false,"timestamp":1780578572442}}
|
||
{"type":"message","id":"acc1aa4f","parentId":"4a8bfa48","timestamp":"2026-06-04T13:09:34.061Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 packages/web-ui 目录的结构,了解 Web UI 部分。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_e4ca9ad522534d9dae5048d5","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages/web-ui"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":499,"output":61,"cacheRead":24896,"cacheWrite":0,"totalTokens":25456,"cost":{"input":0.00019960000000000003,"output":0.000122,"cacheRead":0.00199168,"cacheWrite":0,"total":0.00231328}},"stopReason":"toolUse","timestamp":1780578572443,"responseId":"34ef977f560a4e869d649bb84dd12300"}}
|
||
{"type":"message","id":"b47861d4","parentId":"acc1aa4f","timestamp":"2026-06-04T13:09:34.069Z","message":{"role":"toolResult","toolCallId":"call_e4ca9ad522534d9dae5048d5","toolName":"bash","content":[{"type":"text","text":"总计 20\ndrwxr-xr-x 5 root root 4096 5月22日 20:05 .\ndrwxr-xr-x 7 root root 4096 5月 6日 20:53 ..\ndrwxr-xr-x 8 root root 4096 5月 6日 20:59 dist\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 example\ndrwxr-xr-x 6 root root 4096 5月 6日 20:58 node_modules\n"}],"isError":false,"timestamp":1780578574069}}
|
||
{"type":"message","id":"c04b9e3c","parentId":"b47861d4","timestamp":"2026-06-04T13:09:36.087Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 .pi/agent/extensions/webui 目录的结构,了解 WebUI 扩展。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_f110ff9839374c5a9cc13a4b","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":277,"output":68,"cacheRead":25344,"cacheWrite":0,"totalTokens":25689,"cost":{"input":0.00011080000000000001,"output":0.000136,"cacheRead":0.00202752,"cacheWrite":0,"total":0.0022743200000000002}},"stopReason":"toolUse","timestamp":1780578574070,"responseId":"0f44791de80a48998df364d282db6f39"}}
|
||
{"type":"message","id":"41a366a3","parentId":"c04b9e3c","timestamp":"2026-06-04T13:09:36.096Z","message":{"role":"toolResult","toolCallId":"call_f110ff9839374c5a9cc13a4b","toolName":"bash","content":[{"type":"text","text":"总计 60\ndrwxr-xr-x 7 root root 4096 6月 3日 13:19 .\ndrwxr-xr-x 10 root root 4096 6月 1日 15:06 ..\ndrwxr-xr-x 11 root root 4096 6月 1日 17:13 backend\ndrwxr-xr-x 2 root root 4096 6月 3日 13:19 data\ndrwxr-xr-x 8 root root 4096 6月 1日 19:56 frontend\n-rw-r--r-- 1 root root 97 6月 3日 11:58 .gitignore\n-rw-r--r-- 1 root root 14562 6月 3日 11:58 index.ts\ndrwxr-xr-x 2 root root 4096 6月 1日 17:22 scripts\n-rw-r--r-- 1 root root 161 6月 3日 11:58 server.ts\ndrwxr-xr-x 2 root root 4096 6月 1日 17:21 systemd\n-rw-r--r-- 1 root root 7 6月 3日 13:19 .webui.pid\n-rw-r--r-- 1 root root 193 5月22日 16:16 webui-settings.json.migrated\n"}],"isError":false,"timestamp":1780578576096}}
|
||
{"type":"message","id":"32482336","parentId":"41a366a3","timestamp":"2026-06-04T13:09:38.793Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 .pi/agent/skills 目录的结构,了解 skills 系统。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_27cab6d792f441d08b31c650","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/.pi/agent/skills"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":492,"output":65,"cacheRead":25600,"cacheWrite":0,"totalTokens":26157,"cost":{"input":0.0001968,"output":0.00013,"cacheRead":0.002048,"cacheWrite":0,"total":0.0023748}},"stopReason":"toolUse","timestamp":1780578576097,"responseId":"98d12494acec478fac8080f16e9a99f5"}}
|
||
{"type":"message","id":"9d81ef6b","parentId":"32482336","timestamp":"2026-06-04T13:09:38.802Z","message":{"role":"toolResult","toolCallId":"call_27cab6d792f441d08b31c650","toolName":"bash","content":[{"type":"text","text":"总计 68\ndrwxr-xr-x 16 root root 4096 6月 3日 12:13 .\ndrwx------ 10 root root 4096 6月 4日 21:08 ..\ndrwxr-xr-x 5 root root 4096 6月 3日 12:13 cloudflare-deploy\ndrwxr-xr-x 3 root root 4096 5月12日 15:00 command-help-zh-skill\ndrwxr-xr-x 3 root root 4096 5月13日 12:32 gitea-tea-skill\ndrwxr-xr-x 4 root root 4096 5月12日 15:00 github-gh-skill\ndrwxr-xr-x 6 root root 4096 5月12日 15:00 linux-ssh-operator-skill\ndrwxr-xr-x 4 root root 4096 5月13日 12:58 mcp-builder\ndrwxr-xr-x 4 root root 4096 5月12日 15:00 mengya-mail-skill\ndrwxr-xr-x 3 root root 4096 5月13日 14:51 mengya-rag\ndrwxr-xr-x 2 root root 4096 5月13日 00:52 officecli\n-rw-r--r-- 1 root root 1313 5月12日 14:54 README.md\ndrw------- 2 root root 4096 5月30日 15:13 .Recycle_bin\ndrwxr-xr-x 4 root root 4096 5月13日 12:01 repo-to-resume-tailor\ndrwxr-xr-x 7 root root 4096 5月13日 12:58 skill-creator\ndrwxr-xr-x 2 root root 4096 5月22日 20:05 skills\ndrwxr-xr-x 7 root root 4096 6月 2日 15:00 .system\n"}],"isError":false,"timestamp":1780578578801}}
|
||
{"type":"message","id":"fd6dca6d","parentId":"9d81ef6b","timestamp":"2026-06-04T13:09:40.915Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 .pi/agent/skills/README.md,了解 skills 的使用方法。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_56876489463d4bd5a5c730d9","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/skills/README.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":690,"output":60,"cacheRead":26048,"cacheWrite":0,"totalTokens":26798,"cost":{"input":0.00027600000000000004,"output":0.00011999999999999999,"cacheRead":0.00208384,"cacheWrite":0,"total":0.00247984}},"stopReason":"toolUse","timestamp":1780578578802,"responseId":"95cd8a2579184832b79db8d3356e3c8b"}}
|
||
{"type":"message","id":"7198931d","parentId":"fd6dca6d","timestamp":"2026-06-04T13:09:40.918Z","message":{"role":"toolResult","toolCallId":"call_56876489463d4bd5a5c730d9","toolName":"read","content":[{"type":"text","text":"# Skills\n\n这里是本机通用 Skills 目录,给大模型统一放可复用的技能包、流程说明和模板。\n\n## 目录约定\n\n- 每个 Skill 单独一个子目录\n- 入口文件通常使用 `SKILL.md`\n- 可按需包含 `references/`、`scripts/`、`assets/` 等辅助目录\n- Skill 内尽量写清楚触发条件、输入输出、约束和操作步骤\n\n## 推荐结构\n\n```text\n/root/skills/\n your-skill/\n SKILL.md\n references/\n scripts/\n assets/\n```\n\n## 当前状态\n\n- 目前这个目录已作为小萌芽本机的日常开发主目录使用\n- 先在这里维护、测试和迭代 Skill,确认稳定后再同步到 `~/.codex/skills/` 和 `~/.claude/skills/`\n- 当前已放入的自定义 Skill:\n - `command-help-zh-skill`\n - `mengya-mail-skill`\n - `mengya-search-skill`\n - `linux-ssh-operator-skill`\n- 前两个 Skill 的实现分别对接:\n - `/shumengya/project/python/mengya-mail-api/src/mengya_mail_api/`\n - `/shumengya/project/python/mengya-search-api/src/mengya_search_api/`\n- `linux-ssh-operator-skill` 的脚本与说明位于:\n - `/shumengya/project/skills/linux-ssh-operator-skill/`\n- 如果某些 Skill 需要接入特定工具或工作流,建议在 `SKILL.md` 顶部先写清适用范围\n\n当前入口请直接走 `skills/...` 和对应的 `python/*-api/`。\n"}],"isError":false,"timestamp":1780578580918}}
|
||
{"type":"message","id":"98824e28","parentId":"7198931d","timestamp":"2026-06-04T13:09:48.681Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我对这个项目有了全面的了解。让我总结一下这个项目的基本情况。","thinkingSignature":"reasoning_content"},{"type":"text","text":"根据我的查看,`/shumengya/project/agent/sproutclaw` 是一个基于 [pi-mono](https://github.com/badlogic/pi-mono) 改造的 **Agent 助手项目**,主要面向服务器运维与开发工作。\n\n## 项目概览\n\n**定位**:服务器运维助手 + 开发协作助手 + Docker 部署助手\n\n**技术栈**:Node.js (v22.19+) + TypeScript,使用 npm workspaces 管理的 monorepo 结构\n\n## 核心包结构\n\n| 包名 | 说明 |\n|------|------|\n| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体(sproutclaw 定制) |\n| `packages/agent` | Agent 运行时、工具调用和状态管理 |\n| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |\n| `packages/tui` | 终端 UI 渲染库 |\n| `packages/web-ui` | Web UI 示例/前端 |\n\n## 主要功能模块\n\n- **LLM Provider 支持**:Anthropic、OpenAI、Google、Mistral、Amazon Bedrock、Cloudflare、Azure OpenAI 等\n- **WebUI 扩展**:`.pi/agent/extensions/webui/` 提供网页对话入口(React 前端 + HTTP 服务)\n- **Skills 系统**:`.pi/agent/skills/` 目录下的技能包(SSH 操作、邮件、搜索、文档处理等)\n- **本地化扩展**:中文启动页、命令帮助翻译等\n\n## 项目状态\n\n- **分支**:main 分支,与 origin/main 同步\n- **未提交变更**:有较多修改(主要是 WebUI 扩展和部分包的更新)\n- **最近提交**:主要是 CI 修复、依赖固定、上游同步等\n\n## 常用命令\n\n```bash\nnpm install --ignore-scripts # 安装依赖\nnpm run build # 构建\nnpm run check # 代码检查\n./pi-test.sh # 本地测试运行\n```\n\n项目整体结构清晰,是一个功能完善的 AI Agent 开发框架,特别针对内网服务器环境进行了优化 (。・ω・。)"}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":508,"output":527,"cacheRead":26688,"cacheWrite":0,"totalTokens":27723,"cost":{"input":0.0002032,"output":0.001054,"cacheRead":0.00213504,"cacheWrite":0,"total":0.00339224}},"stopReason":"stop","timestamp":1780578580919,"responseId":"25054ced1dec48c494ed378678f9462d"}}
|
||
|
||
{"type":"session_info","id":"30ae3ef0","parentId":"98824e28","timestamp":"2026-06-04T13:20:30.326Z","name":"SproutClaw项目解析"}{"type":"message","id":"7d2c9d1c","parentId":"30ae3ef0","timestamp":"2026-06-10T03:03:52.394Z","message":{"role":"user","content":[{"type":"text","text":"MCP是什么,描述一下项目中的MCP完整工作流程"}],"timestamp":1781060632394}}
|
||
{"type":"message","id":"7bcaad15","parentId":"7d2c9d1c","timestamp":"2026-06-10T03:03:57.221Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户想了解 MCP 是什么,以及项目中 MCP 的完整工作流程。我需要先查看项目中关于 MCP 的文档和代码实现。\n\n首先,我应该搜索项目中与 MCP 相关的文件和代码,了解 MCP 的具体实现。让我先查看项目根目录下是否有 MCP 相关的文档,然后查看代码中的 MCP 实现。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_f6c854e3600c4a4cb5bae41e","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw -name \"*mcp*\" -type f 2>/dev/null | head -20"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":15987,"output":131,"cacheRead":12288,"cacheWrite":0,"totalTokens":28406,"cost":{"input":0.006394800000000001,"output":0.00026199999999999997,"cacheRead":0.0009830400000000001,"cacheWrite":0,"total":0.007639840000000001}},"stopReason":"toolUse","timestamp":1781060632395,"responseId":"970b87d58dce49e6b1d4d466cacfe671"}}
|
||
{"type":"message","id":"ddced049","parentId":"7bcaad15","timestamp":"2026-06-10T03:03:57.319Z","message":{"role":"toolResult","toolCallId":"call_f6c854e3600c4a4cb5bae41e","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/.pi/agent/skills-disabled/mcp-builder/reference/python_mcp_server.md\n/shumengya/project/agent/sproutclaw/.pi/agent/skills-disabled/mcp-builder/reference/node_mcp_server.md\n/shumengya/project/agent/sproutclaw/.pi/agent/skills-disabled/mcp-builder/reference/mcp_best_practices.md\n/shumengya/project/agent/sproutclaw/.pi/agent/mcp-npx-cache.json\n/shumengya/project/agent/sproutclaw/.pi/agent/mcp.json\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@anthropic-ai/sdk/helpers/beta/mcp.js\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@anthropic-ai/sdk/helpers/beta/mcp.d.mts\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@anthropic-ai/sdk/helpers/beta/mcp.d.mts.map\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@anthropic-ai/sdk/helpers/beta/mcp.d.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@anthropic-ai/sdk/helpers/beta/mcp.d.ts.map\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@anthropic-ai/sdk/helpers/beta/mcp.mjs.map\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@anthropic-ai/sdk/helpers/beta/mcp.mjs\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@anthropic-ai/sdk/helpers/beta/mcp.js.map\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@anthropic-ai/sdk/src/helpers/beta/mcp.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@modelcontextprotocol/sdk/dist/esm/examples/server/mcpServerOutputSchema.js.map\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@modelcontextprotocol/sdk/dist/esm/examples/server/mcpServerOutputSchema.js\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@modelcontextprotocol/sdk/dist/esm/examples/server/mcpServerOutputSchema.d.ts.map\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@modelcontextprotocol/sdk/dist/esm/examples/server/mcpServerOutputSchema.d.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/npm/node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js\n"}],"isError":false,"timestamp":1781060637318}}
|
||
{"type":"message","id":"331edfe3","parentId":"ddced049","timestamp":"2026-06-10T03:03:59.848Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下项目中的 MCP 配置文件和相关文档。首先查看 mcp.json 配置文件。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_d3ba5c5eed574de798a49d41","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/mcp.json"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":840,"output":63,"cacheRead":28224,"cacheWrite":0,"totalTokens":29127,"cost":{"input":0.00033600000000000004,"output":0.000126,"cacheRead":0.00225792,"cacheWrite":0,"total":0.0027199200000000002}},"stopReason":"toolUse","timestamp":1781060637320,"responseId":"7758e870321b4054b9a347045334a0b4"}}
|
||
{"type":"message","id":"7c4b9196","parentId":"331edfe3","timestamp":"2026-06-10T03:03:59.850Z","message":{"role":"toolResult","toolCallId":"call_d3ba5c5eed574de798a49d41","toolName":"read","content":[{"type":"text","text":"{\n \"mcpServers\": {\n \"tavily\": {\n \"url\": \"https://mcp.tavily.com/mcp/?tavilyApiKey=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\"\n }\n },\n \"mcpServersDisabled\": {\n \"cloudflare-search\": {\n \"command\": \"npx\",\n \"args\": [\n \"-y\",\n \"@yrobot/cf-search-mcp\"\n ],\n \"env\": {\n \"CF_SEARCH_URL\": \"https://search.smyhub.com\",\n \"CF_SEARCH_TOKEN\": \"shumengya520\"\n }\n }\n }\n}\n"}],"isError":false,"timestamp":1781060639850}}
|
||
{"type":"message","id":"4423f7cc","parentId":"7c4b9196","timestamp":"2026-06-10T03:04:03.257Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我查看一下项目中关于 MCP 的文档,特别是 mcp-builder 技能包中的参考文档。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_3ef3edf29bdc42368174c197","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/skills-disabled/mcp-builder/reference/mcp_best_practices.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":251,"output":72,"cacheRead":29056,"cacheWrite":0,"totalTokens":29379,"cost":{"input":0.00010040000000000001,"output":0.000144,"cacheRead":0.00232448,"cacheWrite":0,"total":0.00256888}},"stopReason":"toolUse","timestamp":1781060639851,"responseId":"cf6096625b36425faa9ad9da4bcd2e02"}}
|
||
{"type":"message","id":"302ad542","parentId":"4423f7cc","timestamp":"2026-06-10T03:04:03.259Z","message":{"role":"toolResult","toolCallId":"call_3ef3edf29bdc42368174c197","toolName":"read","content":[{"type":"text","text":"# MCP Server Best Practices\n\n## Quick Reference\n\n### Server Naming\n- **Python**: `{service}_mcp` (e.g., `slack_mcp`)\n- **Node/TypeScript**: `{service}-mcp-server` (e.g., `slack-mcp-server`)\n\n### Tool Naming\n- Use snake_case with service prefix\n- Format: `{service}_{action}_{resource}`\n- Example: `slack_send_message`, `github_create_issue`\n\n### Response Formats\n- Support both JSON and Markdown formats\n- JSON for programmatic processing\n- Markdown for human readability\n\n### Pagination\n- Always respect `limit` parameter\n- Return `has_more`, `next_offset`, `total_count`\n- Default to 20-50 items\n\n### Transport\n- **Streamable HTTP**: For remote servers, multi-client scenarios\n- **stdio**: For local integrations, command-line tools\n- Avoid SSE (deprecated in favor of streamable HTTP)\n\n---\n\n## Server Naming Conventions\n\nFollow these standardized naming patterns:\n\n**Python**: Use format `{service}_mcp` (lowercase with underscores)\n- Examples: `slack_mcp`, `github_mcp`, `jira_mcp`\n\n**Node/TypeScript**: Use format `{service}-mcp-server` (lowercase with hyphens)\n- Examples: `slack-mcp-server`, `github-mcp-server`, `jira-mcp-server`\n\nThe name should be general, descriptive of the service being integrated, easy to infer from the task description, and without version numbers.\n\n---\n\n## Tool Naming and Design\n\n### Tool Naming\n\n1. **Use snake_case**: `search_users`, `create_project`, `get_channel_info`\n2. **Include service prefix**: Anticipate that your MCP server may be used alongside other MCP servers\n - Use `slack_send_message` instead of just `send_message`\n - Use `github_create_issue` instead of just `create_issue`\n3. **Be action-oriented**: Start with verbs (get, list, search, create, etc.)\n4. **Be specific**: Avoid generic names that could conflict with other servers\n\n### Tool Design\n\n- Tool descriptions must narrowly and unambiguously describe functionality\n- Descriptions must precisely match actual functionality\n- Provide tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)\n- Keep tool operations focused and atomic\n\n---\n\n## Response Formats\n\nAll tools that return data should support multiple formats:\n\n### JSON Format (`response_format=\"json\"`)\n- Machine-readable structured data\n- Include all available fields and metadata\n- Consistent field names and types\n- Use for programmatic processing\n\n### Markdown Format (`response_format=\"markdown\"`, typically default)\n- Human-readable formatted text\n- Use headers, lists, and formatting for clarity\n- Convert timestamps to human-readable format\n- Show display names with IDs in parentheses\n- Omit verbose metadata\n\n---\n\n## Pagination\n\nFor tools that list resources:\n\n- **Always respect the `limit` parameter**\n- **Implement pagination**: Use `offset` or cursor-based pagination\n- **Return pagination metadata**: Include `has_more`, `next_offset`/`next_cursor`, `total_count`\n- **Never load all results into memory**: Especially important for large datasets\n- **Default to reasonable limits**: 20-50 items is typical\n\nExample pagination response:\n```json\n{\n \"total\": 150,\n \"count\": 20,\n \"offset\": 0,\n \"items\": [...],\n \"has_more\": true,\n \"next_offset\": 20\n}\n```\n\n---\n\n## Transport Options\n\n### Streamable HTTP\n\n**Best for**: Remote servers, web services, multi-client scenarios\n\n**Characteristics**:\n- Bidirectional communication over HTTP\n- Supports multiple simultaneous clients\n- Can be deployed as a web service\n- Enables server-to-client notifications\n\n**Use when**:\n- Serving multiple clients simultaneously\n- Deploying as a cloud service\n- Integration with web applications\n\n### stdio\n\n**Best for**: Local integrations, command-line tools\n\n**Characteristics**:\n- Standard input/output stream communication\n- Simple setup, no network configuration needed\n- Runs as a subprocess of the client\n\n**Use when**:\n- Building tools for local development environments\n- Integrating with desktop applications\n- Single-user, single-session scenarios\n\n**Note**: stdio servers should NOT log to stdout (use stderr for logging)\n\n### Transport Selection\n\n| Criterion | stdio | Streamable HTTP |\n|-----------|-------|-----------------|\n| **Deployment** | Local | Remote |\n| **Clients** | Single | Multiple |\n| **Complexity** | Low | Medium |\n| **Real-time** | No | Yes |\n\n---\n\n## Security Best Practices\n\n### Authentication and Authorization\n\n**OAuth 2.1**:\n- Use secure OAuth 2.1 with certificates from recognized authorities\n- Validate access tokens before processing requests\n- Only accept tokens specifically intended for your server\n\n**API Keys**:\n- Store API keys in environment variables, never in code\n- Validate keys on server startup\n- Provide clear error messages when authentication fails\n\n### Input Validation\n\n- Sanitize file paths to prevent directory traversal\n- Validate URLs and external identifiers\n- Check parameter sizes and ranges\n- Prevent command injection in system calls\n- Use schema validation (Pydantic/Zod) for all inputs\n\n### Error Handling\n\n- Don't expose internal errors to clients\n- Log security-relevant errors server-side\n- Provide helpful but not revealing error messages\n- Clean up resources after errors\n\n### DNS Rebinding Protection\n\nFor streamable HTTP servers running locally:\n- Enable DNS rebinding protection\n- Validate the `Origin` header on all incoming connections\n- Bind to `127.0.0.1` rather than `0.0.0.0`\n\n---\n\n## Tool Annotations\n\nProvide annotations to help clients understand tool behavior:\n\n| Annotation | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `readOnlyHint` | boolean | false | Tool does not modify its environment |\n| `destructiveHint` | boolean | true | Tool may perform destructive updates |\n| `idempotentHint` | boolean | false | Repeated calls with same args have no additional effect |\n| `openWorldHint` | boolean | true | Tool interacts with external entities |\n\n**Important**: Annotations are hints, not security guarantees. Clients should not make security-critical decisions based solely on annotations.\n\n---\n\n## Error Handling\n\n- Use standard JSON-RPC error codes\n- Report tool errors within result objects (not protocol-level errors)\n- Provide helpful, specific error messages with suggested next steps\n- Don't expose internal implementation details\n- Clean up resources properly on errors\n\nExample error handling:\n```typescript\ntry {\n const result = performOperation();\n return { content: [{ type: \"text\", text: result }] };\n} catch (error) {\n return {\n isError: true,\n content: [{\n type: \"text\",\n text: `Error: ${error.message}. Try using filter='active_only' to reduce results.`\n }]\n };\n}\n```\n\n---\n\n## Testing Requirements\n\nComprehensive testing should cover:\n\n- **Functional testing**: Verify correct execution with valid/invalid inputs\n- **Integration testing**: Test interaction with external systems\n- **Security testing**: Validate auth, input sanitization, rate limiting\n- **Performance testing**: Check behavior under load, timeouts\n- **Error handling**: Ensure proper error reporting and cleanup\n\n---\n\n## Documentation Requirements\n\n- Provide clear documentation of all tools and capabilities\n- Include working examples (at least 3 per major feature)\n- Document security considerations\n- Specify required permissions and access levels\n- Document rate limits and performance characteristics\n"}],"isError":false,"timestamp":1781060643259}}
|
||
{"type":"message","id":"b11c5b91","parentId":"302ad542","timestamp":"2026-06-10T03:04:05.454Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下项目中 MCP 的代码实现,特别是 coding-agent 中的 MCP 相关代码。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_c3c38cb1a8e4481e8754e0a7","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw/packages -name \"*.ts\" -type f -exec grep -l \"mcp\\|MCP\" {} \\; 2>/dev/null | head -20"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1745,"output":92,"cacheRead":29248,"cacheWrite":0,"totalTokens":31085,"cost":{"input":0.000698,"output":0.000184,"cacheRead":0.00233984,"cacheWrite":0,"total":0.00322184}},"stopReason":"toolUse","timestamp":1781060643259,"responseId":"671f9a46265541d7881f8b36c7024345"}}
|
||
{"type":"message","id":"c0665541","parentId":"b11c5b91","timestamp":"2026-06-10T03:04:08.138Z","message":{"role":"toolResult","toolCallId":"call_c3c38cb1a8e4481e8754e0a7","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/node_modules/@anthropic-ai/sdk/src/resources/beta/beta.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/node_modules/@anthropic-ai/sdk/src/resources/beta/index.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/node_modules/@anthropic-ai/sdk/src/resources/beta/messages/messages.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/node_modules/@anthropic-ai/sdk/src/resources/beta/messages/index.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/node_modules/@anthropic-ai/sdk/src/lib/BetaMessageStream.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/node_modules/@anthropic-ai/sdk/resources/beta/beta.d.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/node_modules/@anthropic-ai/sdk/resources/beta/index.d.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/node_modules/@anthropic-ai/sdk/resources/beta/messages/index.d.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/node_modules/@anthropic-ai/sdk/resources/beta/messages/messages.d.ts\n/shumengya/project/agent/sproutclaw/packages/ai/src/utils/oauth/anthropic.ts\n"}],"isError":false,"timestamp":1781060648138}}
|
||
{"type":"message","id":"d86c170d","parentId":"c0665541","timestamp":"2026-06-10T03:04:10.375Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 coding-agent 中的 MCP 相关代码,特别是 skills.ts 文件,因为之前看到有修改。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_be9eee3d45d1497ba98949d4","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/skills.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":453,"output":65,"cacheRead":30976,"cacheWrite":0,"totalTokens":31494,"cost":{"input":0.0001812,"output":0.00013,"cacheRead":0.00247808,"cacheWrite":0,"total":0.00278928}},"stopReason":"toolUse","timestamp":1781060648139,"responseId":"bea12854432b429a9603200084a79831"}}
|
||
{"type":"message","id":"fbb5c05d","parentId":"d86c170d","timestamp":"2026-06-10T03:04:10.377Z","message":{"role":"toolResult","toolCallId":"call_be9eee3d45d1497ba98949d4","toolName":"read","content":[{"type":"text","text":"import { existsSync, readdirSync, readFileSync, statSync } from \"fs\";\nimport ignore from \"ignore\";\nimport { basename, dirname, join, relative, resolve, sep } from \"path\";\nimport { CONFIG_DIR_NAME, getAgentDir } from \"../config.ts\";\nimport { parseFrontmatter } from \"../utils/frontmatter.ts\";\nimport { canonicalizePath, resolvePath } from \"../utils/paths.ts\";\nimport type { ResourceDiagnostic } from \"./diagnostics.ts\";\nimport { createSyntheticSourceInfo, type SourceInfo } from \"./source-info.ts\";\n\n/** Max name length per spec */\nconst MAX_NAME_LENGTH = 64;\n\n/** Max description length per spec */\nconst MAX_DESCRIPTION_LENGTH = 1024;\n\nconst IGNORE_FILE_NAMES = [\".gitignore\", \".ignore\", \".fdignore\"];\n\n/** Root-level markdown files that document the skills directory, not skills themselves. */\nconst IGNORED_ROOT_SKILL_MARKDOWN = new Set([\"README.md\"]);\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\nfunction isIgnoredRootSkillMarkdown(fileName: string): boolean {\n\treturn IGNORED_ROOT_SKILL_MARKDOWN.has(fileName) || fileName.toLowerCase() === \"readme.md\";\n}\n\nfunction toPosixPath(p: string): string {\n\treturn p.split(sep).join(\"/\");\n}\n\nfunction prefixIgnorePattern(line: string, prefix: string): string | null {\n\tconst trimmed = line.trim();\n\tif (!trimmed) return null;\n\tif (trimmed.startsWith(\"#\") && !trimmed.startsWith(\"\\\\#\")) return null;\n\n\tlet pattern = line;\n\tlet negated = false;\n\n\tif (pattern.startsWith(\"!\")) {\n\t\tnegated = true;\n\t\tpattern = pattern.slice(1);\n\t} else if (pattern.startsWith(\"\\\\!\")) {\n\t\tpattern = pattern.slice(1);\n\t}\n\n\tif (pattern.startsWith(\"/\")) {\n\t\tpattern = pattern.slice(1);\n\t}\n\n\tconst prefixed = prefix ? `${prefix}${pattern}` : pattern;\n\treturn negated ? `!${prefixed}` : prefixed;\n}\n\nfunction addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void {\n\tconst relativeDir = relative(rootDir, dir);\n\tconst prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : \"\";\n\n\tfor (const filename of IGNORE_FILE_NAMES) {\n\t\tconst ignorePath = join(dir, filename);\n\t\tif (!existsSync(ignorePath)) continue;\n\t\ttry {\n\t\t\tconst content = readFileSync(ignorePath, \"utf-8\");\n\t\t\tconst patterns = content\n\t\t\t\t.split(/\\r?\\n/)\n\t\t\t\t.map((line) => prefixIgnorePattern(line, prefix))\n\t\t\t\t.filter((line): line is string => Boolean(line));\n\t\t\tif (patterns.length > 0) {\n\t\t\t\tig.add(patterns);\n\t\t\t}\n\t\t} catch {}\n\t}\n}\n\nexport interface SkillFrontmatter {\n\tname?: string;\n\tdescription?: string;\n\t\"disable-model-invocation\"?: boolean;\n\t[key: string]: unknown;\n}\n\nexport interface Skill {\n\tname: string;\n\tdescription: string;\n\tfilePath: string;\n\tbaseDir: string;\n\tsourceInfo: SourceInfo;\n\tdisableModelInvocation: boolean;\n}\n\nexport interface LoadSkillsResult {\n\tskills: Skill[];\n\tdiagnostics: ResourceDiagnostic[];\n}\n\n/**\n * Validate skill name per Agent Skills spec.\n * Returns array of validation error messages (empty if valid).\n */\nfunction validateName(name: string): string[] {\n\tconst errors: string[] = [];\n\n\tif (name.length > MAX_NAME_LENGTH) {\n\t\terrors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);\n\t}\n\n\tif (!/^[a-z0-9-]+$/.test(name)) {\n\t\terrors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`);\n\t}\n\n\tif (name.startsWith(\"-\") || name.endsWith(\"-\")) {\n\t\terrors.push(`name must not start or end with a hyphen`);\n\t}\n\n\tif (name.includes(\"--\")) {\n\t\terrors.push(`name must not contain consecutive hyphens`);\n\t}\n\n\treturn errors;\n}\n\n/**\n * Validate description per Agent Skills spec.\n */\nfunction validateDescription(description: string | undefined): string[] {\n\tconst errors: string[] = [];\n\n\tif (!description || description.trim() === \"\") {\n\t\terrors.push(\"description is required\");\n\t} else if (description.length > MAX_DESCRIPTION_LENGTH) {\n\t\terrors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`);\n\t}\n\n\treturn errors;\n}\n\nexport interface LoadSkillsFromDirOptions {\n\t/** Directory to scan for skills */\n\tdir: string;\n\t/** Source identifier for these skills */\n\tsource: string;\n}\n\nfunction createSkillSourceInfo(filePath: string, baseDir: string, source: string): SourceInfo {\n\tswitch (source) {\n\t\tcase \"user\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"user\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tcase \"project\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"project\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tcase \"path\":\n\t\t\treturn createSyntheticSourceInfo(filePath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tbaseDir,\n\t\t\t});\n\t\tdefault:\n\t\t\treturn createSyntheticSourceInfo(filePath, { source, baseDir });\n\t}\n}\n\n/**\n * Load skills from a directory.\n *\n * Discovery rules:\n * - if a directory contains SKILL.md, treat it as a skill root and do not recurse further\n * - otherwise, load direct .md children in the root\n * - recurse into subdirectories to find SKILL.md\n */\nexport function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {\n\tconst { dir, source } = options;\n\treturn loadSkillsFromDirInternal(dir, source, true);\n}\n\nfunction loadSkillsFromDirInternal(\n\tdir: string,\n\tsource: string,\n\tincludeRootFiles: boolean,\n\tignoreMatcher?: IgnoreMatcher,\n\trootDir?: string,\n): LoadSkillsResult {\n\tconst skills: Skill[] = [];\n\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\tif (!existsSync(dir)) {\n\t\treturn { skills, diagnostics };\n\t}\n\n\tconst root = rootDir ?? dir;\n\tconst ig = ignoreMatcher ?? ignore();\n\taddIgnoreRules(ig, dir, root);\n\n\ttry {\n\t\tconst entries = readdirSync(dir, { withFileTypes: true });\n\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.name !== \"SKILL.md\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst fullPath = join(dir, entry.name);\n\n\t\t\tlet isFile = entry.isFile();\n\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\ttry {\n\t\t\t\t\tisFile = statSync(fullPath).isFile();\n\t\t\t\t} catch {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst relPath = toPosixPath(relative(root, fullPath));\n\t\t\tif (!isFile || ig.ignores(relPath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst result = loadSkillFromFile(fullPath, source);\n\t\t\tif (result.skill) {\n\t\t\t\tskills.push(result.skill);\n\t\t\t}\n\t\t\tdiagnostics.push(...result.diagnostics);\n\t\t\treturn { skills, diagnostics };\n\t\t}\n\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.name.startsWith(\".\")) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Skip node_modules to avoid scanning dependencies\n\t\t\tif (entry.name === \"node_modules\") {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst fullPath = join(dir, entry.name);\n\n\t\t\t// For symlinks, check if they point to a directory and follow them\n\t\t\tlet isDirectory = entry.isDirectory();\n\t\t\tlet isFile = entry.isFile();\n\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\ttry {\n\t\t\t\t\tconst stats = statSync(fullPath);\n\t\t\t\t\tisDirectory = stats.isDirectory();\n\t\t\t\t\tisFile = stats.isFile();\n\t\t\t\t} catch {\n\t\t\t\t\t// Broken symlink, skip it\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst relPath = toPosixPath(relative(root, fullPath));\n\t\t\tconst ignorePath = isDirectory ? `${relPath}/` : relPath;\n\t\t\tif (ig.ignores(ignorePath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (isDirectory) {\n\t\t\t\tconst subResult = loadSkillsFromDirInternal(fullPath, source, false, ig, root);\n\t\t\t\tskills.push(...subResult.skills);\n\t\t\t\tdiagnostics.push(...subResult.diagnostics);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (!isFile || !includeRootFiles || !entry.name.endsWith(\".md\") || isIgnoredRootSkillMarkdown(entry.name)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst result = loadSkillFromFile(fullPath, source);\n\t\t\tif (result.skill) {\n\t\t\t\tskills.push(result.skill);\n\t\t\t}\n\t\t\tdiagnostics.push(...result.diagnostics);\n\t\t}\n\t} catch {}\n\n\treturn { skills, diagnostics };\n}\n\nfunction loadSkillFromFile(\n\tfilePath: string,\n\tsource: string,\n): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } {\n\tif (isIgnoredRootSkillMarkdown(basename(filePath))) {\n\t\treturn { skill: null, diagnostics: [] };\n\t}\n\n\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\ttry {\n\t\tconst rawContent = readFileSync(filePath, \"utf-8\");\n\t\tconst { frontmatter } = parseFrontmatter<SkillFrontmatter>(rawContent);\n\t\tconst skillDir = dirname(filePath);\n\t\tconst parentDirName = basename(skillDir);\n\n\t\t// Validate description\n\t\tconst descErrors = validateDescription(frontmatter.description);\n\t\tfor (const error of descErrors) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: error, path: filePath });\n\t\t}\n\n\t\t// Use name from frontmatter, or fall back to parent directory name\n\t\tconst name = frontmatter.name || parentDirName;\n\n\t\t// Validate name\n\t\tconst nameErrors = validateName(name);\n\t\tfor (const error of nameErrors) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: error, path: filePath });\n\t\t}\n\n\t\t// Still load the skill even with warnings (unless description is completely missing)\n\t\tif (!frontmatter.description || frontmatter.description.trim() === \"\") {\n\t\t\treturn { skill: null, diagnostics };\n\t\t}\n\n\t\treturn {\n\t\t\tskill: {\n\t\t\t\tname,\n\t\t\t\tdescription: frontmatter.description,\n\t\t\t\tfilePath,\n\t\t\t\tbaseDir: skillDir,\n\t\t\t\tsourceInfo: createSkillSourceInfo(filePath, skillDir, source),\n\t\t\t\tdisableModelInvocation: frontmatter[\"disable-model-invocation\"] === true,\n\t\t\t},\n\t\t\tdiagnostics,\n\t\t};\n\t} catch (error) {\n\t\tconst message = error instanceof Error ? error.message : \"failed to parse skill file\";\n\t\tdiagnostics.push({ type: \"warning\", message, path: filePath });\n\t\treturn { skill: null, diagnostics };\n\t}\n}\n\n/**\n * Format skills for inclusion in a system prompt.\n * Uses XML format per Agent Skills standard.\n * See: https://agentskills.io/integrate-skills\n *\n * Skills with disableModelInvocation=true are excluded from the prompt\n * (they can only be invoked explicitly via /skill:name commands).\n */\nexport function formatSkillsForPrompt(skills: Skill[]): string {\n\tconst visibleSkills = skills.filter((s) => !s.disableModelInvocation);\n\n\tif (visibleSkills.length === 0) {\n\t\treturn \"\";\n\t}\n\n\tconst lines = [\n\t\t\"\\n\\nThe following skills provide specialized instructions for specific tasks.\",\n\t\t\"Use the read tool to load a skill's file when the task matches its description.\",\n\t\t\"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.\",\n\t\t\"\",\n\t\t\"<available_skills>\",\n\t];\n\n\tfor (const skill of visibleSkills) {\n\t\tlines.push(\" <skill>\");\n\t\tlines.push(` <name>${escapeXml(skill.name)}</name>`);\n\t\tlines.push(` <description>${escapeXml(skill.description)}</description>`);\n\t\tlines.push(` <location>${escapeXml(skill.filePath)}</location>`);\n\t\tlines.push(\" </skill>\");\n\t}\n\n\tlines.push(\"</available_skills>\");\n\n\treturn lines.join(\"\\n\");\n}\n\nfunction escapeXml(str: string): string {\n\treturn str\n\t\t.replace(/&/g, \"&\")\n\t\t.replace(/</g, \"<\")\n\t\t.replace(/>/g, \">\")\n\t\t.replace(/\"/g, \""\")\n\t\t.replace(/'/g, \"'\");\n}\n\nexport interface LoadSkillsOptions {\n\t/** Working directory for project-local skills. */\n\tcwd: string;\n\t/** Agent config directory for global skills. */\n\tagentDir: string;\n\t/** Explicit skill paths (files or directories) */\n\tskillPaths: string[];\n\t/** Include default skills directories. */\n\tincludeDefaults: boolean;\n}\n\n/**\n * Load skills from all configured locations.\n * Returns skills and any validation diagnostics.\n */\nexport function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {\n\tconst { agentDir, skillPaths, includeDefaults } = options;\n\n\t// Resolve agentDir - if not provided, use default from config\n\tconst resolvedCwd = resolvePath(options.cwd);\n\tconst resolvedAgentDir = resolvePath(agentDir ?? getAgentDir());\n\n\tconst skillMap = new Map<string, Skill>();\n\tconst realPathSet = new Set<string>();\n\tconst allDiagnostics: ResourceDiagnostic[] = [];\n\tconst collisionDiagnostics: ResourceDiagnostic[] = [];\n\n\tfunction addSkills(result: LoadSkillsResult) {\n\t\tallDiagnostics.push(...result.diagnostics);\n\t\tfor (const skill of result.skills) {\n\t\t\t// Resolve symlinks to detect duplicate files\n\t\t\tconst realPath = canonicalizePath(skill.filePath);\n\n\t\t\t// Skip silently if we've already loaded this exact file (via symlink)\n\t\t\tif (realPathSet.has(realPath)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst existing = skillMap.get(skill.name);\n\t\t\tif (existing) {\n\t\t\t\tcollisionDiagnostics.push({\n\t\t\t\t\ttype: \"collision\",\n\t\t\t\t\tmessage: `name \"${skill.name}\" collision`,\n\t\t\t\t\tpath: skill.filePath,\n\t\t\t\t\tcollision: {\n\t\t\t\t\t\tresourceType: \"skill\",\n\t\t\t\t\t\tname: skill.name,\n\t\t\t\t\t\twinnerPath: existing.filePath,\n\t\t\t\t\t\tloserPath: skill.filePath,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tskillMap.set(skill.name, skill);\n\t\t\t\trealPathSet.add(realPath);\n\t\t\t}\n\t\t}\n\t}\n\n\tif (includeDefaults) {\n\t\taddSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, \"skills\"), \"user\", true));\n\t\taddSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, \"skills\"), \"project\", true));\n\t}\n\n\tconst userSkillsDir = join(resolvedAgentDir, \"skills\");\n\tconst projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, \"skills\");\n\n\tconst isUnderPath = (target: string, root: string): boolean => {\n\t\tconst normalizedRoot = resolve(root);\n\t\tif (target === normalizedRoot) {\n\t\t\treturn true;\n\t\t}\n\t\tconst prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;\n\t\treturn target.startsWith(prefix);\n\t};\n\n\tconst getSource = (resolvedPath: string): \"user\" | \"project\" | \"path\" => {\n\t\tif (!includeDefaults) {\n\t\t\tif (isUnderPath(resolvedPath, userSkillsDir)) return \"user\";\n\t\t\tif (isUnderPath(resolvedPath, projectSkillsDir)) return \"project\";\n\t\t}\n\t\treturn \"path\";\n\t};\n\n\tfor (const rawPath of skillPaths) {\n\t\tconst resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });\n\t\tif (!existsSync(resolvedPath)) {\n\t\t\tallDiagnostics.push({ type: \"warning\", message: \"skill path does not exist\", path: resolvedPath });\n\t\t\tcontinue;\n\t\t}\n\n\t\ttry {\n\t\t\tconst stats = statSync(resolvedPath);\n\t\t\tconst source = getSource(resolvedPath);\n\t\t\tif (stats.isDirectory()) {\n\t\t\t\taddSkills(loadSkillsFromDirInternal(resolvedPath, source, true));\n\t\t\t} else if (stats.isFile() && resolvedPath.endsWith(\".md\")) {\n\t\t\t\tconst result = loadSkillFromFile(resolvedPath, source);\n\t\t\t\tif (result.skill) {\n\t\t\t\t\taddSkills({ skills: [result.skill], diagnostics: result.diagnostics });\n\t\t\t\t} else {\n\t\t\t\t\tallDiagnostics.push(...result.diagnostics);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallDiagnostics.push({ type: \"warning\", message: \"skill path is not a markdown file\", path: resolvedPath });\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : \"failed to read skill path\";\n\t\t\tallDiagnostics.push({ type: \"warning\", message, path: resolvedPath });\n\t\t}\n\t}\n\n\treturn {\n\t\tskills: Array.from(skillMap.values()),\n\t\tdiagnostics: [...allDiagnostics, ...collisionDiagnostics],\n\t};\n}\n"}],"isError":false,"timestamp":1781060650376}}
|
||
{"type":"message","id":"0ba51169","parentId":"fbb5c05d","timestamp":"2026-06-10T03:04:13.933Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 coding-agent 中的 agent-session.ts 文件,看看 MCP 是如何被集成的。先搜索一下 MCP 相关的代码。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_0b934514bbaf4021bffbc37c","name":"bash","arguments":{"command":"grep -n \"mcp\\|MCP\" /shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/agent-session.ts | head -30"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":3569,"output":91,"cacheRead":31424,"cacheWrite":0,"totalTokens":35084,"cost":{"input":0.0014276000000000002,"output":0.00018199999999999998,"cacheRead":0.0025139200000000002,"cacheWrite":0,"total":0.00412352}},"stopReason":"toolUse","timestamp":1781060650378,"responseId":"1d8cbae40aa74263ac1847515686792d"}}
|
||
{"type":"message","id":"13b86b5e","parentId":"0ba51169","timestamp":"2026-06-10T03:04:13.943Z","message":{"role":"toolResult","toolCallId":"call_0b934514bbaf4021bffbc37c","toolName":"bash","content":[{"type":"text","text":"(no output)"}],"isError":false,"timestamp":1781060653943}}
|
||
{"type":"message","id":"96d75ba9","parentId":"13b86b5e","timestamp":"2026-06-10T03:04:16.158Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我搜索一下项目中所有与 MCP 相关的代码,看看 MCP 是如何被使用的。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_94d09c377f474e298ee0e507","name":"bash","arguments":{"command":"grep -r \"mcp\\|MCP\" /shumengya/project/agent/sproutclaw/packages --include=\"*.ts\" --include=\"*.js\" | grep -v node_modules | head -30"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":154,"output":91,"cacheRead":34944,"cacheWrite":0,"totalTokens":35189,"cost":{"input":0.0000616,"output":0.00018199999999999998,"cacheRead":0.00279552,"cacheWrite":0,"total":0.00303912}},"stopReason":"toolUse","timestamp":1781060653944,"responseId":"7ee32c32328740048af4ca8451d06627"}}
|
||
{"type":"message","id":"918bf6e8","parentId":"96d75ba9","timestamp":"2026-06-10T03:04:16.222Z","message":{"role":"toolResult","toolCallId":"call_94d09c377f474e298ee0e507","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts:\t\t\t\tpackages: [\"npm:pi-mcp-adapter\"],\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts:\t\t// At this point, globalSettings.packages = [\"npm:pi-mcp-adapter\"]\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts:\t\texpect(manager.getPackages()).toEqual([\"npm:pi-mcp-adapter\"]);\n/shumengya/project/agent/sproutclaw/packages/coding-agent/dist/core/export-html/vendor/highlight.min.js:built_in:\"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr\"\n/shumengya/project/agent/sproutclaw/packages/coding-agent/dist/core/export-html/vendor/highlight.min.js:_hint:[\"abort\",\"abs\",\"acos\",\"apply\",\"as_const\",\"asin\",\"atan\",\"atan2\",\"calloc\",\"ceil\",\"cerr\",\"cin\",\"clog\",\"cos\",\"cosh\",\"cout\",\"declval\",\"endl\",\"exchange\",\"exit\",\"exp\",\"fabs\",\"floor\",\"fmod\",\"forward\",\"fprintf\",\"fputs\",\"free\",\"frexp\",\"fscanf\",\"future\",\"invoke\",\"isalnum\",\"isalpha\",\"iscntrl\",\"isdigit\",\"isgraph\",\"islower\",\"isprint\",\"ispunct\",\"isspace\",\"isupper\",\"isxdigit\",\"labs\",\"launder\",\"ldexp\",\"log\",\"log10\",\"make_pair\",\"make_shared\",\"make_shared_for_overwrite\",\"make_tuple\",\"make_unique\",\"malloc\",\"memchr\",\"memcmp\",\"memcpy\",\"memset\",\"modf\",\"move\",\"pow\",\"printf\",\"putchar\",\"puts\",\"realloc\",\"scanf\",\"sin\",\"sinh\",\"snprintf\",\"sprintf\",\"sqrt\",\"sscanf\",\"std\",\"stderr\",\"stdin\",\"stdout\",\"strcat\",\"strchr\",\"strcmp\",\"strcpy\",\"strcspn\",\"strlen\",\"strncat\",\"strncmp\",\"strncpy\",\"strpbrk\",\"strrchr\",\"strspn\",\"strstr\",\"swap\",\"tan\",\"tanh\",\"terminate\",\"to_underlying\",\"tolower\",\"toupper\",\"vfprintf\",\"visit\",\"vprintf\",\"vsprintf\"]\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/export-html/vendor/highlight.min.js:built_in:\"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr\"\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/export-html/vendor/highlight.min.js:_hint:[\"abort\",\"abs\",\"acos\",\"apply\",\"as_const\",\"asin\",\"atan\",\"atan2\",\"calloc\",\"ceil\",\"cerr\",\"cin\",\"clog\",\"cos\",\"cosh\",\"cout\",\"declval\",\"endl\",\"exchange\",\"exit\",\"exp\",\"fabs\",\"floor\",\"fmod\",\"forward\",\"fprintf\",\"fputs\",\"free\",\"frexp\",\"fscanf\",\"future\",\"invoke\",\"isalnum\",\"isalpha\",\"iscntrl\",\"isdigit\",\"isgraph\",\"islower\",\"isprint\",\"ispunct\",\"isspace\",\"isupper\",\"isxdigit\",\"labs\",\"launder\",\"ldexp\",\"log\",\"log10\",\"make_pair\",\"make_shared\",\"make_shared_for_overwrite\",\"make_tuple\",\"make_unique\",\"malloc\",\"memchr\",\"memcmp\",\"memcpy\",\"memset\",\"modf\",\"move\",\"pow\",\"printf\",\"putchar\",\"puts\",\"realloc\",\"scanf\",\"sin\",\"sinh\",\"snprintf\",\"sprintf\",\"sqrt\",\"sscanf\",\"std\",\"stderr\",\"stdin\",\"stdout\",\"strcat\",\"strchr\",\"strcmp\",\"strcpy\",\"strcspn\",\"strlen\",\"strncat\",\"strncmp\",\"strncpy\",\"strpbrk\",\"strrchr\",\"strspn\",\"strstr\",\"swap\",\"tan\",\"tanh\",\"terminate\",\"to_underlying\",\"tolower\",\"toupper\",\"vfprintf\",\"visit\",\"vprintf\",\"vsprintf\"]\n/shumengya/project/agent/sproutclaw/packages/ai/dist/utils/oauth/anthropic.js:const SCOPES = \"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload\";\n/shumengya/project/agent/sproutclaw/packages/ai/src/utils/oauth/anthropic.ts:\t\"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload\";\n"}],"isError":false,"timestamp":1781060656222}}
|
||
{"type":"message","id":"4e2985fd","parentId":"918bf6e8","timestamp":"2026-06-10T03:04:20.756Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 coding-agent 的 package.json 文件,看看是否有 MCP 相关的依赖。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_579ac7fabffd490d9abc4b30","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/package.json"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1334,"output":57,"cacheRead":35072,"cacheWrite":0,"totalTokens":36463,"cost":{"input":0.0005336000000000001,"output":0.00011399999999999999,"cacheRead":0.00280576,"cacheWrite":0,"total":0.00345336}},"stopReason":"toolUse","timestamp":1781060656224,"responseId":"f15801eccf294845afac01fb167b96da"}}
|
||
{"type":"message","id":"a83bed32","parentId":"4e2985fd","timestamp":"2026-06-10T03:04:20.759Z","message":{"role":"toolResult","toolCallId":"call_579ac7fabffd490d9abc4b30","toolName":"read","content":[{"type":"text","text":"{\n\t\"name\": \"@earendil-works/pi-coding-agent\",\n\t\"version\": \"0.75.4\",\n\t\"description\": \"Coding agent CLI with read, bash, edit, write tools and session management\",\n\t\"type\": \"module\",\n\t\"piConfig\": {\n\t\t\"configDir\": \".pi\"\n\t},\n\t\"bin\": {\n\t\t\"pi\": \"dist/cli.js\"\n\t},\n\t\"main\": \"./dist/index.js\",\n\t\"types\": \"./dist/index.d.ts\",\n\t\"exports\": {\n\t\t\".\": {\n\t\t\t\"types\": \"./dist/index.d.ts\",\n\t\t\t\"import\": \"./dist/index.js\"\n\t\t},\n\t\t\"./hooks\": {\n\t\t\t\"types\": \"./dist/core/hooks/index.d.ts\",\n\t\t\t\"import\": \"./dist/core/hooks/index.js\"\n\t\t}\n\t},\n\t\"files\": [\n\t\t\"dist\",\n\t\t\"docs\",\n\t\t\"examples\",\n\t\t\"CHANGELOG.md\",\n\t\t\"npm-shrinkwrap.json\"\n\t],\n\t\"scripts\": {\n\t\t\"clean\": \"shx rm -rf dist\",\n\t\t\"build\": \"tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets\",\n\t\t\"build:binary\": \"npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js --outfile dist/pi && npm run copy-binary-assets\",\n\t\t\"copy-assets\": \"shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/\",\n\t\t\"copy-binary-assets\": \"shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/\",\n\t\t\"test\": \"vitest --run\",\n\t\t\"shrinkwrap\": \"node ../../scripts/generate-coding-agent-shrinkwrap.mjs\",\n\t\t\"prepublishOnly\": \"npm run clean && npm run build && npm run shrinkwrap\"\n\t},\n\t\"dependencies\": {\n\t\t\"@earendil-works/pi-agent-core\": \"^0.75.4\",\n\t\t\"@earendil-works/pi-ai\": \"^0.75.4\",\n\t\t\"@earendil-works/pi-tui\": \"^0.75.4\",\n\t\t\"@silvia-odwyer/photon-node\": \"0.3.4\",\n\t\t\"chalk\": \"5.6.2\",\n\t\t\"cross-spawn\": \"7.0.6\",\n\t\t\"diff\": \"8.0.4\",\n\t\t\"glob\": \"13.0.6\",\n\t\t\"highlight.js\": \"10.7.3\",\n\t\t\"hosted-git-info\": \"9.0.3\",\n\t\t\"ignore\": \"7.0.5\",\n\t\t\"jiti\": \"2.7.0\",\n\t\t\"minimatch\": \"10.2.5\",\n\t\t\"proper-lockfile\": \"4.1.2\",\n\t\t\"typebox\": \"1.1.38\",\n\t\t\"undici\": \"8.3.0\",\n\t\t\"yaml\": \"2.9.0\"\n\t},\n\t\"overrides\": {\n\t\t\"rimraf\": \"6.1.2\",\n\t\t\"gaxios\": {\n\t\t\t\"rimraf\": \"6.1.2\"\n\t\t}\n\t},\n\t\"optionalDependencies\": {\n\t\t\"@mariozechner/clipboard\": \"0.3.6\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@types/cross-spawn\": \"6.0.6\",\n\t\t\"@types/diff\": \"7.0.2\",\n\t\t\"@types/hosted-git-info\": \"3.0.5\",\n\t\t\"@types/ms\": \"2.1.0\",\n\t\t\"@types/node\": \"24.12.4\",\n\t\t\"@types/proper-lockfile\": \"4.1.4\",\n\t\t\"shx\": \"0.4.0\",\n\t\t\"typescript\": \"5.9.3\",\n\t\t\"vitest\": \"3.2.4\"\n\t},\n\t\"keywords\": [\n\t\t\"coding-agent\",\n\t\t\"ai\",\n\t\t\"llm\",\n\t\t\"cli\",\n\t\t\"tui\",\n\t\t\"agent\"\n\t],\n\t\"author\": \"Mario Zechner\",\n\t\"license\": \"MIT\",\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"git+https://github.com/earendil-works/pi-mono.git\",\n\t\t\"directory\": \"packages/coding-agent\"\n\t},\n\t\"engines\": {\n\t\t\"node\": \">=22.19.0\"\n\t}\n}\n"}],"isError":false,"timestamp":1781060660759}}
|
||
{"type":"message","id":"1f3a8307","parentId":"a83bed32","timestamp":"2026-06-10T03:04:22.958Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下项目的 README.md 文件中关于 MCP 的说明,或者查看其他文档。让我先看看 coding-agent 的 README.md。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_d2c1737da781440bb86e3090","name":"bash","arguments":{"command":"grep -i \"mcp\" /shumengya/project/agent/sproutclaw/packages/coding-agent/README.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1371,"output":76,"cacheRead":36352,"cacheWrite":0,"totalTokens":37799,"cost":{"input":0.0005484,"output":0.00015199999999999998,"cacheRead":0.00290816,"cacheWrite":0,"total":0.00360856}},"stopReason":"toolUse","timestamp":1781060660761,"responseId":"a0c0651784964f2283930a9ba0323d91"}}
|
||
{"type":"message","id":"e107259c","parentId":"1f3a8307","timestamp":"2026-06-10T03:04:22.968Z","message":{"role":"toolResult","toolCallId":"call_d2c1737da781440bb86e3090","toolName":"bash","content":[{"type":"text","text":"- MCP server integration\n**No MCP.** Build CLI tools with READMEs (see [Skills](#skills)), or build an extension that adds MCP support. [Why?](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/)\n"}],"isError":false,"timestamp":1781060662968}}
|
||
{"type":"message","id":"f06f93e8","parentId":"e107259c","timestamp":"2026-06-10T03:04:24.823Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下项目中关于 MCP 的更详细的文档。让我查看一下 coding-agent 的文档目录。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_e065e982052e4555bf64ba38","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages/coding-agent/docs"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":182,"output":64,"cacheRead":37696,"cacheWrite":0,"totalTokens":37942,"cost":{"input":0.00007280000000000001,"output":0.000128,"cacheRead":0.00301568,"cacheWrite":0,"total":0.0032164800000000003}},"stopReason":"toolUse","timestamp":1781060662969,"responseId":"4606062a620e437da9778fc668a5f1f8"}}
|
||
{"type":"message","id":"fb13a72e","parentId":"f06f93e8","timestamp":"2026-06-10T03:04:24.832Z","message":{"role":"toolResult","toolCallId":"call_e065e982052e4555bf64ba38","toolName":"bash","content":[{"type":"text","text":"总计 416\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 .\ndrwxr-xr-x 9 root root 4096 5月22日 20:05 ..\n-rw-r--r-- 1 root root 17088 5月 8日 19:40 compaction.md\n-rw-r--r-- 1 root root 23911 5月22日 20:05 custom-provider.md\n-rw-r--r-- 1 root root 1440 5月 8日 19:40 development.md\n-rw-r--r-- 1 root root 2854 5月 6日 20:53 docs.json\n-rw-r--r-- 1 root root 97059 5月22日 20:05 extensions.md\ndrwxr-xr-x 2 root root 4096 5月 6日 20:53 images\n-rw-r--r-- 1 root root 2974 5月22日 20:05 index.md\n-rw-r--r-- 1 root root 3285 5月 8日 19:40 json.md\n-rw-r--r-- 1 root root 8919 5月 6日 20:53 keybindings.md\n-rw-r--r-- 1 root root 15862 5月 9日 13:48 models.md\n-rw-r--r-- 1 root root 8169 5月22日 20:05 packages.md\n-rw-r--r-- 1 root root 2616 5月 6日 20:53 prompt-templates.md\n-rw-r--r-- 1 root root 9829 5月 9日 13:48 providers.md\n-rw-r--r-- 1 root root 4419 5月22日 20:05 quickstart.md\n-rw-r--r-- 1 root root 35429 5月 8日 19:40 rpc.md\n-rw-r--r-- 1 root root 33456 5月22日 20:05 sdk.md\n-rw-r--r-- 1 root root 14334 5月 8日 19:40 session-format.md\n-rw-r--r-- 1 root root 5133 5月 6日 20:53 sessions.md\n-rw-r--r-- 1 root root 9846 5月22日 20:05 settings.md\n-rw-r--r-- 1 root root 356 5月 6日 20:53 shell-aliases.md\n-rw-r--r-- 1 root root 6861 5月22日 20:05 skills.md\n-rw-r--r-- 1 root root 3503 5月 6日 20:53 terminal-setup.md\n-rw-r--r-- 1 root root 3213 5月22日 20:05 termux.md\n-rw-r--r-- 1 root root 7936 5月10日 09:48 themes.md\n-rw-r--r-- 1 root root 1818 5月 6日 20:53 tmux.md\n-rw-r--r-- 1 root root 28984 5月 8日 19:40 tui.md\n-rw-r--r-- 1 root root 11003 5月22日 20:05 usage.md\n-rw-r--r-- 1 root root 394 5月 6日 20:53 windows.md\n"}],"isError":false,"timestamp":1781060664832}}
|
||
{"type":"message","id":"5a3662c3","parentId":"fb13a72e","timestamp":"2026-06-10T03:04:27.461Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 extensions.md 文档,看看是否有关于 MCP 的说明。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_9b97ae342b87427cbbcb3220","name":"bash","arguments":{"command":"grep -i \"mcp\" /shumengya/project/agent/sproutclaw/packages/coding-agent/docs/extensions.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1101,"output":65,"cacheRead":37824,"cacheWrite":0,"totalTokens":38990,"cost":{"input":0.0004404,"output":0.00013,"cacheRead":0.00302592,"cacheWrite":0,"total":0.00359632}},"stopReason":"toolUse","timestamp":1781060664833,"responseId":"490369efaba146b8ab6a9a91d5832793"}}
|
||
{"type":"message","id":"c0641908","parentId":"5a3662c3","timestamp":"2026-06-10T03:04:27.472Z","message":{"role":"toolResult","toolCallId":"call_9b97ae342b87427cbbcb3220","toolName":"bash","content":[{"type":"text","text":"(no output)\n\nCommand exited with code 1"}],"details":{},"isError":true,"timestamp":1781060667471}}
|
||
{"type":"message","id":"6cfe53ec","parentId":"c0641908","timestamp":"2026-06-10T03:04:29.930Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下项目中关于 MCP 的实际使用情况。首先查看一下 mcp-npx-cache.json 文件。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_c2a3515834a648b2b173006f","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/mcp-npx-cache.json"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":99,"output":65,"cacheRead":38912,"cacheWrite":0,"totalTokens":39076,"cost":{"input":0.0000396,"output":0.00013,"cacheRead":0.00311296,"cacheWrite":0,"total":0.0032825600000000003}},"stopReason":"toolUse","timestamp":1781060667472,"responseId":"14639c44369b49aeb9e75ca6a969442e"}}
|
||
{"type":"message","id":"20f54d7e","parentId":"6cfe53ec","timestamp":"2026-06-10T03:04:29.932Z","message":{"role":"toolResult","toolCallId":"call_c2a3515834a648b2b173006f","toolName":"read","content":[{"type":"text","text":"{\n \"version\": 1,\n \"entries\": {\n \"[\\\"npx\\\",\\\"-y\\\",\\\"@yrobot/cf-search-mcp\\\"]\": {\n \"resolvedBin\": \"/root/.npm/_npx/b8af60c0b6d7dc1b/node_modules/@yrobot/cf-search-mcp/mcp/cf-search-mcp.js\",\n \"resolvedAt\": 1778500714760,\n \"packageVersion\": \"1.1.0\",\n \"isJs\": true\n }\n }\n}"}],"isError":false,"timestamp":1781060669932}}
|
||
{"type":"message","id":"a4aad411","parentId":"20f54d7e","timestamp":"2026-06-10T03:04:32.058Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下项目中关于 MCP 的实际代码实现。让我搜索一下 MCP 相关的工具或适配器代码。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_2e859fc8d42e45aca99423e9","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw -name \"*.ts\" -type f -exec grep -l \"MCP\\|mcp\" {} \\; 2>/dev/null | grep -v node_modules | head -20"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":233,"output":103,"cacheRead":38976,"cacheWrite":0,"totalTokens":39312,"cost":{"input":0.0000932,"output":0.000206,"cacheRead":0.00311808,"cacheWrite":0,"total":0.00341728}},"stopReason":"toolUse","timestamp":1781060669933,"responseId":"ebc5320efcc9466a8628233acc0c78d1"}}
|
||
{"type":"message","id":"5107b847","parentId":"a4aad411","timestamp":"2026-06-10T03:05:15.690Z","message":{"role":"toolResult","toolCallId":"call_2e859fc8d42e45aca99423e9","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/frontend/src/types/events.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/frontend/src/api/settings.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/settings/mcp-settings.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/config/paths.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/routes/settings.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts\n/shumengya/project/agent/sproutclaw/packages/ai/src/utils/oauth/anthropic.ts\n"}],"isError":false,"timestamp":1781060715690}}
|
||
{"type":"message","id":"1253e50d","parentId":"5107b847","timestamp":"2026-06-10T03:05:18.596Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 webui 后端中的 MCP 设置文件,了解 MCP 在项目中的实际应用。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_0d48a011f70049e7bf8c215e","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/settings/mcp-settings.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":327,"output":66,"cacheRead":39168,"cacheWrite":0,"totalTokens":39561,"cost":{"input":0.0001308,"output":0.00013199999999999998,"cacheRead":0.00313344,"cacheWrite":0,"total":0.00339624}},"stopReason":"toolUse","timestamp":1781060715690,"responseId":"6698a68cf9a3421bbe47ea672477b37b"}}
|
||
{"type":"message","id":"52b22337","parentId":"1253e50d","timestamp":"2026-06-10T03:05:18.599Z","message":{"role":"toolResult","toolCallId":"call_0d48a011f70049e7bf8c215e","toolName":"read","content":[{"type":"text","text":"import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\n\nconst DISABLED_SERVERS_KEY = \"mcpServersDisabled\";\n\ntype McpServerEntry = Record<string, unknown>;\n\nexport interface McpToolSettingsEntry {\n server: string;\n name: string;\n description: string;\n parameters: string[];\n required: string[];\n enabled: boolean;\n}\n\nexport interface McpServerSettingsEntry {\n name: string;\n configured: boolean;\n enabled: boolean;\n cached: boolean;\n toolCount: number;\n enabledToolCount: number;\n resourceCount: number;\n cachedAt: string;\n tools: McpToolSettingsEntry[];\n}\n\nfunction readRawConfig(filePath: string): Record<string, unknown> {\n if (!existsSync(filePath)) return { mcpServers: {} };\n try {\n const raw = JSON.parse(readFileSync(filePath, \"utf8\"));\n return raw && typeof raw === \"object\" && !Array.isArray(raw) ? raw : { mcpServers: {} };\n } catch {\n return { mcpServers: {} };\n }\n}\n\nfunction writeRawConfig(filePath: string, raw: Record<string, unknown>): void {\n mkdirSync(dirname(filePath), { recursive: true });\n const tmpPath = `${filePath}.${process.pid}.tmp`;\n writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\\n`, \"utf8\");\n renameSync(tmpPath, filePath);\n}\n\nfunction getServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {\n const existing = raw.mcpServers ?? raw[\"mcp-servers\"];\n if (!existing || typeof existing !== \"object\" || Array.isArray(existing)) {\n return {};\n }\n return existing as Record<string, McpServerEntry>;\n}\n\nfunction getDisabledServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {\n const existing = raw[DISABLED_SERVERS_KEY];\n if (!existing || typeof existing !== \"object\" || Array.isArray(existing)) {\n return {};\n }\n return existing as Record<string, McpServerEntry>;\n}\n\nfunction setServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {\n delete raw[\"mcp-servers\"];\n raw.mcpServers = servers;\n}\n\nfunction setDisabledServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {\n if (Object.keys(servers).length === 0) {\n delete raw[DISABLED_SERVERS_KEY];\n return;\n }\n raw[DISABLED_SERVERS_KEY] = servers;\n}\n\nfunction normalizeToolName(value: string): string {\n return value.replace(/-/g, \"_\");\n}\n\nfunction isToolExcluded(toolName: string, serverName: string, excludeTools: unknown): boolean {\n if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false;\n\n const candidates = new Set<string>([\n normalizeToolName(toolName),\n normalizeToolName(`${serverName}_${toolName}`),\n normalizeToolName(`${serverName.replace(/-/g, \"_\")}_${toolName}`),\n ]);\n\n for (const excluded of excludeTools) {\n if (typeof excluded !== \"string\") continue;\n if (candidates.has(normalizeToolName(excluded))) {\n return true;\n }\n }\n return false;\n}\n\nfunction removeToolFromExcludeList(excludeTools: string[], toolName: string, serverName: string): string[] {\n return excludeTools.filter((entry) => !isToolExcluded(toolName, serverName, [entry]));\n}\n\nfunction readJsonFile(filePath: string): any | null {\n if (!existsSync(filePath)) return null;\n try {\n return JSON.parse(readFileSync(filePath, \"utf8\"));\n } catch {\n return null;\n }\n}\n\nfunction normalizeMcpTool(serverName: string, tool: any, serverEntry: McpServerEntry): McpToolSettingsEntry {\n const schema = tool?.inputSchema && typeof tool.inputSchema === \"object\" ? tool.inputSchema : {};\n const properties = schema && typeof schema.properties === \"object\" ? Object.keys(schema.properties) : [];\n const required = Array.isArray(schema.required) ? schema.required.map(String) : [];\n const name = String(tool?.name || \"\");\n return {\n server: serverName,\n name,\n description: String(tool?.description || \"\"),\n parameters: properties,\n required,\n enabled: name ? !isToolExcluded(name, serverName, serverEntry.excludeTools) : false,\n };\n}\n\nexport function listMcpSettings(configPath: string, cachePath: string): McpServerSettingsEntry[] {\n const raw = readRawConfig(configPath);\n const activeServers = getServersObject(raw);\n const disabledServers = getDisabledServersObject(raw);\n const cache = readJsonFile(cachePath);\n const cachedServers = cache?.servers && typeof cache.servers === \"object\" ? cache.servers : {};\n\n const serverNames = Array.from(\n new Set([...Object.keys(activeServers), ...Object.keys(disabledServers), ...Object.keys(cachedServers)]),\n ).sort();\n\n return serverNames.map((serverName) => {\n const enabled = Object.prototype.hasOwnProperty.call(activeServers, serverName);\n const serverEntry = (enabled ? activeServers[serverName] : disabledServers[serverName]) || {};\n const entry = cachedServers[serverName] || {};\n const tools = Array.isArray(entry.tools)\n ? entry.tools\n .map((tool: any) => normalizeMcpTool(serverName, tool, serverEntry))\n .filter((tool: McpToolSettingsEntry) => tool.name)\n .sort((a: McpToolSettingsEntry, b: McpToolSettingsEntry) => a.name.localeCompare(b.name))\n : [];\n const enabledTools = enabled ? tools.filter((tool) => tool.enabled) : [];\n const resources = Array.isArray(entry.resources) ? entry.resources : [];\n\n return {\n name: serverName,\n configured: enabled || Object.prototype.hasOwnProperty.call(disabledServers, serverName),\n enabled,\n cached: Boolean(cachedServers[serverName]),\n toolCount: tools.length,\n enabledToolCount: enabledTools.length,\n resourceCount: resources.length,\n cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : \"\",\n tools,\n };\n });\n}\n\nexport function setMcpServerEnabled(\n configPath: string,\n cachePath: string,\n serverName: string,\n enabled: boolean,\n): McpServerSettingsEntry {\n const raw = readRawConfig(configPath);\n const activeServers = getServersObject(raw);\n const disabledServers = getDisabledServersObject(raw);\n\n if (enabled) {\n const entry = disabledServers[serverName];\n if (!entry) {\n const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);\n if (current?.enabled) return current;\n throw new Error(`未找到已禁用的 MCP Server:${serverName}`);\n }\n activeServers[serverName] = entry;\n delete disabledServers[serverName];\n } else {\n const entry = activeServers[serverName];\n if (!entry) {\n const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);\n if (current && !current.enabled) return current;\n throw new Error(`未找到 MCP Server:${serverName}`);\n }\n disabledServers[serverName] = entry;\n delete activeServers[serverName];\n }\n\n setServersObject(raw, activeServers);\n setDisabledServersObject(raw, disabledServers);\n writeRawConfig(configPath, raw);\n\n const updated = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);\n if (!updated) {\n throw new Error(`更新 MCP Server 状态失败:${serverName}`);\n }\n return updated;\n}\n\nexport function setMcpToolEnabled(\n configPath: string,\n cachePath: string,\n serverName: string,\n toolName: string,\n enabled: boolean,\n): McpToolSettingsEntry {\n const raw = readRawConfig(configPath);\n const activeServers = getServersObject(raw);\n const serverEntry = activeServers[serverName];\n if (!serverEntry) {\n throw new Error(`MCP Server 未启用:${serverName}`);\n }\n\n const excludeTools = Array.isArray(serverEntry.excludeTools)\n ? serverEntry.excludeTools.filter((value): value is string => typeof value === \"string\")\n : [];\n\n let nextExclude = excludeTools;\n if (enabled) {\n nextExclude = removeToolFromExcludeList(excludeTools, toolName, serverName);\n } else if (!isToolExcluded(toolName, serverName, excludeTools)) {\n nextExclude = [...excludeTools, toolName];\n }\n\n if (nextExclude.length > 0) {\n serverEntry.excludeTools = nextExclude;\n } else {\n delete serverEntry.excludeTools;\n }\n\n activeServers[serverName] = serverEntry;\n setServersObject(raw, activeServers);\n writeRawConfig(configPath, raw);\n\n const server = listMcpSettings(configPath, cachePath).find((item) => item.name === serverName);\n const tool = server?.tools.find((item) => item.name === toolName);\n if (!tool) {\n throw new Error(`未找到 MCP工具:${serverName}/${toolName}`);\n }\n return tool;\n}\n"}],"isError":false,"timestamp":1781060718598}}
|
||
{"type":"message","id":"133abf06","parentId":"52b22337","timestamp":"2026-06-10T03:05:22.111Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 webui 中的设置路由,了解 MCP 是如何被管理的。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_ead71b0028124479b12aeb6e","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/routes/settings.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":2118,"output":61,"cacheRead":39488,"cacheWrite":0,"totalTokens":41667,"cost":{"input":0.0008472000000000001,"output":0.000122,"cacheRead":0.00315904,"cacheWrite":0,"total":0.00412824}},"stopReason":"toolUse","timestamp":1781060718600,"responseId":"f0f2b3a6800341ab8fff83fc4482491e"}}
|
||
{"type":"message","id":"c85c9cf2","parentId":"133abf06","timestamp":"2026-06-10T03:05:22.114Z","message":{"role":"toolResult","toolCallId":"call_ead71b0028124479b12aeb6e","toolName":"read","content":[{"type":"text","text":"import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { readWebuiAvatarSettings, writeWebuiAvatarSettings } from \"../db/index.ts\";\nimport { setExtensionEnabled } from \"../settings/extension-settings.ts\";\nimport { listMcpSettings, setMcpServerEnabled, setMcpToolEnabled } from \"../settings/mcp-settings.ts\";\nimport { listSkillSettings, setSkillEnabled } from \"../settings/skills-settings.ts\";\nimport { normalizeAvatarUrl } from \"../services/avatars.ts\";\nimport {\n\tlistExtensionsForSettings,\n\tlistLoadedExtensionsByPath,\n\tmergeExtensionToggleResponse,\n} from \"../services/extensions-display.ts\";\nimport { readModelsConfig, writeModelsConfig } from \"../services/models-config.ts\";\nimport { readSystemPrompt, writeSystemPrompt } from \"../services/system-prompt.ts\";\nimport type { WebUiContext } from \"../types/context.ts\";\nimport { json, readBody } from \"../http/request.ts\";\n\nfunction listMcpTools(ctx: WebUiContext): Record<string, unknown>[] {\n\treturn listMcpSettings(ctx.config.paths.mcpConfigFile, ctx.config.paths.mcpCacheFile);\n}\n\nexport function handleSettingsRoute(\n\treq: IncomingMessage,\n\tres: ServerResponse,\n\tctx: WebUiContext,\n\tpathname: string,\n): boolean {\n\tconst { paths } = ctx.config;\n\tconst { sendCmd } = ctx.rpc;\n\n\tif (req.method === \"GET\" && pathname === \"/api/settings\") {\n\t\tvoid listSkillSettings(paths.repoRoot, paths.agentDir)\n\t\t\t.then((skills) =>\n\t\t\t\tlistExtensionsForSettings(paths, sendCmd).then((extensions) =>\n\t\t\t\t\tjson(res, {\n\t\t\t\t\t\t...readWebuiAvatarSettings(),\n\t\t\t\t\t\twebuiDbPath: ctx.db.dbPath,\n\t\t\t\t\t\tsystemPrompt: readSystemPrompt(paths),\n\t\t\t\t\t\tsystemPromptPath: paths.systemPromptFile,\n\t\t\t\t\t\tmodelsConfig: readModelsConfig(paths),\n\t\t\t\t\t\tmodelsConfigPath: paths.modelsConfigFile,\n\t\t\t\t\t\textensionsPath: paths.agentExtensionsDir,\n\t\t\t\t\t\tmcpCachePath: paths.mcpCacheFile,\n\t\t\t\t\t\tmcpConfigPath: paths.mcpConfigFile,\n\t\t\t\t\t\tskills,\n\t\t\t\t\t\textensions,\n\t\t\t\t\t\tmcpTools: listMcpTools(ctx),\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t)\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/skills/toggle\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ path: skillPath, enabled }) => {\n\t\t\t\tif (typeof skillPath !== \"string\" || !skillPath.trim()) {\n\t\t\t\t\tthrow new Error(\"skill path 无效\");\n\t\t\t\t}\n\t\t\t\tif (typeof enabled !== \"boolean\") {\n\t\t\t\t\tthrow new Error(\"enabled 必须是 boolean\");\n\t\t\t\t}\n\t\t\t\tconst skill = await setSkillEnabled(paths.repoRoot, paths.agentDir, skillPath.trim(), enabled);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tjson(res, { ok: true, skill });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/extensions/toggle\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ path: extensionPath, enabled }) => {\n\t\t\t\tif (typeof extensionPath !== \"string\" || !extensionPath.trim()) {\n\t\t\t\t\tthrow new Error(\"extension path 无效\");\n\t\t\t\t}\n\t\t\t\tif (typeof enabled !== \"boolean\") {\n\t\t\t\t\tthrow new Error(\"enabled 必须是 boolean\");\n\t\t\t\t}\n\t\t\t\tconst extension = await setExtensionEnabled(\n\t\t\t\t\tpaths.repoRoot,\n\t\t\t\t\tpaths.agentDir,\n\t\t\t\t\textensionPath.trim(),\n\t\t\t\t\tenabled,\n\t\t\t\t);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tlet loadedByPath = new Map<string, any>();\n\t\t\t\ttry {\n\t\t\t\t\tloadedByPath = await listLoadedExtensionsByPath(paths, sendCmd);\n\t\t\t\t} catch {\n\t\t\t\t\t/* ignore */\n\t\t\t\t}\n\t\t\t\tjson(res, {\n\t\t\t\t\tok: true,\n\t\t\t\t\textension: mergeExtensionToggleResponse(paths, extension, loadedByPath),\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/mcp/server/toggle\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ server, enabled }) => {\n\t\t\t\tif (typeof server !== \"string\" || !server.trim()) {\n\t\t\t\t\tthrow new Error(\"server 名称无效\");\n\t\t\t\t}\n\t\t\t\tif (typeof enabled !== \"boolean\") {\n\t\t\t\t\tthrow new Error(\"enabled 必须是 boolean\");\n\t\t\t\t}\n\t\t\t\tconst entry = setMcpServerEnabled(\n\t\t\t\t\tpaths.mcpConfigFile,\n\t\t\t\t\tpaths.mcpCacheFile,\n\t\t\t\t\tserver.trim(),\n\t\t\t\t\tenabled,\n\t\t\t\t);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tjson(res, { ok: true, server: entry });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/mcp/tool/toggle\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ server, tool, enabled }) => {\n\t\t\t\tif (typeof server !== \"string\" || !server.trim()) {\n\t\t\t\t\tthrow new Error(\"server 名称无效\");\n\t\t\t\t}\n\t\t\t\tif (typeof tool !== \"string\" || !tool.trim()) {\n\t\t\t\t\tthrow new Error(\"tool 名称无效\");\n\t\t\t\t}\n\t\t\t\tif (typeof enabled !== \"boolean\") {\n\t\t\t\t\tthrow new Error(\"enabled 必须是 boolean\");\n\t\t\t\t}\n\t\t\t\tconst entry = setMcpToolEnabled(\n\t\t\t\t\tpaths.mcpConfigFile,\n\t\t\t\t\tpaths.mcpCacheFile,\n\t\t\t\t\tserver.trim(),\n\t\t\t\t\ttool.trim(),\n\t\t\t\t\tenabled,\n\t\t\t\t);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tjson(res, { ok: true, tool: entry });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/reload\") {\n\t\tvoid sendCmd({ type: \"reload\" })\n\t\t\t.then((r: any) => (r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500)))\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/models-config\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ modelsConfig }) => {\n\t\t\t\tif (typeof modelsConfig !== \"string\") {\n\t\t\t\t\tthrow new Error(\"modelsConfig 必须是字符串\");\n\t\t\t\t}\n\t\t\t\twriteModelsConfig(paths, modelsConfig);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tjson(res, { ok: true, modelsConfigPath: paths.modelsConfigFile });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/system-prompt\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ systemPrompt }) => {\n\t\t\t\tif (typeof systemPrompt !== \"string\") {\n\t\t\t\t\tthrow new Error(\"systemPrompt 必须是字符串\");\n\t\t\t\t}\n\t\t\t\twriteSystemPrompt(paths, systemPrompt);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tjson(res, { ok: true, systemPromptPath: paths.systemPromptFile });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/avatars\") {\n\t\tvoid readBody(req)\n\t\t\t.then(({ userAvatarUrl, agentAvatarUrl }) => {\n\t\t\t\tconst settings = {\n\t\t\t\t\tuserAvatarUrl: normalizeAvatarUrl(userAvatarUrl),\n\t\t\t\t\tagentAvatarUrl: normalizeAvatarUrl(agentAvatarUrl),\n\t\t\t\t};\n\t\t\t\twriteWebuiAvatarSettings(settings);\n\t\t\t\tjson(res, { ok: true, ...settings });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\treturn false;\n}\n"}],"isError":false,"timestamp":1781060722113}}
|
||
{"type":"message","id":"ed4e309b","parentId":"c85c9cf2","timestamp":"2026-06-10T03:05:25.549Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 coding-agent 中的 interactive-mode.ts 文件,看看 MCP 是如何被集成的。首先搜索一下 MCP 相关的代码。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_fd22976bff684811af292225","name":"bash","arguments":{"command":"grep -n \"mcp\\|MCP\" /shumengya/project/agent/sproutclaw/packages/coding-agent/src/modes/interactive/interactive-mode.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1988,"output":89,"cacheRead":41600,"cacheWrite":0,"totalTokens":43677,"cost":{"input":0.0007952,"output":0.000178,"cacheRead":0.0033280000000000002,"cacheWrite":0,"total":0.0043012}},"stopReason":"toolUse","timestamp":1781060722115,"responseId":"2ec352117f0e4a7aa88846e2eb52e2f6"}}
|
||
{"type":"message","id":"91cc3365","parentId":"ed4e309b","timestamp":"2026-06-10T03:05:25.558Z","message":{"role":"toolResult","toolCallId":"call_fd22976bff684811af292225","toolName":"bash","content":[{"type":"text","text":"(no output)\n\nCommand exited with code 1"}],"details":{},"isError":true,"timestamp":1781060725558}}
|
||
{"type":"message","id":"a17a0b70","parentId":"91cc3365","timestamp":"2026-06-10T03:05:28.729Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下项目中 MCP 的实际使用情况。让我查看一下 mcp-cache.json 文件,看看缓存了哪些 MCP 服务器信息。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_5cf0bee6a3f7468e9792a122","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/mcp-cache.json"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":114,"output":70,"cacheRead":43584,"cacheWrite":0,"totalTokens":43768,"cost":{"input":0.000045600000000000004,"output":0.00014,"cacheRead":0.00348672,"cacheWrite":0,"total":0.00367232}},"stopReason":"toolUse","timestamp":1781060725559,"responseId":"b7cbfc15780a4d839f4098f4d39ddfad"}}
|
||
{"type":"message","id":"1592e1d7","parentId":"a17a0b70","timestamp":"2026-06-10T03:05:28.731Z","message":{"role":"toolResult","toolCallId":"call_5cf0bee6a3f7468e9792a122","toolName":"read","content":[{"type":"text","text":"{\n \"version\": 1,\n \"servers\": {\n \"cloudflare-search\": {\n \"configHash\": \"ea720932a2f6b9890c3c4ebb2d0d74ebc1d324a9a3c2dedd89793a8744e72b14\",\n \"tools\": [\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web for current information, news, or any topic. Uses multiple engines (Brave, DuckDuckGo, Google, Bing) simultaneously and returns aggregated results with source URLs. Use this when you need real-time information not in your training data. \",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\n \"type\": \"string\",\n \"description\": \"The search query string\"\n },\n \"engines\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"enum\": [\n \"google\",\n \"brave\",\n \"duckduckgo\",\n \"bing\"\n ]\n },\n \"description\": \"Optional: Array of search engines to use. If not specified, uses default engines. Available engines: google, brave, duckduckgo, bing\"\n }\n },\n \"required\": [\n \"query\"\n ]\n }\n },\n {\n \"name\": \"search\",\n \"description\": \"Search across multiple search engines (Google, Brave, DuckDuckGo, Bing) and return aggregated results. This tool provides comprehensive search results from multiple sources, with source attribution for each result.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\n \"type\": \"string\",\n \"description\": \"The search query string\"\n },\n \"engines\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"enum\": [\n \"google\",\n \"brave\",\n \"duckduckgo\",\n \"bing\"\n ]\n },\n \"description\": \"Optional: Array of search engines to use. If not specified, uses default engines. Available engines: google, brave, duckduckgo, bing\"\n }\n },\n \"required\": [\n \"query\"\n ]\n }\n }\n ],\n \"resources\": [],\n \"cachedAt\": 1778572129327\n },\n \"tavily\": {\n \"configHash\": \"fddbea0c9ff5b203109a7b91f8148491a26481de41b86b93c342cf025faa982e\",\n \"tools\": [\n {\n \"name\": \"tavily_search\",\n \"description\": \"Search the web for current information on any topic. Use for news, facts, or data beyond your knowledge cutoff. Returns snippets and source URLs.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\n \"description\": \"Search query\",\n \"type\": \"string\"\n },\n \"max_results\": {\n \"default\": 5,\n \"description\": \"The maximum number of search results to return\",\n \"type\": \"integer\"\n },\n \"search_depth\": {\n \"default\": \"basic\",\n \"description\": \"The depth of the search. 'basic' for generic results, 'advanced' for more thorough search, 'fast' for optimized low latency with high relevance, 'ultra-fast' for prioritizing latency above all else\",\n \"enum\": [\n \"basic\",\n \"advanced\",\n \"fast\",\n \"ultra-fast\"\n ],\n \"type\": \"string\"\n },\n \"topic\": {\n \"const\": \"general\",\n \"default\": \"general\",\n \"description\": \"The category of the search. This will determine which of our agents will be used for the search\",\n \"type\": \"string\"\n },\n \"time_range\": {\n \"anyOf\": [\n {\n \"enum\": [\n \"day\",\n \"week\",\n \"month\",\n \"year\"\n ],\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"The time range back from the current date to include in the search results\"\n },\n \"include_images\": {\n \"default\": false,\n \"description\": \"Include a list of query-related images in the response\",\n \"type\": \"boolean\"\n },\n \"include_image_descriptions\": {\n \"default\": false,\n \"description\": \"Include a list of query-related images and their descriptions in the response\",\n \"type\": \"boolean\"\n },\n \"include_raw_content\": {\n \"default\": false,\n \"description\": \"Include the cleaned and parsed HTML content of each search result\",\n \"type\": \"boolean\"\n },\n \"include_domains\": {\n \"default\": [],\n \"description\": \"A list of domains to specifically include in the search results, if the user asks to search on specific sites set this to the domain of the site\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"exclude_domains\": {\n \"default\": [],\n \"description\": \"List of domains to specifically exclude, if the user asks to exclude a domain set this to the domain of the site\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"country\": {\n \"default\": \"\",\n \"description\": \"Boost search results from a specific country. Must be a full country name (e.g., 'United States', 'Japan', 'Germany'). ISO country codes (e.g., 'us', 'jp') are not supported. Available only if topic is general. See https://docs.tavily.com/documentation/api-reference/search for the full list of supported countries.\",\n \"type\": \"string\"\n },\n \"include_favicon\": {\n \"default\": false,\n \"description\": \"Whether to include the favicon URL for each result\",\n \"type\": \"boolean\"\n },\n \"start_date\": {\n \"default\": \"\",\n \"description\": \"Will return all results after the specified start date. Required to be written in the format YYYY-MM-DD.\",\n \"type\": \"string\"\n },\n \"end_date\": {\n \"default\": \"\",\n \"description\": \"Will return all results before the specified end date. Required to be written in the format YYYY-MM-DD\",\n \"type\": \"string\"\n },\n \"exact_match\": {\n \"anyOf\": [\n {\n \"type\": \"boolean\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Only return results containing the exact phrase(s) in quotes in your query\"\n }\n },\n \"required\": [\n \"query\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"tavily_extract\",\n \"description\": \"Extract content from URLs. Returns raw page content in markdown or text format.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"urls\": {\n \"description\": \"List of URLs to extract content from\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"extract_depth\": {\n \"default\": \"basic\",\n \"description\": \"Use 'advanced' for LinkedIn, protected sites, or tables/embedded content\",\n \"enum\": [\n \"basic\",\n \"advanced\"\n ],\n \"type\": \"string\"\n },\n \"include_images\": {\n \"default\": false,\n \"description\": \"Include images from pages\",\n \"type\": \"boolean\"\n },\n \"format\": {\n \"default\": \"markdown\",\n \"description\": \"Output format\",\n \"enum\": [\n \"markdown\",\n \"text\"\n ],\n \"type\": \"string\"\n },\n \"include_favicon\": {\n \"default\": false,\n \"description\": \"Include favicon URLs\",\n \"type\": \"boolean\"\n },\n \"query\": {\n \"default\": \"\",\n \"description\": \"Query to rerank content chunks by relevance\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"urls\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"tavily_crawl\",\n \"description\": \"Crawl a website starting from a URL. Extracts content from pages with configurable depth and breadth.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"description\": \"The root URL to begin the crawl\",\n \"type\": \"string\"\n },\n \"max_depth\": {\n \"default\": 1,\n \"description\": \"Max depth of the crawl. Defines how far from the base URL the crawler can explore.\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"max_breadth\": {\n \"default\": 20,\n \"description\": \"Max number of links to follow per level of the tree (i.e., per page)\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"limit\": {\n \"default\": 50,\n \"description\": \"Total number of links the crawler will process before stopping\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"instructions\": {\n \"default\": \"\",\n \"description\": \"Natural language instructions for the crawler. Instructions specify which types of pages the crawler should return.\",\n \"type\": \"string\"\n },\n \"select_paths\": {\n \"default\": [],\n \"description\": \"Regex patterns to select only URLs with specific path patterns (e.g., /docs/.*, /api/v1.*)\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"select_domains\": {\n \"default\": [],\n \"description\": \"Regex patterns to restrict crawling to specific domains or subdomains (e.g., ^docs\\\\.example\\\\.com$)\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"allow_external\": {\n \"default\": true,\n \"description\": \"Whether to return external links in the final response\",\n \"type\": \"boolean\"\n },\n \"extract_depth\": {\n \"default\": \"basic\",\n \"description\": \"Advanced extraction retrieves more data, including tables and embedded content, with higher success but may increase latency\",\n \"enum\": [\n \"basic\",\n \"advanced\"\n ],\n \"type\": \"string\"\n },\n \"format\": {\n \"default\": \"markdown\",\n \"description\": \"The format of the extracted web page content. markdown returns content in markdown format. text returns plain text and may increase latency.\",\n \"enum\": [\n \"markdown\",\n \"text\"\n ],\n \"type\": \"string\"\n },\n \"include_favicon\": {\n \"default\": false,\n \"description\": \"Whether to include the favicon URL for each result\",\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"url\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"tavily_map\",\n \"description\": \"Map a website's structure. Returns a list of URLs found starting from the base URL.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"description\": \"The root URL to begin the mapping\",\n \"type\": \"string\"\n },\n \"max_depth\": {\n \"default\": 1,\n \"description\": \"Max depth of the mapping. Defines how far from the base URL the crawler can explore\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"max_breadth\": {\n \"default\": 20,\n \"description\": \"Max number of links to follow per level of the tree (i.e., per page)\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"limit\": {\n \"default\": 50,\n \"description\": \"Total number of links the crawler will process before stopping\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"instructions\": {\n \"default\": \"\",\n \"description\": \"Natural language instructions for the crawler\",\n \"type\": \"string\"\n },\n \"select_paths\": {\n \"default\": [],\n \"description\": \"Regex patterns to select only URLs with specific path patterns (e.g., /docs/.*, /api/v1.*)\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"select_domains\": {\n \"default\": [],\n \"description\": \"Regex patterns to restrict crawling to specific domains or subdomains (e.g., ^docs\\\\.example\\\\.com$)\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"allow_external\": {\n \"default\": true,\n \"description\": \"Whether to return external links in the final response\",\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"url\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"tavily_research\",\n \"description\": \"Perform comprehensive research on a given topic or question. Use this tool when you need to gather information from multiple sources, including web pages, documents, and other resources, to answer a question or complete a task. Returns a detailed response based on the research findings. Rate limit: 20 requests per minute.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"input\": {\n \"description\": \"A comprehensive description of the research task\",\n \"type\": \"string\"\n },\n \"model\": {\n \"default\": \"auto\",\n \"description\": \"Defines the degree of depth of the research. 'mini' is good for narrow tasks with few subtopics. 'pro' is good for broad tasks with many subtopics\",\n \"enum\": [\n \"mini\",\n \"pro\",\n \"auto\"\n ],\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"input\"\n ],\n \"additionalProperties\": false\n }\n }\n ],\n \"resources\": [],\n \"cachedAt\": 1781024183873\n }\n }\n}"}],"isError":false,"timestamp":1781060728731}}
|
||
{"type":"message","id":"1d8627da","parentId":"1592e1d7","timestamp":"2026-06-10T03:05:31.198Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我对项目中的 MCP 有了更清晰的了解。让我查看一下项目中 MCP 工具的实际使用情况。让我搜索一下 MCP 工具的调用代码。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_ab64bb2f0a85439d9f717a7a","name":"bash","arguments":{"command":"grep -r \"mcp\\|MCP\" /shumengya/project/agent/sproutclaw/packages/coding-agent/src --include=\"*.ts\" | grep -v \"node_modules\" | head -20"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":3318,"output":107,"cacheRead":43648,"cacheWrite":0,"totalTokens":47073,"cost":{"input":0.0013272000000000002,"output":0.000214,"cacheRead":0.00349184,"cacheWrite":0,"total":0.00503304}},"stopReason":"toolUse","timestamp":1781060728733,"responseId":"07f6499b62984f8f858874b6c1a2fee9"}}
|
||
{"type":"message","id":"a77b29ac","parentId":"1d8627da","timestamp":"2026-06-10T03:05:31.207Z","message":{"role":"toolResult","toolCallId":"call_ab64bb2f0a85439d9f717a7a","toolName":"bash","content":[{"type":"text","text":"(no output)"}],"isError":false,"timestamp":1781060731207}}
|
||
{"type":"message","id":"6749a756","parentId":"a77b29ac","timestamp":"2026-06-10T03:05:33.116Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下项目中是否有专门的 MCP 工具或适配器代码。让我搜索一下 pi-mcp-adapter 相关的代码。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_defc01754334448196b46e35","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw -name \"*mcp*\" -type f | grep -v node_modules | head -20"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":175,"output":88,"cacheRead":46912,"cacheWrite":0,"totalTokens":47175,"cost":{"input":0.00007000000000000001,"output":0.000176,"cacheRead":0.00375296,"cacheWrite":0,"total":0.00399896}},"stopReason":"toolUse","timestamp":1781060731208,"responseId":"8841c889cde9400d8057d3a82726be0d"}}
|
||
{"type":"message","id":"ed1ac145","parentId":"6749a756","timestamp":"2026-06-10T03:05:33.306Z","message":{"role":"toolResult","toolCallId":"call_defc01754334448196b46e35","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/.pi/agent/skills-disabled/mcp-builder/reference/python_mcp_server.md\n/shumengya/project/agent/sproutclaw/.pi/agent/skills-disabled/mcp-builder/reference/node_mcp_server.md\n/shumengya/project/agent/sproutclaw/.pi/agent/skills-disabled/mcp-builder/reference/mcp_best_practices.md\n/shumengya/project/agent/sproutclaw/.pi/agent/mcp-npx-cache.json\n/shumengya/project/agent/sproutclaw/.pi/agent/mcp.json\n/shumengya/project/agent/sproutclaw/.pi/agent/mcp-cache.json\n/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/settings/mcp-settings.ts\n"}],"isError":false,"timestamp":1781060733306}}
|
||
{"type":"thinking_level_change","id":"40eaa15b","parentId":"ed1ac145","timestamp":"2026-06-10T03:05:39.469Z","thinkingLevel":"high"}
|
||
{"type":"model_change","id":"e10fec7c","parentId":"40eaa15b","timestamp":"2026-06-10T03:07:26.684Z","provider":"opencode-go-openai","modelId":"kimi-k2.6"}
|
||
{"type":"thinking_level_change","id":"40703f4c","parentId":"e10fec7c","timestamp":"2026-06-10T03:07:26.685Z","thinkingLevel":"off"}
|
||
{"type":"message","id":"d92987f7","parentId":"40703f4c","timestamp":"2026-06-10T03:07:30.291Z","message":{"role":"user","content":[{"type":"text","text":"你好"}],"timestamp":1781060850290}}
|
||
{"type":"message","id":"1025f45e","parentId":"d92987f7","timestamp":"2026-06-10T03:07:33.863Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户只是简单地打招呼,我应该友好地回应,并询问用户需要什么帮助。","thinkingSignature":"reasoning_content"},{"type":"text","text":"你好!我是萌小芽,树萌芽的智能 AI 助手 🌱\n\n有什么我可以帮你的吗?无论是写代码、查文档、分析问题,还是其他事情,我都很乐意效劳!"}],"api":"openai-completions","provider":"opencode-go-openai","model":"kimi-k2.6","usage":{"input":28945,"output":59,"cacheRead":528,"cacheWrite":0,"totalTokens":29532,"cost":{"input":0.027497749999999998,"output":0.000236,"cacheRead":0.00008448,"cacheWrite":0,"total":0.02781823}},"stopReason":"stop","timestamp":1781060850291,"responseId":"chatcmpl-960f6c8605c244ab8d1d5a4c2b6ff584","responseModel":"accounts/fireworks/models/kimi-k2p6"}}
|
||
{"type":"message","id":"6372a93b","parentId":"1025f45e","timestamp":"2026-06-10T03:08:00.974Z","message":{"role":"user","content":[{"type":"text","text":"MCP是什么,项目中的MCP工作流程是怎样的"}],"timestamp":1781060880973}}
|
||
{"type":"message","id":"def59ba0","parentId":"6372a93b","timestamp":"2026-06-10T03:08:04.576Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户想了解 MCP 是什么,以及项目中 MCP 的完整工作流程。我需要先查看项目中关于 MCP 的文档和代码实现。\n\n首先,我应该搜索项目中与 MCP 相关的文件和代码,了解 MCP 的具体实现。让我先查看项目根目录下是否有 MCP 相关的文档,然后查看代码中的 MCP 实现。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"functions.read:0","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/skills-disabled/mcp-builder/reference/mcp_best_practices.md"}},{"type":"toolCall","id":"functions.read:1","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/mcp.json"}},{"type":"toolCall","id":"functions.read:2","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/mcp-cache.json"}}],"api":"openai-completions","provider":"opencode-go-openai","model":"kimi-k2.6","usage":{"input":62,"output":166,"cacheRead":29472,"cacheWrite":0,"totalTokens":29700,"cost":{"input":0.000058899999999999995,"output":0.000664,"cacheRead":0.00471552,"cacheWrite":0,"total":0.00543842}},"stopReason":"toolUse","timestamp":1781060880975,"responseId":"chatcmpl-88c7ecd27a1d4707afac85d7997258c4","responseModel":"accounts/fireworks/models/kimi-k2p6"}}
|
||
{"type":"message","id":"6ec2f6f1","parentId":"def59ba0","timestamp":"2026-06-10T03:08:04.579Z","message":{"role":"toolResult","toolCallId":"functions.read:0","toolName":"read","content":[{"type":"text","text":"# MCP Server Best Practices\n\n## Quick Reference\n\n### Server Naming\n- **Python**: `{service}_mcp` (e.g., `slack_mcp`)\n- **Node/TypeScript**: `{service}-mcp-server` (e.g., `slack-mcp-server`)\n\n### Tool Naming\n- Use snake_case with service prefix\n- Format: `{service}_{action}_{resource}`\n- Example: `slack_send_message`, `github_create_issue`\n\n### Response Formats\n- Support both JSON and Markdown formats\n- JSON for programmatic processing\n- Markdown for human readability\n\n### Pagination\n- Always respect `limit` parameter\n- Return `has_more`, `next_offset`, `total_count`\n- Default to 20-50 items\n\n### Transport\n- **Streamable HTTP**: For remote servers, multi-client scenarios\n- **stdio**: For local integrations, command-line tools\n- Avoid SSE (deprecated in favor of streamable HTTP)\n\n---\n\n## Server Naming Conventions\n\nFollow these standardized naming patterns:\n\n**Python**: Use format `{service}_mcp` (lowercase with underscores)\n- Examples: `slack_mcp`, `github_mcp`, `jira_mcp`\n\n**Node/TypeScript**: Use format `{service}-mcp-server` (lowercase with hyphens)\n- Examples: `slack-mcp-server`, `github-mcp-server`, `jira-mcp-server`\n\nThe name should be general, descriptive of the service being integrated, easy to infer from the task description, and without version numbers.\n\n---\n\n## Tool Naming and Design\n\n### Tool Naming\n\n1. **Use snake_case**: `search_users`, `create_project`, `get_channel_info`\n2. **Include service prefix**: Anticipate that your MCP server may be used alongside other MCP servers\n - Use `slack_send_message` instead of just `send_message`\n - Use `github_create_issue` instead of just `create_issue`\n3. **Be action-oriented**: Start with verbs (get, list, search, create, etc.)\n4. **Be specific**: Avoid generic names that could conflict with other servers\n\n### Tool Design\n\n- Tool descriptions must narrowly and unambiguously describe functionality\n- Descriptions must precisely match actual functionality\n- Provide tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)\n- Keep tool operations focused and atomic\n\n---\n\n## Response Formats\n\nAll tools that return data should support multiple formats:\n\n### JSON Format (`response_format=\"json\"`)\n- Machine-readable structured data\n- Include all available fields and metadata\n- Consistent field names and types\n- Use for programmatic processing\n\n### Markdown Format (`response_format=\"markdown\"`, typically default)\n- Human-readable formatted text\n- Use headers, lists, and formatting for clarity\n- Convert timestamps to human-readable format\n- Show display names with IDs in parentheses\n- Omit verbose metadata\n\n---\n\n## Pagination\n\nFor tools that list resources:\n\n- **Always respect the `limit` parameter**\n- **Implement pagination**: Use `offset` or cursor-based pagination\n- **Return pagination metadata**: Include `has_more`, `next_offset`/`next_cursor`, `total_count`\n- **Never load all results into memory**: Especially important for large datasets\n- **Default to reasonable limits**: 20-50 items is typical\n\nExample pagination response:\n```json\n{\n \"total\": 150,\n \"count\": 20,\n \"offset\": 0,\n \"items\": [...],\n \"has_more\": true,\n \"next_offset\": 20\n}\n```\n\n---\n\n## Transport Options\n\n### Streamable HTTP\n\n**Best for**: Remote servers, web services, multi-client scenarios\n\n**Characteristics**:\n- Bidirectional communication over HTTP\n- Supports multiple simultaneous clients\n- Can be deployed as a web service\n- Enables server-to-client notifications\n\n**Use when**:\n- Serving multiple clients simultaneously\n- Deploying as a cloud service\n- Integration with web applications\n\n### stdio\n\n**Best for**: Local integrations, command-line tools\n\n**Characteristics**:\n- Standard input/output stream communication\n- Simple setup, no network configuration needed\n- Runs as a subprocess of the client\n\n**Use when**:\n- Building tools for local development environments\n- Integrating with desktop applications\n- Single-user, single-session scenarios\n\n**Note**: stdio servers should NOT log to stdout (use stderr for logging)\n\n### Transport Selection\n\n| Criterion | stdio | Streamable HTTP |\n|-----------|-------|-----------------|\n| **Deployment** | Local | Remote |\n| **Clients** | Single | Multiple |\n| **Complexity** | Low | Medium |\n| **Real-time** | No | Yes |\n\n---\n\n## Security Best Practices\n\n### Authentication and Authorization\n\n**OAuth 2.1**:\n- Use secure OAuth 2.1 with certificates from recognized authorities\n- Validate access tokens before processing requests\n- Only accept tokens specifically intended for your server\n\n**API Keys**:\n- Store API keys in environment variables, never in code\n- Validate keys on server startup\n- Provide clear error messages when authentication fails\n\n### Input Validation\n\n- Sanitize file paths to prevent directory traversal\n- Validate URLs and external identifiers\n- Check parameter sizes and ranges\n- Prevent command injection in system calls\n- Use schema validation (Pydantic/Zod) for all inputs\n\n### Error Handling\n\n- Don't expose internal errors to clients\n- Log security-relevant errors server-side\n- Provide helpful but not revealing error messages\n- Clean up resources after errors\n\n### DNS Rebinding Protection\n\nFor streamable HTTP servers running locally:\n- Enable DNS rebinding protection\n- Validate the `Origin` header on all incoming connections\n- Bind to `127.0.0.1` rather than `0.0.0.0`\n\n---\n\n## Tool Annotations\n\nProvide annotations to help clients understand tool behavior:\n\n| Annotation | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `readOnlyHint` | boolean | false | Tool does not modify its environment |\n| `destructiveHint` | boolean | true | Tool may perform destructive updates |\n| `idempotentHint` | boolean | false | Repeated calls with same args have no additional effect |\n| `openWorldHint` | boolean | true | Tool interacts with external entities |\n\n**Important**: Annotations are hints, not security guarantees. Clients should not make security-critical decisions based solely on annotations.\n\n---\n\n## Error Handling\n\n- Use standard JSON-RPC error codes\n- Report tool errors within result objects (not protocol-level errors)\n- Provide helpful, specific error messages with suggested next steps\n- Don't expose internal implementation details\n- Clean up resources properly on errors\n\nExample error handling:\n```typescript\ntry {\n const result = performOperation();\n return { content: [{ type: \"text\", text: result }] };\n} catch (error) {\n return {\n isError: true,\n content: [{\n type: \"text\",\n text: `Error: ${error.message}. Try using filter='active_only' to reduce results.`\n }]\n };\n}\n```\n\n---\n\n## Testing Requirements\n\nComprehensive testing should cover:\n\n- **Functional testing**: Verify correct execution with valid/invalid inputs\n- **Integration testing**: Test interaction with external systems\n- **Security testing**: Validate auth, input sanitization, rate limiting\n- **Performance testing**: Check behavior under load, timeouts\n- **Error handling**: Ensure proper error reporting and cleanup\n\n---\n\n## Documentation Requirements\n\n- Provide clear documentation of all tools and capabilities\n- Include working examples (at least 3 per major feature)\n- Document security considerations\n- Specify required permissions and access levels\n- Document rate limits and performance characteristics\n"}],"isError":false,"timestamp":1781060884579}}
|
||
{"type":"message","id":"561935e0","parentId":"6ec2f6f1","timestamp":"2026-06-10T03:08:04.579Z","message":{"role":"toolResult","toolCallId":"functions.read:1","toolName":"read","content":[{"type":"text","text":"{\n \"mcpServers\": {\n \"tavily\": {\n \"url\": \"https://mcp.tavily.com/mcp/?tavilyApiKey=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\"\n }\n },\n \"mcpServersDisabled\": {\n \"cloudflare-search\": {\n \"command\": \"npx\",\n \"args\": [\n \"-y\",\n \"@yrobot/cf-search-mcp\"\n ],\n \"env\": {\n \"CF_SEARCH_URL\": \"https://search.smyhub.com\",\n \"CF_SEARCH_TOKEN\": \"shumengya520\"\n }\n }\n }\n}\n"}],"isError":false,"timestamp":1781060884579}}
|
||
{"type":"message","id":"c03ad271","parentId":"561935e0","timestamp":"2026-06-10T03:08:04.579Z","message":{"role":"toolResult","toolCallId":"functions.read:2","toolName":"read","content":[{"type":"text","text":"{\n \"version\": 1,\n \"servers\": {\n \"cloudflare-search\": {\n \"configHash\": \"ea720932a2f6b9890c3c4ebb2d0d74ebc1d324a9a3c2dedd89793a8744e72b14\",\n \"tools\": [\n {\n \"name\": \"web_search\",\n \"description\": \"Search the web for current information, news, or any topic. Uses multiple engines (Brave, DuckDuckGo, Google, Bing) simultaneously and returns aggregated results with source URLs. Use this when you need real-time information not in your training data. \",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\n \"type\": \"string\",\n \"description\": \"The search query string\"\n },\n \"engines\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"enum\": [\n \"google\",\n \"brave\",\n \"duckduckgo\",\n \"bing\"\n ]\n },\n \"description\": \"Optional: Array of search engines to use. If not specified, uses default engines. Available engines: google, brave, duckduckgo, bing\"\n }\n },\n \"required\": [\n \"query\"\n ]\n }\n },\n {\n \"name\": \"search\",\n \"description\": \"Search across multiple search engines (Google, Brave, DuckDuckGo, Bing) and return aggregated results. This tool provides comprehensive search results from multiple sources, with source attribution for each result.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\n \"type\": \"string\",\n \"description\": \"The search query string\"\n },\n \"engines\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"enum\": [\n \"google\",\n \"brave\",\n \"duckduckgo\",\n \"bing\"\n ]\n },\n \"description\": \"Optional: Array of search engines to use. If not specified, uses default engines. Available engines: google, brave, duckduckgo, bing\"\n }\n },\n \"required\": [\n \"query\"\n ]\n }\n }\n ],\n \"resources\": [],\n \"cachedAt\": 1778572129327\n },\n \"tavily\": {\n \"configHash\": \"fddbea0c9ff5b203109a7b91f8148491a26481de41b86b93c342cf025faa982e\",\n \"tools\": [\n {\n \"name\": \"tavily_search\",\n \"description\": \"Search the web for current information on any topic. Use for news, facts, or data beyond your knowledge cutoff. Returns snippets and source URLs.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\n \"description\": \"Search query\",\n \"type\": \"string\"\n },\n \"max_results\": {\n \"default\": 5,\n \"description\": \"The maximum number of search results to return\",\n \"type\": \"integer\"\n },\n \"search_depth\": {\n \"default\": \"basic\",\n \"description\": \"The depth of the search. 'basic' for generic results, 'advanced' for more thorough search, 'fast' for optimized low latency with high relevance, 'ultra-fast' for prioritizing latency above all else\",\n \"enum\": [\n \"basic\",\n \"advanced\",\n \"fast\",\n \"ultra-fast\"\n ],\n \"type\": \"string\"\n },\n \"topic\": {\n \"const\": \"general\",\n \"default\": \"general\",\n \"description\": \"The category of the search. This will determine which of our agents will be used for the search\",\n \"type\": \"string\"\n },\n \"time_range\": {\n \"anyOf\": [\n {\n \"enum\": [\n \"day\",\n \"week\",\n \"month\",\n \"year\"\n ],\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"The time range back from the current date to include in the search results\"\n },\n \"include_images\": {\n \"default\": false,\n \"description\": \"Include a list of query-related images in the response\",\n \"type\": \"boolean\"\n },\n \"include_image_descriptions\": {\n \"default\": false,\n \"description\": \"Include a list of query-related images and their descriptions in the response\",\n \"type\": \"boolean\"\n },\n \"include_raw_content\": {\n \"default\": false,\n \"description\": \"Include the cleaned and parsed HTML content of each search result\",\n \"type\": \"boolean\"\n },\n \"include_domains\": {\n \"default\": [],\n \"description\": \"A list of domains to specifically include in the search results, if the user asks to search on specific sites set this to the domain of the site\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"exclude_domains\": {\n \"default\": [],\n \"description\": \"List of domains to specifically exclude, if the user asks to exclude a domain set this to the domain of the site\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"country\": {\n \"default\": \"\",\n \"description\": \"Boost search results from a specific country. Must be a full country name (e.g., 'United States', 'Japan', 'Germany'). ISO country codes (e.g., 'us', 'jp') are not supported. Available only if topic is general. See https://docs.tavily.com/documentation/api-reference/search for the full list of supported countries.\",\n \"type\": \"string\"\n },\n \"include_favicon\": {\n \"default\": false,\n \"description\": \"Whether to include the favicon URL for each result\",\n \"type\": \"boolean\"\n },\n \"start_date\": {\n \"default\": \"\",\n \"description\": \"Will return all results after the specified start date. Required to be written in the format YYYY-MM-DD.\",\n \"type\": \"string\"\n },\n \"end_date\": {\n \"default\": \"\",\n \"description\": \"Will return all results before the specified end date. Required to be written in the format YYYY-MM-DD\",\n \"type\": \"string\"\n },\n \"exact_match\": {\n \"anyOf\": [\n {\n \"type\": \"boolean\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"default\": null,\n \"description\": \"Only return results containing the exact phrase(s) in quotes in your query\"\n }\n },\n \"required\": [\n \"query\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"tavily_extract\",\n \"description\": \"Extract content from URLs. Returns raw page content in markdown or text format.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"urls\": {\n \"description\": \"List of URLs to extract content from\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"extract_depth\": {\n \"default\": \"basic\",\n \"description\": \"Use 'advanced' for LinkedIn, protected sites, or tables/embedded content\",\n \"enum\": [\n \"basic\",\n \"advanced\"\n ],\n \"type\": \"string\"\n },\n \"include_images\": {\n \"default\": false,\n \"description\": \"Include images from pages\",\n \"type\": \"boolean\"\n },\n \"format\": {\n \"default\": \"markdown\",\n \"description\": \"Output format\",\n \"enum\": [\n \"markdown\",\n \"text\"\n ],\n \"type\": \"string\"\n },\n \"include_favicon\": {\n \"default\": false,\n \"description\": \"Include favicon URLs\",\n \"type\": \"boolean\"\n },\n \"query\": {\n \"default\": \"\",\n \"description\": \"Query to rerank content chunks by relevance\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"urls\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"tavily_crawl\",\n \"description\": \"Crawl a website starting from a URL. Extracts content from pages with configurable depth and breadth.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"description\": \"The root URL to begin the crawl\",\n \"type\": \"string\"\n },\n \"max_depth\": {\n \"default\": 1,\n \"description\": \"Max depth of the crawl. Defines how far from the base URL the crawler can explore.\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"max_breadth\": {\n \"default\": 20,\n \"description\": \"Max number of links to follow per level of the tree (i.e., per page)\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"limit\": {\n \"default\": 50,\n \"description\": \"Total number of links the crawler will process before stopping\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"instructions\": {\n \"default\": \"\",\n \"description\": \"Natural language instructions for the crawler. Instructions specify which types of pages the crawler should return.\",\n \"type\": \"string\"\n },\n \"select_paths\": {\n \"default\": [],\n \"description\": \"Regex patterns to select only URLs with specific path patterns (e.g., /docs/.*, /api/v1.*)\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"select_domains\": {\n \"default\": [],\n \"description\": \"Regex patterns to restrict crawling to specific domains or subdomains (e.g., ^docs\\\\.example\\\\.com$)\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"allow_external\": {\n \"default\": true,\n \"description\": \"Whether to return external links in the final response\",\n \"type\": \"boolean\"\n },\n \"extract_depth\": {\n \"default\": \"basic\",\n \"description\": \"Advanced extraction retrieves more data, including tables and embedded content, with higher success but may increase latency\",\n \"enum\": [\n \"basic\",\n \"advanced\"\n ],\n \"type\": \"string\"\n },\n \"format\": {\n \"default\": \"markdown\",\n \"description\": \"The format of the extracted web page content. markdown returns content in markdown format. text returns plain text and may increase latency.\",\n \"enum\": [\n \"markdown\",\n \"text\"\n ],\n \"type\": \"string\"\n },\n \"include_favicon\": {\n \"default\": false,\n \"description\": \"Whether to include the favicon URL for each result\",\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"url\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"tavily_map\",\n \"description\": \"Map a website's structure. Returns a list of URLs found starting from the base URL.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"description\": \"The root URL to begin the mapping\",\n \"type\": \"string\"\n },\n \"max_depth\": {\n \"default\": 1,\n \"description\": \"Max depth of the mapping. Defines how far from the base URL the crawler can explore\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"max_breadth\": {\n \"default\": 20,\n \"description\": \"Max number of links to follow per level of the tree (i.e., per page)\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"limit\": {\n \"default\": 50,\n \"description\": \"Total number of links the crawler will process before stopping\",\n \"minimum\": 1,\n \"type\": \"integer\"\n },\n \"instructions\": {\n \"default\": \"\",\n \"description\": \"Natural language instructions for the crawler\",\n \"type\": \"string\"\n },\n \"select_paths\": {\n \"default\": [],\n \"description\": \"Regex patterns to select only URLs with specific path patterns (e.g., /docs/.*, /api/v1.*)\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"select_domains\": {\n \"default\": [],\n \"description\": \"Regex patterns to restrict crawling to specific domains or subdomains (e.g., ^docs\\\\.example\\\\.com$)\",\n \"items\": {\n \"type\": \"string\"\n },\n \"type\": \"array\"\n },\n \"allow_external\": {\n \"default\": true,\n \"description\": \"Whether to return external links in the final response\",\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"url\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"tavily_research\",\n \"description\": \"Perform comprehensive research on a given topic or question. Use this tool when you need to gather information from multiple sources, including web pages, documents, and other resources, to answer a question or complete a task. Returns a detailed response based on the research findings. Rate limit: 20 requests per minute.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"input\": {\n \"description\": \"A comprehensive description of the research task\",\n \"type\": \"string\"\n },\n \"model\": {\n \"default\": \"auto\",\n \"description\": \"Defines the degree of depth of the research. 'mini' is good for narrow tasks with few subtopics. 'pro' is good for broad tasks with many subtopics\",\n \"enum\": [\n \"mini\",\n \"pro\",\n \"auto\"\n ],\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"input\"\n ],\n \"additionalProperties\": false\n }\n }\n ],\n \"resources\": [],\n \"cachedAt\": 1781024183873\n }\n }\n}"}],"isError":false,"timestamp":1781060884579}}
|
||
{"type":"message","id":"62105bbc","parentId":"c03ad271","timestamp":"2026-06-10T03:08:07.035Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"我已经看到了MCP相关的配置文件和缓存文件。现在让我查看一下项目中MCP相关的代码,特别是webui后端的MCP设置和前端API调用。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"functions.read:3","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/settings/mcp-settings.ts"}},{"type":"toolCall","id":"functions.read:4","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/routes/settings.ts"}}],"api":"openai-completions","provider":"opencode-go-openai","model":"kimi-k2.6","usage":{"input":5073,"output":104,"cacheRead":29533,"cacheWrite":0,"totalTokens":34710,"cost":{"input":0.004819349999999999,"output":0.000416,"cacheRead":0.00472528,"cacheWrite":0,"total":0.009960629999999998}},"stopReason":"toolUse","timestamp":1781060884581,"responseId":"chatcmpl-a04cceb7fc4b409e83dbfa059178bbc2","responseModel":"accounts/fireworks/models/kimi-k2p6"}}
|
||
{"type":"message","id":"9c545abb","parentId":"62105bbc","timestamp":"2026-06-10T03:08:07.038Z","message":{"role":"toolResult","toolCallId":"functions.read:3","toolName":"read","content":[{"type":"text","text":"import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\n\nconst DISABLED_SERVERS_KEY = \"mcpServersDisabled\";\n\ntype McpServerEntry = Record<string, unknown>;\n\nexport interface McpToolSettingsEntry {\n server: string;\n name: string;\n description: string;\n parameters: string[];\n required: string[];\n enabled: boolean;\n}\n\nexport interface McpServerSettingsEntry {\n name: string;\n configured: boolean;\n enabled: boolean;\n cached: boolean;\n toolCount: number;\n enabledToolCount: number;\n resourceCount: number;\n cachedAt: string;\n tools: McpToolSettingsEntry[];\n}\n\nfunction readRawConfig(filePath: string): Record<string, unknown> {\n if (!existsSync(filePath)) return { mcpServers: {} };\n try {\n const raw = JSON.parse(readFileSync(filePath, \"utf8\"));\n return raw && typeof raw === \"object\" && !Array.isArray(raw) ? raw : { mcpServers: {} };\n } catch {\n return { mcpServers: {} };\n }\n}\n\nfunction writeRawConfig(filePath: string, raw: Record<string, unknown>): void {\n mkdirSync(dirname(filePath), { recursive: true });\n const tmpPath = `${filePath}.${process.pid}.tmp`;\n writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\\n`, \"utf8\");\n renameSync(tmpPath, filePath);\n}\n\nfunction getServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {\n const existing = raw.mcpServers ?? raw[\"mcp-servers\"];\n if (!existing || typeof existing !== \"object\" || Array.isArray(existing)) {\n return {};\n }\n return existing as Record<string, McpServerEntry>;\n}\n\nfunction getDisabledServersObject(raw: Record<string, unknown>): Record<string, McpServerEntry> {\n const existing = raw[DISABLED_SERVERS_KEY];\n if (!existing || typeof existing !== \"object\" || Array.isArray(existing)) {\n return {};\n }\n return existing as Record<string, McpServerEntry>;\n}\n\nfunction setServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {\n delete raw[\"mcp-servers\"];\n raw.mcpServers = servers;\n}\n\nfunction setDisabledServersObject(raw: Record<string, unknown>, servers: Record<string, McpServerEntry>): void {\n if (Object.keys(servers).length === 0) {\n delete raw[DISABLED_SERVERS_KEY];\n return;\n }\n raw[DISABLED_SERVERS_KEY] = servers;\n}\n\nfunction normalizeToolName(value: string): string {\n return value.replace(/-/g, \"_\");\n}\n\nfunction isToolExcluded(toolName: string, serverName: string, excludeTools: unknown): boolean {\n if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false;\n\n const candidates = new Set<string>([\n normalizeToolName(toolName),\n normalizeToolName(`${serverName}_${toolName}`),\n normalizeToolName(`${serverName.replace(/-/g, \"_\")}_${toolName}`),\n ]);\n\n for (const excluded of excludeTools) {\n if (typeof excluded !== \"string\") continue;\n if (candidates.has(normalizeToolName(excluded))) {\n return true;\n }\n }\n return false;\n}\n\nfunction removeToolFromExcludeList(excludeTools: string[], toolName: string, serverName: string): string[] {\n return excludeTools.filter((entry) => !isToolExcluded(toolName, serverName, [entry]));\n}\n\nfunction readJsonFile(filePath: string): any | null {\n if (!existsSync(filePath)) return null;\n try {\n return JSON.parse(readFileSync(filePath, \"utf8\"));\n } catch {\n return null;\n }\n}\n\nfunction normalizeMcpTool(serverName: string, tool: any, serverEntry: McpServerEntry): McpToolSettingsEntry {\n const schema = tool?.inputSchema && typeof tool.inputSchema === \"object\" ? tool.inputSchema : {};\n const properties = schema && typeof schema.properties === \"object\" ? Object.keys(schema.properties) : [];\n const required = Array.isArray(schema.required) ? schema.required.map(String) : [];\n const name = String(tool?.name || \"\");\n return {\n server: serverName,\n name,\n description: String(tool?.description || \"\"),\n parameters: properties,\n required,\n enabled: name ? !isToolExcluded(name, serverName, serverEntry.excludeTools) : false,\n };\n}\n\nexport function listMcpSettings(configPath: string, cachePath: string): McpServerSettingsEntry[] {\n const raw = readRawConfig(configPath);\n const activeServers = getServersObject(raw);\n const disabledServers = getDisabledServersObject(raw);\n const cache = readJsonFile(cachePath);\n const cachedServers = cache?.servers && typeof cache.servers === \"object\" ? cache.servers : {};\n\n const serverNames = Array.from(\n new Set([...Object.keys(activeServers), ...Object.keys(disabledServers), ...Object.keys(cachedServers)]),\n ).sort();\n\n return serverNames.map((serverName) => {\n const enabled = Object.prototype.hasOwnProperty.call(activeServers, serverName);\n const serverEntry = (enabled ? activeServers[serverName] : disabledServers[serverName]) || {};\n const entry = cachedServers[serverName] || {};\n const tools = Array.isArray(entry.tools)\n ? entry.tools\n .map((tool: any) => normalizeMcpTool(serverName, tool, serverEntry))\n .filter((tool: McpToolSettingsEntry) => tool.name)\n .sort((a: McpToolSettingsEntry, b: McpToolSettingsEntry) => a.name.localeCompare(b.name))\n : [];\n const enabledTools = enabled ? tools.filter((tool) => tool.enabled) : [];\n const resources = Array.isArray(entry.resources) ? entry.resources : [];\n\n return {\n name: serverName,\n configured: enabled || Object.prototype.hasOwnProperty.call(disabledServers, serverName),\n enabled,\n cached: Boolean(cachedServers[serverName]),\n toolCount: tools.length,\n enabledToolCount: enabledTools.length,\n resourceCount: resources.length,\n cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : \"\",\n tools,\n };\n });\n}\n\nexport function setMcpServerEnabled(\n configPath: string,\n cachePath: string,\n serverName: string,\n enabled: boolean,\n): McpServerSettingsEntry {\n const raw = readRawConfig(configPath);\n const activeServers = getServersObject(raw);\n const disabledServers = getDisabledServersObject(raw);\n\n if (enabled) {\n const entry = disabledServers[serverName];\n if (!entry) {\n const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);\n if (current?.enabled) return current;\n throw new Error(`未找到已禁用的 MCP Server:${serverName}`);\n }\n activeServers[serverName] = entry;\n delete disabledServers[serverName];\n } else {\n const entry = activeServers[serverName];\n if (!entry) {\n const current = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);\n if (current && !current.enabled) return current;\n throw new Error(`未找到 MCP Server:${serverName}`);\n }\n disabledServers[serverName] = entry;\n delete activeServers[serverName];\n }\n\n setServersObject(raw, activeServers);\n setDisabledServersObject(raw, disabledServers);\n writeRawConfig(configPath, raw);\n\n const updated = listMcpSettings(configPath, cachePath).find((server) => server.name === serverName);\n if (!updated) {\n throw new Error(`更新 MCP Server 状态失败:${serverName}`);\n }\n return updated;\n}\n\nexport function setMcpToolEnabled(\n configPath: string,\n cachePath: string,\n serverName: string,\n toolName: string,\n enabled: boolean,\n): McpToolSettingsEntry {\n const raw = readRawConfig(configPath);\n const activeServers = getServersObject(raw);\n const serverEntry = activeServers[serverName];\n if (!serverEntry) {\n throw new Error(`MCP Server 未启用:${serverName}`);\n }\n\n const excludeTools = Array.isArray(serverEntry.excludeTools)\n ? serverEntry.excludeTools.filter((value): value is string => typeof value === \"string\")\n : [];\n\n let nextExclude = excludeTools;\n if (enabled) {\n nextExclude = removeToolFromExcludeList(excludeTools, toolName, serverName);\n } else if (!isToolExcluded(toolName, serverName, excludeTools)) {\n nextExclude = [...excludeTools, toolName];\n }\n\n if (nextExclude.length > 0) {\n serverEntry.excludeTools = nextExclude;\n } else {\n delete serverEntry.excludeTools;\n }\n\n activeServers[serverName] = serverEntry;\n setServersObject(raw, activeServers);\n writeRawConfig(configPath, raw);\n\n const server = listMcpSettings(configPath, cachePath).find((item) => item.name === serverName);\n const tool = server?.tools.find((item) => item.name === toolName);\n if (!tool) {\n throw new Error(`未找到 MCP工具:${serverName}/${toolName}`);\n }\n return tool;\n}\n"}],"isError":false,"timestamp":1781060887037}}
|
||
{"type":"message","id":"62b5c542","parentId":"9c545abb","timestamp":"2026-06-10T03:08:07.038Z","message":{"role":"toolResult","toolCallId":"functions.read:4","toolName":"read","content":[{"type":"text","text":"import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { readWebuiAvatarSettings, writeWebuiAvatarSettings } from \"../db/index.ts\";\nimport { setExtensionEnabled } from \"../settings/extension-settings.ts\";\nimport { listMcpSettings, setMcpServerEnabled, setMcpToolEnabled } from \"../settings/mcp-settings.ts\";\nimport { listSkillSettings, setSkillEnabled } from \"../settings/skills-settings.ts\";\nimport { normalizeAvatarUrl } from \"../services/avatars.ts\";\nimport {\n\tlistExtensionsForSettings,\n\tlistLoadedExtensionsByPath,\n\tmergeExtensionToggleResponse,\n} from \"../services/extensions-display.ts\";\nimport { readModelsConfig, writeModelsConfig } from \"../services/models-config.ts\";\nimport { readSystemPrompt, writeSystemPrompt } from \"../services/system-prompt.ts\";\nimport type { WebUiContext } from \"../types/context.ts\";\nimport { json, readBody } from \"../http/request.ts\";\n\nfunction listMcpTools(ctx: WebUiContext): Record<string, unknown>[] {\n\treturn listMcpSettings(ctx.config.paths.mcpConfigFile, ctx.config.paths.mcpCacheFile);\n}\n\nexport function handleSettingsRoute(\n\treq: IncomingMessage,\n\tres: ServerResponse,\n\tctx: WebUiContext,\n\tpathname: string,\n): boolean {\n\tconst { paths } = ctx.config;\n\tconst { sendCmd } = ctx.rpc;\n\n\tif (req.method === \"GET\" && pathname === \"/api/settings\") {\n\t\tvoid listSkillSettings(paths.repoRoot, paths.agentDir)\n\t\t\t.then((skills) =>\n\t\t\t\tlistExtensionsForSettings(paths, sendCmd).then((extensions) =>\n\t\t\t\t\tjson(res, {\n\t\t\t\t\t\t...readWebuiAvatarSettings(),\n\t\t\t\t\t\twebuiDbPath: ctx.db.dbPath,\n\t\t\t\t\t\tsystemPrompt: readSystemPrompt(paths),\n\t\t\t\t\t\tsystemPromptPath: paths.systemPromptFile,\n\t\t\t\t\t\tmodelsConfig: readModelsConfig(paths),\n\t\t\t\t\t\tmodelsConfigPath: paths.modelsConfigFile,\n\t\t\t\t\t\textensionsPath: paths.agentExtensionsDir,\n\t\t\t\t\t\tmcpCachePath: paths.mcpCacheFile,\n\t\t\t\t\t\tmcpConfigPath: paths.mcpConfigFile,\n\t\t\t\t\t\tskills,\n\t\t\t\t\t\textensions,\n\t\t\t\t\t\tmcpTools: listMcpTools(ctx),\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t)\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/skills/toggle\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ path: skillPath, enabled }) => {\n\t\t\t\tif (typeof skillPath !== \"string\" || !skillPath.trim()) {\n\t\t\t\t\tthrow new Error(\"skill path 无效\");\n\t\t\t\t}\n\t\t\t\tif (typeof enabled !== \"boolean\") {\n\t\t\t\t\tthrow new Error(\"enabled 必须是 boolean\");\n\t\t\t\t}\n\t\t\t\tconst skill = await setSkillEnabled(paths.repoRoot, paths.agentDir, skillPath.trim(), enabled);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tjson(res, { ok: true, skill });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/extensions/toggle\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ path: extensionPath, enabled }) => {\n\t\t\t\tif (typeof extensionPath !== \"string\" || !extensionPath.trim()) {\n\t\t\t\t\tthrow new Error(\"extension path 无效\");\n\t\t\t\t}\n\t\t\t\tif (typeof enabled !== \"boolean\") {\n\t\t\t\t\tthrow new Error(\"enabled 必须是 boolean\");\n\t\t\t\t}\n\t\t\t\tconst extension = await setExtensionEnabled(\n\t\t\t\t\tpaths.repoRoot,\n\t\t\t\t\tpaths.agentDir,\n\t\t\t\t\textensionPath.trim(),\n\t\t\t\t\tenabled,\n\t\t\t\t);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tlet loadedByPath = new Map<string, any>();\n\t\t\t\ttry {\n\t\t\t\t\tloadedByPath = await listLoadedExtensionsByPath(paths, sendCmd);\n\t\t\t\t} catch {\n\t\t\t\t\t/* ignore */\n\t\t\t\t}\n\t\t\t\tjson(res, {\n\t\t\t\t\tok: true,\n\t\t\t\t\textension: mergeExtensionToggleResponse(paths, extension, loadedByPath),\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/mcp/server/toggle\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ server, enabled }) => {\n\t\t\t\tif (typeof server !== \"string\" || !server.trim()) {\n\t\t\t\t\tthrow new Error(\"server 名称无效\");\n\t\t\t\t}\n\t\t\t\tif (typeof enabled !== \"boolean\") {\n\t\t\t\t\tthrow new Error(\"enabled 必须是 boolean\");\n\t\t\t\t}\n\t\t\t\tconst entry = setMcpServerEnabled(\n\t\t\t\t\tpaths.mcpConfigFile,\n\t\t\t\t\tpaths.mcpCacheFile,\n\t\t\t\t\tserver.trim(),\n\t\t\t\t\tenabled,\n\t\t\t\t);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tjson(res, { ok: true, server: entry });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/mcp/tool/toggle\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ server, tool, enabled }) => {\n\t\t\t\tif (typeof server !== \"string\" || !server.trim()) {\n\t\t\t\t\tthrow new Error(\"server 名称无效\");\n\t\t\t\t}\n\t\t\t\tif (typeof tool !== \"string\" || !tool.trim()) {\n\t\t\t\t\tthrow new Error(\"tool 名称无效\");\n\t\t\t\t}\n\t\t\t\tif (typeof enabled !== \"boolean\") {\n\t\t\t\t\tthrow new Error(\"enabled 必须是 boolean\");\n\t\t\t\t}\n\t\t\t\tconst entry = setMcpToolEnabled(\n\t\t\t\t\tpaths.mcpConfigFile,\n\t\t\t\t\tpaths.mcpCacheFile,\n\t\t\t\t\tserver.trim(),\n\t\t\t\t\ttool.trim(),\n\t\t\t\t\tenabled,\n\t\t\t\t);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tjson(res, { ok: true, tool: entry });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/reload\") {\n\t\tvoid sendCmd({ type: \"reload\" })\n\t\t\t.then((r: any) => (r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500)))\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/models-config\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ modelsConfig }) => {\n\t\t\t\tif (typeof modelsConfig !== \"string\") {\n\t\t\t\t\tthrow new Error(\"modelsConfig 必须是字符串\");\n\t\t\t\t}\n\t\t\t\twriteModelsConfig(paths, modelsConfig);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tjson(res, { ok: true, modelsConfigPath: paths.modelsConfigFile });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/system-prompt\") {\n\t\tvoid readBody(req)\n\t\t\t.then(async ({ systemPrompt }) => {\n\t\t\t\tif (typeof systemPrompt !== \"string\") {\n\t\t\t\t\tthrow new Error(\"systemPrompt 必须是字符串\");\n\t\t\t\t}\n\t\t\t\twriteSystemPrompt(paths, systemPrompt);\n\t\t\t\tconst reload = await sendCmd({ type: \"reload\" });\n\t\t\t\tif (!reload.success) throw new Error(reload.error || \"Agent 重新加载失败\");\n\t\t\t\tjson(res, { ok: true, systemPromptPath: paths.systemPromptFile });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\tif (req.method === \"POST\" && pathname === \"/api/settings/avatars\") {\n\t\tvoid readBody(req)\n\t\t\t.then(({ userAvatarUrl, agentAvatarUrl }) => {\n\t\t\t\tconst settings = {\n\t\t\t\t\tuserAvatarUrl: normalizeAvatarUrl(userAvatarUrl),\n\t\t\t\t\tagentAvatarUrl: normalizeAvatarUrl(agentAvatarUrl),\n\t\t\t\t};\n\t\t\t\twriteWebuiAvatarSettings(settings);\n\t\t\t\tjson(res, { ok: true, ...settings });\n\t\t\t})\n\t\t\t.catch((err) => json(res, { error: err.message }, 500));\n\t\treturn true;\n\t}\n\n\treturn false;\n}\n"}],"isError":false,"timestamp":1781060887038}}
|
||
{"type":"message","id":"db242f68","parentId":"62b5c542","timestamp":"2026-06-10T03:08:09.596Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"我已经查看了项目中MCP相关的配置文件和代码。现在让我查看一下MCP的架构设计和核心实现。让我搜索一下pi-mcp-adapter相关的代码,看看MCP是如何被集成到agent中的。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"functions.bash:5","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw -path \"*/node_modules\" -prune -o -name \"*.ts\" -type f -exec grep -l \"pi-mcp-adapter\\|mcpServer\\|mcp-client\\|mcp_server\\|mcpClient\" {} \\; 2>/dev/null | head -20"}},{"type":"toolCall","id":"functions.bash:6","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw -path \"*/node_modules\" -prune -o -name \"*.ts\" -type f -exec grep -l \"MCP\\|mcp\" {} \\; 2>/dev/null | grep -v node_modules | head -20"}}],"api":"openai-completions","provider":"opencode-go-openai","model":"kimi-k2.6","usage":{"input":4067,"output":214,"cacheRead":34605,"cacheWrite":0,"totalTokens":38886,"cost":{"input":0.0038636499999999997,"output":0.000856,"cacheRead":0.0055368,"cacheWrite":0,"total":0.01025645}},"stopReason":"toolUse","timestamp":1781060887040,"responseId":"chatcmpl-6cc06e8c4e2241cd847cffa0aee08153","responseModel":"accounts/fireworks/models/kimi-k2p6"}}
|
||
{"type":"message","id":"586dce3a","parentId":"db242f68","timestamp":"2026-06-10T03:08:10.910Z","message":{"role":"toolResult","toolCallId":"functions.bash:5","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/settings/mcp-settings.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts\n/shumengya/project/agent/sproutclaw/packages/ai/src/utils/oauth/anthropic.ts\n"}],"isError":false,"timestamp":1781060890910}}
|
||
{"type":"message","id":"13e9d2a9","parentId":"586dce3a","timestamp":"2026-06-10T03:08:10.910Z","message":{"role":"toolResult","toolCallId":"functions.bash:6","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/frontend/src/types/events.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/frontend/src/api/settings.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/settings/mcp-settings.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/config/paths.ts\n/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/routes/settings.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts\n/shumengya/project/agent/sproutclaw/packages/ai/src/utils/oauth/anthropic.ts\n"}],"isError":false,"timestamp":1781060890910}}
|
||
{"type":"message","id":"c42b4f30","parentId":"13e9d2a9","timestamp":"2026-06-10T03:08:13.372Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"我已经看到了项目中MCP相关的代码和配置文件。让我查看一下coding-agent中MCP相关的测试文件,了解MCP的集成方式。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"functions.read:7","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts"}},{"type":"toolCall","id":"functions.read:8","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/ai/src/utils/oauth/anthropic.ts"}}],"api":"openai-completions","provider":"opencode-go-openai","model":"kimi-k2.6","usage":{"input":465,"output":96,"cacheRead":38671,"cacheWrite":0,"totalTokens":39232,"cost":{"input":0.00044175,"output":0.000384,"cacheRead":0.00618736,"cacheWrite":0,"total":0.00701311}},"stopReason":"toolUse","timestamp":1781060890911,"responseId":"chatcmpl-901b3e9080bb475eba1734211a6c00c1","responseModel":"accounts/fireworks/models/kimi-k2p6"}}
|
||
{"type":"message","id":"5f4c5263","parentId":"c42b4f30","timestamp":"2026-06-10T03:08:13.375Z","message":{"role":"toolResult","toolCallId":"functions.read:7","toolName":"read","content":[{"type":"text","text":"import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from \"fs\";\nimport { join } from \"path\";\nimport { afterEach, beforeEach, describe, expect, it } from \"vitest\";\nimport { SettingsManager } from \"../src/core/settings-manager.ts\";\n\n/**\n * Tests for the fix to a bug where external file changes to arrays were overwritten.\n *\n * The bug scenario was:\n * 1. Pi starts with settings.json containing packages: [\"npm:some-pkg\"]\n * 2. User externally edits file to packages: []\n * 3. User changes an unrelated setting (e.g., theme) via UI\n * 4. save() would overwrite packages back to [\"npm:some-pkg\"] from stale in-memory state\n *\n * The fix tracks which fields were explicitly modified during the session, and only\n * those fields override file values during save().\n */\ndescribe(\"SettingsManager - External Edit Preservation\", () => {\n\tconst testDir = join(process.cwd(), \"test-settings-bug-tmp\");\n\tconst agentDir = join(testDir, \"agent\");\n\tconst projectDir = join(testDir, \"project\");\n\n\tbeforeEach(() => {\n\t\tif (existsSync(testDir)) {\n\t\t\trmSync(testDir, { recursive: true });\n\t\t}\n\t\tmkdirSync(agentDir, { recursive: true });\n\t\tmkdirSync(join(projectDir, \".pi\"), { recursive: true });\n\t});\n\n\tafterEach(() => {\n\t\tif (existsSync(testDir)) {\n\t\t\trmSync(testDir, { recursive: true });\n\t\t}\n\t});\n\n\tit(\"should preserve file changes to packages array when changing unrelated setting\", async () => {\n\t\tconst settingsPath = join(agentDir, \"settings.json\");\n\n\t\t// Initial state: packages has one item\n\t\twriteFileSync(\n\t\t\tsettingsPath,\n\t\t\tJSON.stringify({\n\t\t\t\ttheme: \"dark\",\n\t\t\t\tpackages: [\"npm:pi-mcp-adapter\"],\n\t\t\t}),\n\t\t);\n\n\t\t// Pi starts up, loads settings into memory\n\t\tconst manager = SettingsManager.create(projectDir, agentDir);\n\n\t\t// At this point, globalSettings.packages = [\"npm:pi-mcp-adapter\"]\n\t\texpect(manager.getPackages()).toEqual([\"npm:pi-mcp-adapter\"]);\n\n\t\t// User externally edits settings.json to remove the package\n\t\tconst currentSettings = JSON.parse(readFileSync(settingsPath, \"utf-8\"));\n\t\tcurrentSettings.packages = []; // User wants to remove this!\n\t\twriteFileSync(settingsPath, JSON.stringify(currentSettings, null, 2));\n\n\t\t// Verify file was changed\n\t\texpect(JSON.parse(readFileSync(settingsPath, \"utf-8\")).packages).toEqual([]);\n\n\t\t// User changes an UNRELATED setting via UI (this triggers save)\n\t\tmanager.setTheme(\"light\");\n\t\tawait manager.flush();\n\n\t\t// With the fix, packages should be preserved as [] (not reverted to startup value)\n\t\tconst savedSettings = JSON.parse(readFileSync(settingsPath, \"utf-8\"));\n\n\t\texpect(savedSettings.packages).toEqual([]);\n\t\texpect(savedSettings.theme).toBe(\"light\");\n\t});\n\n\tit(\"should preserve file changes to extensions array when changing unrelated setting\", async () => {\n\t\tconst settingsPath = join(agentDir, \"settings.json\");\n\n\t\twriteFileSync(\n\t\t\tsettingsPath,\n\t\t\tJSON.stringify({\n\t\t\t\ttheme: \"dark\",\n\t\t\t\textensions: [\"/old/extension.ts\"],\n\t\t\t}),\n\t\t);\n\n\t\tconst manager = SettingsManager.create(projectDir, agentDir);\n\n\t\t// User externally updates extensions\n\t\tconst currentSettings = JSON.parse(readFileSync(settingsPath, \"utf-8\"));\n\t\tcurrentSettings.extensions = [\"/new/extension.ts\"];\n\t\twriteFileSync(settingsPath, JSON.stringify(currentSettings, null, 2));\n\n\t\t// Change unrelated setting\n\t\tmanager.setDefaultThinkingLevel(\"high\");\n\t\tawait manager.flush();\n\n\t\tconst savedSettings = JSON.parse(readFileSync(settingsPath, \"utf-8\"));\n\n\t\t// With the fix, extensions should be preserved (not reverted to startup value)\n\t\texpect(savedSettings.extensions).toEqual([\"/new/extension.ts\"]);\n\t});\n\n\tit(\"should preserve external project settings changes when updating unrelated project field\", async () => {\n\t\tconst projectSettingsPath = join(projectDir, \".pi\", \"settings.json\");\n\t\twriteFileSync(\n\t\t\tprojectSettingsPath,\n\t\t\tJSON.stringify({\n\t\t\t\textensions: [\"./old-extension.ts\"],\n\t\t\t\tprompts: [\"./old-prompt.md\"],\n\t\t\t}),\n\t\t);\n\n\t\tconst manager = SettingsManager.create(projectDir, agentDir);\n\n\t\tconst currentProjectSettings = JSON.parse(readFileSync(projectSettingsPath, \"utf-8\"));\n\t\tcurrentProjectSettings.prompts = [\"./new-prompt.md\"];\n\t\twriteFileSync(projectSettingsPath, JSON.stringify(currentProjectSettings, null, 2));\n\n\t\tmanager.setProjectExtensionPaths([\"./updated-extension.ts\"]);\n\t\tawait manager.flush();\n\n\t\tconst savedProjectSettings = JSON.parse(readFileSync(projectSettingsPath, \"utf-8\"));\n\t\texpect(savedProjectSettings.prompts).toEqual([\"./new-prompt.md\"]);\n\t\texpect(savedProjectSettings.extensions).toEqual([\"./updated-extension.ts\"]);\n\t});\n\n\tit(\"should let in-memory project changes override external changes for the same project field\", async () => {\n\t\tconst projectSettingsPath = join(projectDir, \".pi\", \"settings.json\");\n\t\twriteFileSync(\n\t\t\tprojectSettingsPath,\n\t\t\tJSON.stringify({\n\t\t\t\textensions: [\"./initial-extension.ts\"],\n\t\t\t}),\n\t\t);\n\n\t\tconst manager = SettingsManager.create(projectDir, agentDir);\n\n\t\tconst currentProjectSettings = JSON.parse(readFileSync(projectSettingsPath, \"utf-8\"));\n\t\tcurrentProjectSettings.extensions = [\"./external-extension.ts\"];\n\t\twriteFileSync(projectSettingsPath, JSON.stringify(currentProjectSettings, null, 2));\n\n\t\tmanager.setProjectExtensionPaths([\"./in-memory-extension.ts\"]);\n\t\tawait manager.flush();\n\n\t\tconst savedProjectSettings = JSON.parse(readFileSync(projectSettingsPath, \"utf-8\"));\n\t\texpect(savedProjectSettings.extensions).toEqual([\"./in-memory-extension.ts\"]);\n\t});\n});\n"}],"isError":false,"timestamp":1781060893375}}
|
||
{"type":"message","id":"3c54b584","parentId":"5f4c5263","timestamp":"2026-06-10T03:08:13.375Z","message":{"role":"toolResult","toolCallId":"functions.read:8","toolName":"read","content":[{"type":"text","text":"/**\n * Anthropic OAuth flow (Claude Pro/Max)\n *\n * NOTE: This module uses Node.js http.createServer for the OAuth callback server.\n * It is only intended for CLI use, not browser environments.\n */\n\nimport type { Server } from \"node:http\";\nimport { oauthErrorHtml, oauthSuccessHtml } from \"./oauth-page.ts\";\nimport { generatePKCE } from \"./pkce.ts\";\nimport type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from \"./types.ts\";\n\ntype CallbackServerInfo = {\n\tserver: Server;\n\tredirectUri: string;\n\tcancelWait: () => void;\n\twaitForCode: () => Promise<{ code: string; state: string } | null>;\n};\n\ntype NodeApis = {\n\tcreateServer: typeof import(\"node:http\").createServer;\n};\n\nlet nodeApis: NodeApis | null = null;\nlet nodeApisPromise: Promise<NodeApis> | null = null;\n\nconst decode = (s: string) => atob(s);\nconst CLIENT_ID = decode(\"OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl\");\nconst AUTHORIZE_URL = \"https://claude.ai/oauth/authorize\";\nconst TOKEN_URL = \"https://platform.claude.com/v1/oauth/token\";\nconst CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || \"127.0.0.1\";\nconst CALLBACK_PORT = 53692;\nconst CALLBACK_PATH = \"/callback\";\nconst REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;\nconst SCOPES =\n\t\"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload\";\nasync function getNodeApis(): Promise<NodeApis> {\n\tif (nodeApis) return nodeApis;\n\tif (!nodeApisPromise) {\n\t\tif (typeof process === \"undefined\" || (!process.versions?.node && !process.versions?.bun)) {\n\t\t\tthrow new Error(\"Anthropic OAuth is only available in Node.js environments\");\n\t\t}\n\t\tnodeApisPromise = import(\"node:http\").then((httpModule) => ({\n\t\t\tcreateServer: httpModule.createServer,\n\t\t}));\n\t}\n\tnodeApis = await nodeApisPromise;\n\treturn nodeApis;\n}\n\nfunction parseAuthorizationInput(input: string): { code?: string; state?: string } {\n\tconst value = input.trim();\n\tif (!value) return {};\n\n\ttry {\n\t\tconst url = new URL(value);\n\t\treturn {\n\t\t\tcode: url.searchParams.get(\"code\") ?? undefined,\n\t\t\tstate: url.searchParams.get(\"state\") ?? undefined,\n\t\t};\n\t} catch {\n\t\t// not a URL\n\t}\n\n\tif (value.includes(\"#\")) {\n\t\tconst [code, state] = value.split(\"#\", 2);\n\t\treturn { code, state };\n\t}\n\n\tif (value.includes(\"code=\")) {\n\t\tconst params = new URLSearchParams(value);\n\t\treturn {\n\t\t\tcode: params.get(\"code\") ?? undefined,\n\t\t\tstate: params.get(\"state\") ?? undefined,\n\t\t};\n\t}\n\n\treturn { code: value };\n}\n\nfunction formatErrorDetails(error: unknown): string {\n\tif (error instanceof Error) {\n\t\tconst details: string[] = [`${error.name}: ${error.message}`];\n\t\tconst errorWithCode = error as Error & { code?: string; errno?: number | string; cause?: unknown };\n\t\tif (errorWithCode.code) details.push(`code=${errorWithCode.code}`);\n\t\tif (typeof errorWithCode.errno !== \"undefined\") details.push(`errno=${String(errorWithCode.errno)}`);\n\t\tif (typeof error.cause !== \"undefined\") {\n\t\t\tdetails.push(`cause=${formatErrorDetails(error.cause)}`);\n\t\t}\n\t\tif (error.stack) {\n\t\t\tdetails.push(`stack=${error.stack}`);\n\t\t}\n\t\treturn details.join(\"; \");\n\t}\n\treturn String(error);\n}\n\nasync function startCallbackServer(expectedState: string): Promise<CallbackServerInfo> {\n\tconst { createServer } = await getNodeApis();\n\n\treturn new Promise((resolve, reject) => {\n\t\tlet settleWait: ((value: { code: string; state: string } | null) => void) | undefined;\n\t\tconst waitForCodePromise = new Promise<{ code: string; state: string } | null>((resolveWait) => {\n\t\t\tlet settled = false;\n\t\t\tsettleWait = (value) => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tresolveWait(value);\n\t\t\t};\n\t\t});\n\n\t\tconst server = createServer((req, res) => {\n\t\t\ttry {\n\t\t\t\tconst url = new URL(req.url || \"\", \"http://localhost\");\n\t\t\t\tif (url.pathname !== CALLBACK_PATH) {\n\t\t\t\t\tres.writeHead(404, { \"Content-Type\": \"text/html; charset=utf-8\" });\n\t\t\t\t\tres.end(oauthErrorHtml(\"Callback route not found.\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst code = url.searchParams.get(\"code\");\n\t\t\t\tconst state = url.searchParams.get(\"state\");\n\t\t\t\tconst error = url.searchParams.get(\"error\");\n\n\t\t\t\tif (error) {\n\t\t\t\t\tres.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n\t\t\t\t\tres.end(oauthErrorHtml(\"Anthropic authentication did not complete.\", `Error: ${error}`));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (!code || !state) {\n\t\t\t\t\tres.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n\t\t\t\t\tres.end(oauthErrorHtml(\"Missing code or state parameter.\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (state !== expectedState) {\n\t\t\t\t\tres.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n\t\t\t\t\tres.end(oauthErrorHtml(\"State mismatch.\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tres.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n\t\t\t\tres.end(oauthSuccessHtml(\"Anthropic authentication completed. You can close this window.\"));\n\t\t\t\tsettleWait?.({ code, state });\n\t\t\t} catch {\n\t\t\t\tres.writeHead(500, { \"Content-Type\": \"text/plain; charset=utf-8\" });\n\t\t\t\tres.end(\"Internal error\");\n\t\t\t}\n\t\t});\n\n\t\tserver.on(\"error\", (err) => {\n\t\t\treject(err);\n\t\t});\n\n\t\tserver.listen(CALLBACK_PORT, CALLBACK_HOST, () => {\n\t\t\tresolve({\n\t\t\t\tserver,\n\t\t\t\tredirectUri: REDIRECT_URI,\n\t\t\t\tcancelWait: () => {\n\t\t\t\t\tsettleWait?.(null);\n\t\t\t\t},\n\t\t\t\twaitForCode: () => waitForCodePromise,\n\t\t\t});\n\t\t});\n\t});\n}\n\nasync function postJson(url: string, body: Record<string, string | number>): Promise<string> {\n\tconst response = await fetch(url, {\n\t\tmethod: \"POST\",\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\tAccept: \"application/json\",\n\t\t},\n\t\tbody: JSON.stringify(body),\n\t\tsignal: AbortSignal.timeout(30_000),\n\t});\n\n\tconst responseBody = await response.text();\n\n\tif (!response.ok) {\n\t\tthrow new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`);\n\t}\n\n\treturn responseBody;\n}\n\nasync function exchangeAuthorizationCode(\n\tcode: string,\n\tstate: string,\n\tverifier: string,\n\tredirectUri: string,\n): Promise<OAuthCredentials> {\n\tlet responseBody: string;\n\ttry {\n\t\tresponseBody = await postJson(TOKEN_URL, {\n\t\t\tgrant_type: \"authorization_code\",\n\t\t\tclient_id: CLIENT_ID,\n\t\t\tcode,\n\t\t\tstate,\n\t\t\tredirect_uri: redirectUri,\n\t\t\tcode_verifier: verifier,\n\t\t});\n\t} catch (error) {\n\t\tthrow new Error(\n\t\t\t`Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`,\n\t\t);\n\t}\n\n\tlet tokenData: { access_token: string; refresh_token: string; expires_in: number };\n\ttry {\n\t\ttokenData = JSON.parse(responseBody) as { access_token: string; refresh_token: string; expires_in: number };\n\t} catch (error) {\n\t\tthrow new Error(\n\t\t\t`Token exchange returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`,\n\t\t);\n\t}\n\n\treturn {\n\t\trefresh: tokenData.refresh_token,\n\t\taccess: tokenData.access_token,\n\t\texpires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,\n\t};\n}\n\n/**\n * Login with Anthropic OAuth (authorization code + PKCE)\n */\nexport async function loginAnthropic(options: {\n\tonAuth: (info: { url: string; instructions?: string }) => void;\n\tonPrompt: (prompt: OAuthPrompt) => Promise<string>;\n\tonProgress?: (message: string) => void;\n\tonManualCodeInput?: () => Promise<string>;\n}): Promise<OAuthCredentials> {\n\tconst { verifier, challenge } = await generatePKCE();\n\tconst server = await startCallbackServer(verifier);\n\n\tlet code: string | undefined;\n\tlet state: string | undefined;\n\tlet redirectUriForExchange = REDIRECT_URI;\n\n\ttry {\n\t\tconst authParams = new URLSearchParams({\n\t\t\tcode: \"true\",\n\t\t\tclient_id: CLIENT_ID,\n\t\t\tresponse_type: \"code\",\n\t\t\tredirect_uri: REDIRECT_URI,\n\t\t\tscope: SCOPES,\n\t\t\tcode_challenge: challenge,\n\t\t\tcode_challenge_method: \"S256\",\n\t\t\tstate: verifier,\n\t\t});\n\n\t\toptions.onAuth({\n\t\t\turl: `${AUTHORIZE_URL}?${authParams.toString()}`,\n\t\t\tinstructions:\n\t\t\t\t\"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.\",\n\t\t});\n\n\t\tif (options.onManualCodeInput) {\n\t\t\tlet manualInput: string | undefined;\n\t\t\tlet manualError: Error | undefined;\n\t\t\tconst manualPromise = options\n\t\t\t\t.onManualCodeInput()\n\t\t\t\t.then((input) => {\n\t\t\t\t\tmanualInput = input;\n\t\t\t\t\tserver.cancelWait();\n\t\t\t\t})\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tmanualError = err instanceof Error ? err : new Error(String(err));\n\t\t\t\t\tserver.cancelWait();\n\t\t\t\t});\n\n\t\t\tconst result = await server.waitForCode();\n\n\t\t\tif (manualError) {\n\t\t\t\tthrow manualError;\n\t\t\t}\n\n\t\t\tif (result?.code) {\n\t\t\t\tcode = result.code;\n\t\t\t\tstate = result.state;\n\t\t\t\tredirectUriForExchange = REDIRECT_URI;\n\t\t\t} else if (manualInput) {\n\t\t\t\tconst parsed = parseAuthorizationInput(manualInput);\n\t\t\t\tif (parsed.state && parsed.state !== verifier) {\n\t\t\t\t\tthrow new Error(\"OAuth state mismatch\");\n\t\t\t\t}\n\t\t\t\tcode = parsed.code;\n\t\t\t\tstate = parsed.state ?? verifier;\n\t\t\t}\n\n\t\t\tif (!code) {\n\t\t\t\tawait manualPromise;\n\t\t\t\tif (manualError) {\n\t\t\t\t\tthrow manualError;\n\t\t\t\t}\n\t\t\t\tif (manualInput) {\n\t\t\t\t\tconst parsed = parseAuthorizationInput(manualInput);\n\t\t\t\t\tif (parsed.state && parsed.state !== verifier) {\n\t\t\t\t\t\tthrow new Error(\"OAuth state mismatch\");\n\t\t\t\t\t}\n\t\t\t\t\tcode = parsed.code;\n\t\t\t\t\tstate = parsed.state ?? verifier;\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tconst result = await server.waitForCode();\n\t\t\tif (result?.code) {\n\t\t\t\tcode = result.code;\n\t\t\t\tstate = result.state;\n\t\t\t\tredirectUriForExchange = REDIRECT_URI;\n\t\t\t}\n\t\t}\n\n\t\tif (!code) {\n\t\t\tconst input = await options.onPrompt({\n\t\t\t\tmessage: \"Paste the authorization code or full redirect URL:\",\n\t\t\t\tplaceholder: REDIRECT_URI,\n\t\t\t});\n\t\t\tconst parsed = parseAuthorizationInput(input);\n\t\t\tif (parsed.state && parsed.state !== verifier) {\n\t\t\t\tthrow new Error(\"OAuth state mismatch\");\n\t\t\t}\n\t\t\tcode = parsed.code;\n\t\t\tstate = parsed.state ?? verifier;\n\t\t}\n\n\t\tif (!code) {\n\t\t\tthrow new Error(\"Missing authorization code\");\n\t\t}\n\n\t\tif (!state) {\n\t\t\tthrow new Error(\"Missing OAuth state\");\n\t\t}\n\n\t\toptions.onProgress?.(\"Exchanging authorization code for tokens...\");\n\t\treturn exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange);\n\t} finally {\n\t\tserver.server.close();\n\t}\n}\n\n/**\n * Refresh Anthropic OAuth token\n */\nexport async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {\n\tlet responseBody: string;\n\ttry {\n\t\tresponseBody = await postJson(TOKEN_URL, {\n\t\t\tgrant_type: \"refresh_token\",\n\t\t\tclient_id: CLIENT_ID,\n\t\t\trefresh_token: refreshToken,\n\t\t});\n\t} catch (error) {\n\t\tthrow new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`);\n\t}\n\n\tlet data: { access_token: string; refresh_token: string; expires_in: number; scope?: string };\n\ttry {\n\t\tdata = JSON.parse(responseBody) as {\n\t\t\taccess_token: string;\n\t\t\trefresh_token: string;\n\t\t\texpires_in: number;\n\t\t\tscope?: string;\n\t\t};\n\t} catch (error) {\n\t\tthrow new Error(\n\t\t\t`Anthropic token refresh returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`,\n\t\t);\n\t}\n\n\treturn {\n\t\trefresh: data.refresh_token,\n\t\taccess: data.access_token,\n\t\texpires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,\n\t};\n}\n\nexport const anthropicOAuthProvider: OAuthProviderInterface = {\n\tid: \"anthropic\",\n\tname: \"Anthropic (Claude Pro/Max)\",\n\tusesCallbackServer: true,\n\n\tasync login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {\n\t\treturn loginAnthropic({\n\t\t\tonAuth: callbacks.onAuth,\n\t\t\tonPrompt: callbacks.onPrompt,\n\t\t\tonProgress: callbacks.onProgress,\n\t\t\tonManualCodeInput: callbacks.onManualCodeInput,\n\t\t});\n\t},\n\n\tasync refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {\n\t\treturn refreshAnthropicToken(credentials.refresh);\n\t},\n\n\tgetApiKey(credentials: OAuthCredentials): string {\n\t\treturn credentials.access;\n\t},\n};\n"}],"isError":false,"timestamp":1781060893375}}
|
||
{"type":"message","id":"3a05e06e","parentId":"3c54b584","timestamp":"2026-06-10T03:08:15.675Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我查看一下MCP在WebUI前端中的类型定义和API调用。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"functions.read:9","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/frontend/src/types/events.ts"}},{"type":"toolCall","id":"functions.read:10","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/frontend/src/api/settings.ts"}}],"api":"openai-completions","provider":"opencode-go-openai","model":"kimi-k2.6","usage":{"input":4389,"output":89,"cacheRead":39135,"cacheWrite":0,"totalTokens":43613,"cost":{"input":0.004169549999999999,"output":0.000356,"cacheRead":0.0062616,"cacheWrite":0,"total":0.010787149999999999}},"stopReason":"toolUse","timestamp":1781060893377,"responseId":"chatcmpl-f32385f4aa6745c283236b58c58facc3","responseModel":"accounts/fireworks/models/kimi-k2p6"}}
|
||
{"type":"message","id":"7d528bc4","parentId":"3a05e06e","timestamp":"2026-06-10T03:08:15.677Z","message":{"role":"toolResult","toolCallId":"functions.read:9","toolName":"read","content":[{"type":"text","text":"import type { RpcMessage } from \"./message\";\n\nexport interface ConnectedEvent {\n\ttype: \"connected\";\n}\n\nexport interface AgentStartEvent {\n\ttype: \"agent_start\";\n}\n\nexport interface AgentEndEvent {\n\ttype: \"agent_end\";\n}\n\nexport interface MessageStartEvent {\n\ttype: \"message_start\";\n\tmessage: RpcMessage;\n}\n\nexport interface MessageUpdateEvent {\n\ttype: \"message_update\";\n\tmessage: RpcMessage;\n}\n\nexport interface MessageEndEvent {\n\ttype: \"message_end\";\n\tmessage: RpcMessage;\n}\n\nexport interface ToolExecutionStartEvent {\n\ttype: \"tool_execution_start\";\n\ttoolCallId?: string;\n\ttoolName: string;\n\targs?: unknown;\n}\n\nexport interface ToolExecutionUpdateEvent {\n\ttype: \"tool_execution_update\";\n\ttoolCallId?: string;\n\ttoolName: string;\n\tpartialResult?: { content?: unknown };\n}\n\nexport interface ToolExecutionEndEvent {\n\ttype: \"tool_execution_end\";\n\ttoolCallId?: string;\n\ttoolName: string;\n\tisError?: boolean;\n\tresult?: { content?: unknown };\n}\n\nexport interface CompactionStartEvent {\n\ttype: \"compaction_start\";\n\treason?: string;\n}\n\nexport interface CompactionEndEvent {\n\ttype: \"compaction_end\";\n\treason?: string;\n\taborted?: boolean;\n\terrorMessage?: string;\n\tresult?: { tokensBefore?: number; summary?: string };\n}\n\nexport type SseEvent =\n\t| ConnectedEvent\n\t| AgentStartEvent\n\t| AgentEndEvent\n\t| MessageStartEvent\n\t| MessageUpdateEvent\n\t| MessageEndEvent\n\t| ToolExecutionStartEvent\n\t| ToolExecutionUpdateEvent\n\t| ToolExecutionEndEvent\n\t| CompactionStartEvent\n\t| CompactionEndEvent;\n\nexport interface SkillInfo {\n\tname?: string;\n\tcommand?: string;\n\tdescription?: string;\n\tpath?: string;\n\tscope?: string;\n\tsource?: string;\n\tenabled?: boolean;\n\ttoggleable?: boolean;\n}\n\nexport interface McpToolInfo {\n\tserver?: string;\n\tname?: string;\n\tdescription?: string;\n\tparameters?: string[];\n\trequired?: string[];\n\tenabled?: boolean;\n}\n\nexport interface McpServerInfo {\n\tname?: string;\n\tconfigured?: boolean;\n\tenabled?: boolean;\n\tcached?: boolean;\n\ttoolCount?: number;\n\tenabledToolCount?: number;\n\tresourceCount?: number;\n\tcachedAt?: string;\n\ttools?: McpToolInfo[];\n}\n\nexport interface ExtensionInfo {\n\tname?: string;\n\tpath?: string;\n\tresolvedPath?: string;\n\tkind?: string;\n\tcategory?: \"local\" | \"npm\";\n\tscope?: string;\n\tsource?: string;\n\tlocation?: string;\n\tversion?: string;\n\tenabled?: boolean;\n\tcommands?: string[];\n\ttools?: string[];\n\thandlers?: string[];\n\tflags?: string[];\n\tshortcuts?: string[];\n}\n\nexport interface SettingsData {\n\tsystemPrompt?: string;\n\tsystemPromptPath?: string;\n\tmodelsConfig?: string;\n\tmodelsConfigPath?: string;\n\tuserAvatarUrl?: string;\n\tagentAvatarUrl?: string;\n\tskills?: SkillInfo[];\n\tmcpTools?: McpServerInfo[];\n\tmcpConfigPath?: string;\n\tmcpCachePath?: string;\n\textensions?: ExtensionInfo[];\n\textensionsPath?: string;\n\terror?: string;\n}\n\nexport interface AvatarSettings {\n\tuserAvatarUrl: string;\n\tagentAvatarUrl: string;\n}\n\nexport type SettingsPaneId = \"prompt\" | \"models\" | \"skills\" | \"mcp\" | \"extensions\" | \"other\";\n"}],"isError":false,"timestamp":1781060895677}}
|
||
{"type":"message","id":"da13d88e","parentId":"7d528bc4","timestamp":"2026-06-10T03:08:15.678Z","message":{"role":"toolResult","toolCallId":"functions.read:10","toolName":"read","content":[{"type":"text","text":"import { apiGet, apiPost } from \"./client\";\nimport type { AvatarSettings, ExtensionInfo, McpServerInfo, McpToolInfo, SettingsData, SkillInfo } from \"../types/events\";\n\nexport function fetchSettings(): Promise<SettingsData> {\n\treturn apiGet(\"/api/settings\");\n}\n\nexport function toggleSkill(path: string, enabled: boolean): Promise<{ ok?: boolean; skill?: SkillInfo }> {\n\treturn apiPost(\"/api/settings/skills/toggle\", { path, enabled });\n}\n\nexport function toggleExtension(\n\tpath: string,\n\tenabled: boolean,\n): Promise<{ ok?: boolean; extension?: ExtensionInfo }> {\n\treturn apiPost(\"/api/settings/extensions/toggle\", { path, enabled });\n}\n\nexport function toggleMcpServer(\n\tserver: string,\n\tenabled: boolean,\n): Promise<{ ok?: boolean; server?: McpServerInfo }> {\n\treturn apiPost(\"/api/settings/mcp/server/toggle\", { server, enabled });\n}\n\nexport function toggleMcpTool(\n\tserver: string,\n\ttool: string,\n\tenabled: boolean,\n): Promise<{ ok?: boolean; tool?: McpToolInfo }> {\n\treturn apiPost(\"/api/settings/mcp/tool/toggle\", { server, tool, enabled });\n}\n\nexport function fetchAvatars(): Promise<AvatarSettings> {\n\treturn apiGet(\"/api/avatars\");\n}\n\nexport function reloadSettings(): Promise<{ ok?: boolean }> {\n\treturn apiPost(\"/api/settings/reload\");\n}\n\nexport function saveSystemPrompt(systemPrompt: string): Promise<{ systemPromptPath?: string }> {\n\treturn apiPost(\"/api/settings/system-prompt\", { systemPrompt });\n}\n\nexport function saveModelsConfig(modelsConfig: string): Promise<{ modelsConfigPath?: string }> {\n\treturn apiPost(\"/api/settings/models-config\", { modelsConfig });\n}\n\nexport function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & { ok?: boolean }> {\n\treturn apiPost(\"/api/settings/avatars\", avatars);\n}\n"}],"isError":false,"timestamp":1781060895678}}
|
||
{"type":"message","id":"a2a976a1","parentId":"da13d88e","timestamp":"2026-06-10T03:08:17.903Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下MCP相关的配置路径和后端配置。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"functions.read:11","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/backend/config/paths.ts"}}],"api":"openai-completions","provider":"opencode-go-openai","model":"kimi-k2.6","usage":{"input":1222,"output":49,"cacheRead":43523,"cacheWrite":0,"totalTokens":44794,"cost":{"input":0.0011608999999999999,"output":0.000196,"cacheRead":0.00696368,"cacheWrite":0,"total":0.00832058}},"stopReason":"toolUse","timestamp":1781060895679,"responseId":"chatcmpl-1125a171edda4ba8af5c4ab317077644","responseModel":"accounts/fireworks/models/kimi-k2p6"}}
|
||
{"type":"message","id":"6620cfbc","parentId":"a2a976a1","timestamp":"2026-06-10T03:08:17.905Z","message":{"role":"toolResult","toolCallId":"functions.read:11","toolName":"read","content":[{"type":"text","text":"import { existsSync } from \"node:fs\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst configDir = dirname(fileURLToPath(import.meta.url));\nconst backendDir = dirname(configDir);\nconst extensionRoot = resolve(backendDir, \"..\");\n\nexport interface WebUiPaths {\n\textensionRoot: string;\n\trepoRoot: string;\n\tagentDir: string;\n\tpublicDir: string;\n\tpidFile: string;\n\tsessionsDir: string;\n\tagentExtensionsDir: string;\n\tagentNpmNodeModules: string;\n\tagentSettingsFile: string;\n\tsystemPromptFile: string;\n\tmodelsConfigFile: string;\n\tmcpCacheFile: string;\n\tmcpConfigFile: string;\n\tpiCliDist: string;\n}\n\nexport function createWebUiPaths(): WebUiPaths {\n\tconst repoRoot = process.cwd();\n\tconst agentDir = resolve(process.env.PI_CODING_AGENT_DIR || join(repoRoot, \".pi\", \"agent\"));\n\n\treturn {\n\t\textensionRoot,\n\t\trepoRoot,\n\t\tagentDir,\n\t\tpublicDir: join(extensionRoot, \"frontend\", \"dist\"),\n\t\tpidFile: join(extensionRoot, \".webui.pid\"),\n\t\tsessionsDir: join(agentDir, \"sessions\"),\n\t\tagentExtensionsDir: join(agentDir, \"extensions\"),\n\t\tagentNpmNodeModules: join(agentDir, \"npm\", \"node_modules\"),\n\t\tagentSettingsFile: join(agentDir, \"settings.json\"),\n\t\tsystemPromptFile: join(agentDir, \"AGENTS.md\"),\n\t\tmodelsConfigFile: join(agentDir, \"models.json\"),\n\t\tmcpCacheFile: join(agentDir, \"mcp-cache.json\"),\n\t\tmcpConfigFile: join(agentDir, \"mcp.json\"),\n\t\tpiCliDist: join(repoRoot, \"packages\", \"coding-agent\", \"dist\", \"cli.js\"),\n\t};\n}\n\nexport function resolvePiRpcLaunch(paths: WebUiPaths): { command: string; args: string[]; mode: \"dist\" } {\n\tconst rpcArgs = [\"--mode\", \"rpc\"];\n\tif (!existsSync(paths.piCliDist)) {\n\t\tthrow new Error(\n\t\t\t`[webui] 构建版 sproutclaw 未找到: ${paths.piCliDist}。请先运行: sproutclaw build`,\n\t\t);\n\t}\n\treturn { command: process.execPath, args: [paths.piCliDist, ...rpcArgs], mode: \"dist\" };\n}\n"}],"isError":false,"timestamp":1781060897905}}
|
||
{"type":"message","id":"df6608dd","parentId":"6620cfbc","timestamp":"2026-06-10T03:08:20.457Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"我已经收集了足够的信息。让我查看一下MCP builder相关的文档和技能包。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"functions.read:12","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/skills-disabled/mcp-builder/reference/node_mcp_server.md"}},{"type":"toolCall","id":"functions.read:13","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/.pi/agent/skills-disabled/mcp-builder/reference/python_mcp_server.md"}}],"api":"openai-completions","provider":"opencode-go-openai","model":"kimi-k2.6","usage":{"input":581,"output":95,"cacheRead":44744,"cacheWrite":0,"totalTokens":45420,"cost":{"input":0.0005519499999999999,"output":0.00037999999999999997,"cacheRead":0.00715904,"cacheWrite":0,"total":0.00809099}},"stopReason":"toolUse","timestamp":1781060897907,"responseId":"chatcmpl-16f2131924744af5861c47882a63811c","responseModel":"accounts/fireworks/models/kimi-k2p6"}}
|
||
{"type":"message","id":"fc49faa9","parentId":"df6608dd","timestamp":"2026-06-10T03:08:20.462Z","message":{"role":"toolResult","toolCallId":"functions.read:12","toolName":"read","content":[{"type":"text","text":"# Node/TypeScript MCP Server Implementation Guide\n\n## Overview\n\nThis document provides Node/TypeScript-specific best practices and examples for implementing MCP servers using the MCP TypeScript SDK. It covers project structure, server setup, tool registration patterns, input validation with Zod, error handling, and complete working examples.\n\n---\n\n## Quick Reference\n\n### Key Imports\n```typescript\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/streamableHttp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport express from \"express\";\nimport { z } from \"zod\";\n```\n\n### Server Initialization\n```typescript\nconst server = new McpServer({\n name: \"service-mcp-server\",\n version: \"1.0.0\"\n});\n```\n\n### Tool Registration Pattern\n```typescript\nserver.registerTool(\n \"tool_name\",\n {\n title: \"Tool Display Name\",\n description: \"What the tool does\",\n inputSchema: { param: z.string() },\n outputSchema: { result: z.string() }\n },\n async ({ param }) => {\n const output = { result: `Processed: ${param}` };\n return {\n content: [{ type: \"text\", text: JSON.stringify(output) }],\n structuredContent: output // Modern pattern for structured data\n };\n }\n);\n```\n\n---\n\n## MCP TypeScript SDK\n\nThe official MCP TypeScript SDK provides:\n- `McpServer` class for server initialization\n- `registerTool` method for tool registration\n- Zod schema integration for runtime input validation\n- Type-safe tool handler implementations\n\n**IMPORTANT - Use Modern APIs Only:**\n- **DO use**: `server.registerTool()`, `server.registerResource()`, `server.registerPrompt()`\n- **DO NOT use**: Old deprecated APIs such as `server.tool()`, `server.setRequestHandler(ListToolsRequestSchema, ...)`, or manual handler registration\n- The `register*` methods provide better type safety, automatic schema handling, and are the recommended approach\n\nSee the MCP SDK documentation in the references for complete details.\n\n## Server Naming Convention\n\nNode/TypeScript MCP servers must follow this naming pattern:\n- **Format**: `{service}-mcp-server` (lowercase with hyphens)\n- **Examples**: `github-mcp-server`, `jira-mcp-server`, `stripe-mcp-server`\n\nThe name should be:\n- General (not tied to specific features)\n- Descriptive of the service/API being integrated\n- Easy to infer from the task description\n- Without version numbers or dates\n\n## Project Structure\n\nCreate the following structure for Node/TypeScript MCP servers:\n\n```\n{service}-mcp-server/\n├── package.json\n├── tsconfig.json\n├── README.md\n├── src/\n│ ├── index.ts # Main entry point with McpServer initialization\n│ ├── types.ts # TypeScript type definitions and interfaces\n│ ├── tools/ # Tool implementations (one file per domain)\n│ ├── services/ # API clients and shared utilities\n│ ├── schemas/ # Zod validation schemas\n│ └── constants.ts # Shared constants (API_URL, CHARACTER_LIMIT, etc.)\n└── dist/ # Built JavaScript files (entry point: dist/index.js)\n```\n\n## Tool Implementation\n\n### Tool Naming\n\nUse snake_case for tool names (e.g., \"search_users\", \"create_project\", \"get_channel_info\") with clear, action-oriented names.\n\n**Avoid Naming Conflicts**: Include the service context to prevent overlaps:\n- Use \"slack_send_message\" instead of just \"send_message\"\n- Use \"github_create_issue\" instead of just \"create_issue\"\n- Use \"asana_list_tasks\" instead of just \"list_tasks\"\n\n### Tool Structure\n\nTools are registered using the `registerTool` method with the following requirements:\n- Use Zod schemas for runtime input validation and type safety\n- The `description` field must be explicitly provided - JSDoc comments are NOT automatically extracted\n- Explicitly provide `title`, `description`, `inputSchema`, and `annotations`\n- The `inputSchema` must be a Zod schema object (not a JSON schema)\n- Type all parameters and return values explicitly\n\n```typescript\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\n\nconst server = new McpServer({\n name: \"example-mcp\",\n version: \"1.0.0\"\n});\n\n// Zod schema for input validation\nconst UserSearchInputSchema = z.object({\n query: z.string()\n .min(2, \"Query must be at least 2 characters\")\n .max(200, \"Query must not exceed 200 characters\")\n .describe(\"Search string to match against names/emails\"),\n limit: z.number()\n .int()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum results to return\"),\n offset: z.number()\n .int()\n .min(0)\n .default(0)\n .describe(\"Number of results to skip for pagination\"),\n response_format: z.nativeEnum(ResponseFormat)\n .default(ResponseFormat.MARKDOWN)\n .describe(\"Output format: 'markdown' for human-readable or 'json' for machine-readable\")\n}).strict();\n\n// Type definition from Zod schema\ntype UserSearchInput = z.infer<typeof UserSearchInputSchema>;\n\nserver.registerTool(\n \"example_search_users\",\n {\n title: \"Search Example Users\",\n description: `Search for users in the Example system by name, email, or team.\n\nThis tool searches across all user profiles in the Example platform, supporting partial matches and various search filters. It does NOT create or modify users, only searches existing ones.\n\nArgs:\n - query (string): Search string to match against names/emails\n - limit (number): Maximum results to return, between 1-100 (default: 20)\n - offset (number): Number of results to skip for pagination (default: 0)\n - response_format ('markdown' | 'json'): Output format (default: 'markdown')\n\nReturns:\n For JSON format: Structured data with schema:\n {\n \"total\": number, // Total number of matches found\n \"count\": number, // Number of results in this response\n \"offset\": number, // Current pagination offset\n \"users\": [\n {\n \"id\": string, // User ID (e.g., \"U123456789\")\n \"name\": string, // Full name (e.g., \"John Doe\")\n \"email\": string, // Email address\n \"team\": string, // Team name (optional)\n \"active\": boolean // Whether user is active\n }\n ],\n \"has_more\": boolean, // Whether more results are available\n \"next_offset\": number // Offset for next page (if has_more is true)\n }\n\nExamples:\n - Use when: \"Find all marketing team members\" -> params with query=\"team:marketing\"\n - Use when: \"Search for John's account\" -> params with query=\"john\"\n - Don't use when: You need to create a user (use example_create_user instead)\n\nError Handling:\n - Returns \"Error: Rate limit exceeded\" if too many requests (429 status)\n - Returns \"No users found matching '<query>'\" if search returns empty`,\n inputSchema: UserSearchInputSchema,\n annotations: {\n readOnlyHint: true,\n destructiveHint: false,\n idempotentHint: true,\n openWorldHint: true\n }\n },\n async (params: UserSearchInput) => {\n try {\n // Input validation is handled by Zod schema\n // Make API request using validated parameters\n const data = await makeApiRequest<any>(\n \"users/search\",\n \"GET\",\n undefined,\n {\n q: params.query,\n limit: params.limit,\n offset: params.offset\n }\n );\n\n const users = data.users || [];\n const total = data.total || 0;\n\n if (!users.length) {\n return {\n content: [{\n type: \"text\",\n text: `No users found matching '${params.query}'`\n }]\n };\n }\n\n // Prepare structured output\n const output = {\n total,\n count: users.length,\n offset: params.offset,\n users: users.map((user: any) => ({\n id: user.id,\n name: user.name,\n email: user.email,\n ...(user.team ? { team: user.team } : {}),\n active: user.active ?? true\n })),\n has_more: total > params.offset + users.length,\n ...(total > params.offset + users.length ? {\n next_offset: params.offset + users.length\n } : {})\n };\n\n // Format text representation based on requested format\n let textContent: string;\n if (params.response_format === ResponseFormat.MARKDOWN) {\n const lines = [`# User Search Results: '${params.query}'`, \"\",\n `Found ${total} users (showing ${users.length})`, \"\"];\n for (const user of users) {\n lines.push(`## ${user.name} (${user.id})`);\n lines.push(`- **Email**: ${user.email}`);\n if (user.team) lines.push(`- **Team**: ${user.team}`);\n lines.push(\"\");\n }\n textContent = lines.join(\"\\n\");\n } else {\n textContent = JSON.stringify(output, null, 2);\n }\n\n return {\n content: [{ type: \"text\", text: textContent }],\n structuredContent: output // Modern pattern for structured data\n };\n } catch (error) {\n return {\n content: [{\n type: \"text\",\n text: handleApiError(error)\n }]\n };\n }\n }\n);\n```\n\n## Zod Schemas for Input Validation\n\nZod provides runtime type validation:\n\n```typescript\nimport { z } from \"zod\";\n\n// Basic schema with validation\nconst CreateUserSchema = z.object({\n name: z.string()\n .min(1, \"Name is required\")\n .max(100, \"Name must not exceed 100 characters\"),\n email: z.string()\n .email(\"Invalid email format\"),\n age: z.number()\n .int(\"Age must be a whole number\")\n .min(0, \"Age cannot be negative\")\n .max(150, \"Age cannot be greater than 150\")\n}).strict(); // Use .strict() to forbid extra fields\n\n// Enums\nenum ResponseFormat {\n MARKDOWN = \"markdown\",\n JSON = \"json\"\n}\n\nconst SearchSchema = z.object({\n response_format: z.nativeEnum(ResponseFormat)\n .default(ResponseFormat.MARKDOWN)\n .describe(\"Output format\")\n});\n\n// Optional fields with defaults\nconst PaginationSchema = z.object({\n limit: z.number()\n .int()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum results to return\"),\n offset: z.number()\n .int()\n .min(0)\n .default(0)\n .describe(\"Number of results to skip\")\n});\n```\n\n## Response Format Options\n\nSupport multiple output formats for flexibility:\n\n```typescript\nenum ResponseFormat {\n MARKDOWN = \"markdown\",\n JSON = \"json\"\n}\n\nconst inputSchema = z.object({\n query: z.string(),\n response_format: z.nativeEnum(ResponseFormat)\n .default(ResponseFormat.MARKDOWN)\n .describe(\"Output format: 'markdown' for human-readable or 'json' for machine-readable\")\n});\n```\n\n**Markdown format**:\n- Use headers, lists, and formatting for clarity\n- Convert timestamps to human-readable format\n- Show display names with IDs in parentheses\n- Omit verbose metadata\n- Group related information logically\n\n**JSON format**:\n- Return complete, structured data suitable for programmatic processing\n- Include all available fields and metadata\n- Use consistent field names and types\n\n## Pagination Implementation\n\nFor tools that list resources:\n\n```typescript\nconst ListSchema = z.object({\n limit: z.number().int().min(1).max(100).default(20),\n offset: z.number().int().min(0).default(0)\n});\n\nasync function listItems(params: z.infer<typeof ListSchema>) {\n const data = await apiRequest(params.limit, params.offset);\n\n const response = {\n total: data.total,\n count: data.items.length,\n offset: params.offset,\n items: data.items,\n has_more: data.total > params.offset + data.items.length,\n next_offset: data.total > params.offset + data.items.length\n ? params.offset + data.items.length\n : undefined\n };\n\n return JSON.stringify(response, null, 2);\n}\n```\n\n## Character Limits and Truncation\n\nAdd a CHARACTER_LIMIT constant to prevent overwhelming responses:\n\n```typescript\n// At module level in constants.ts\nexport const CHARACTER_LIMIT = 25000; // Maximum response size in characters\n\nasync function searchTool(params: SearchInput) {\n let result = generateResponse(data);\n\n // Check character limit and truncate if needed\n if (result.length > CHARACTER_LIMIT) {\n const truncatedData = data.slice(0, Math.max(1, data.length / 2));\n response.data = truncatedData;\n response.truncated = true;\n response.truncation_message =\n `Response truncated from ${data.length} to ${truncatedData.length} items. ` +\n `Use 'offset' parameter or add filters to see more results.`;\n result = JSON.stringify(response, null, 2);\n }\n\n return result;\n}\n```\n\n## Error Handling\n\nProvide clear, actionable error messages:\n\n```typescript\nimport axios, { AxiosError } from \"axios\";\n\nfunction handleApiError(error: unknown): string {\n if (error instanceof AxiosError) {\n if (error.response) {\n switch (error.response.status) {\n case 404:\n return \"Error: Resource not found. Please check the ID is correct.\";\n case 403:\n return \"Error: Permission denied. You don't have access to this resource.\";\n case 429:\n return \"Error: Rate limit exceeded. Please wait before making more requests.\";\n default:\n return `Error: API request failed with status ${error.response.status}`;\n }\n } else if (error.code === \"ECONNABORTED\") {\n return \"Error: Request timed out. Please try again.\";\n }\n }\n return `Error: Unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`;\n}\n```\n\n## Shared Utilities\n\nExtract common functionality into reusable functions:\n\n```typescript\n// Shared API request function\nasync function makeApiRequest<T>(\n endpoint: string,\n method: \"GET\" | \"POST\" | \"PUT\" | \"DELETE\" = \"GET\",\n data?: any,\n params?: any\n): Promise<T> {\n try {\n const response = await axios({\n method,\n url: `${API_BASE_URL}/${endpoint}`,\n data,\n params,\n timeout: 30000,\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"application/json\"\n }\n });\n return response.data;\n } catch (error) {\n throw error;\n }\n}\n```\n\n## Async/Await Best Practices\n\nAlways use async/await for network requests and I/O operations:\n\n```typescript\n// Good: Async network request\nasync function fetchData(resourceId: string): Promise<ResourceData> {\n const response = await axios.get(`${API_URL}/resource/${resourceId}`);\n return response.data;\n}\n\n// Bad: Promise chains\nfunction fetchData(resourceId: string): Promise<ResourceData> {\n return axios.get(`${API_URL}/resource/${resourceId}`)\n .then(response => response.data); // Harder to read and maintain\n}\n```\n\n## TypeScript Best Practices\n\n1. **Use Strict TypeScript**: Enable strict mode in tsconfig.json\n2. **Define Interfaces**: Create clear interface definitions for all data structures\n3. **Avoid `any`**: Use proper types or `unknown` instead of `any`\n4. **Zod for Runtime Validation**: Use Zod schemas to validate external data\n5. **Type Guards**: Create type guard functions for complex type checking\n6. **Error Handling**: Always use try-catch with proper error type checking\n7. **Null Safety**: Use optional chaining (`?.`) and nullish coalescing (`??`)\n\n```typescript\n// Good: Type-safe with Zod and interfaces\ninterface UserResponse {\n id: string;\n name: string;\n email: string;\n team?: string;\n active: boolean;\n}\n\nconst UserSchema = z.object({\n id: z.string(),\n name: z.string(),\n email: z.string().email(),\n team: z.string().optional(),\n active: z.boolean()\n});\n\ntype User = z.infer<typeof UserSchema>;\n\nasync function getUser(id: string): Promise<User> {\n const data = await apiCall(`/users/${id}`);\n return UserSchema.parse(data); // Runtime validation\n}\n\n// Bad: Using any\nasync function getUser(id: string): Promise<any> {\n return await apiCall(`/users/${id}`); // No type safety\n}\n```\n\n## Package Configuration\n\n### package.json\n\n```json\n{\n \"name\": \"{service}-mcp-server\",\n \"version\": \"1.0.0\",\n \"description\": \"MCP server for {Service} API integration\",\n \"type\": \"module\",\n \"main\": \"dist/index.js\",\n \"scripts\": {\n \"start\": \"node dist/index.js\",\n \"dev\": \"tsx watch src/index.ts\",\n \"build\": \"tsc\",\n \"clean\": \"rm -rf dist\"\n },\n \"engines\": {\n \"node\": \">=18\"\n },\n \"dependencies\": {\n \"@modelcontextprotocol/sdk\": \"^1.6.1\",\n \"axios\": \"^1.7.9\",\n \"zod\": \"^3.23.8\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^22.10.0\",\n \"tsx\": \"^4.19.2\",\n \"typescript\": \"^5.7.2\"\n }\n}\n```\n\n### tsconfig.json\n\n```json\n{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"Node16\",\n \"moduleResolution\": \"Node16\",\n \"lib\": [\"ES2022\"],\n \"outDir\": \"./dist\",\n \"rootDir\": \"./src\",\n \"strict\": true,\n \"esModuleInterop\": true,\n \"skipLibCheck\": true,\n \"forceConsistentCasingInFileNames\": true,\n \"declaration\": true,\n \"declarationMap\": true,\n \"sourceMap\": true,\n \"allowSyntheticDefaultImports\": true\n },\n \"include\": [\"src/**/*\"],\n \"exclude\": [\"node_modules\", \"dist\"]\n}\n```\n\n## Complete Example\n\n```typescript\n#!/usr/bin/env node\n/**\n * MCP Server for Example Service.\n *\n * This server provides tools to interact with Example API, including user search,\n * project management, and data export capabilities.\n */\n\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { z } from \"zod\";\nimport axios, { AxiosError } from \"axios\";\n\n// Constants\nconst API_BASE_URL = \"https://api.example.com/v1\";\nconst CHARACTER_LIMIT = 25000;\n\n// Enums\nenum ResponseFormat {\n MARKDOWN = \"markdown\",\n JSON = \"json\"\n}\n\n// Zod schemas\nconst UserSearchInputSchema = z.object({\n query: z.string()\n .min(2, \"Query must be at least 2 characters\")\n .max(200, \"Query must not exceed 200 characters\")\n .describe(\"Search string to match against names/emails\"),\n limit: z.number()\n .int()\n .min(1)\n .max(100)\n .default(20)\n .describe(\"Maximum results to return\"),\n offset: z.number()\n .int()\n .min(0)\n .default(0)\n .describe(\"Number of results to skip for pagination\"),\n response_format: z.nativeEnum(ResponseFormat)\n .default(ResponseFormat.MARKDOWN)\n .describe(\"Output format: 'markdown' for human-readable or 'json' for machine-readable\")\n}).strict();\n\ntype UserSearchInput = z.infer<typeof UserSearchInputSchema>;\n\n// Shared utility functions\nasync function makeApiRequest<T>(\n endpoint: string,\n method: \"GET\" | \"POST\" | \"PUT\" | \"DELETE\" = \"GET\",\n data?: any,\n params?: any\n): Promise<T> {\n try {\n const response = await axios({\n method,\n url: `${API_BASE_URL}/${endpoint}`,\n data,\n params,\n timeout: 30000,\n headers: {\n \"Content-Type\": \"application/json\",\n \"Accept\": \"application/json\"\n }\n });\n return response.data;\n } catch (error) {\n throw error;\n }\n}\n\nfunction handleApiError(error: unknown): string {\n if (error instanceof AxiosError) {\n if (error.response) {\n switch (error.response.status) {\n case 404:\n return \"Error: Resource not found. Please check the ID is correct.\";\n case 403:\n return \"Error: Permission denied. You don't have access to this resource.\";\n case 429:\n return \"Error: Rate limit exceeded. Please wait before making more requests.\";\n default:\n return `Error: API request failed with status ${error.response.status}`;\n }\n } else if (error.code === \"ECONNABORTED\") {\n return \"Error: Request timed out. Please try again.\";\n }\n }\n return `Error: Unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`;\n}\n\n// Create MCP server instance\nconst server = new McpServer({\n name: \"example-mcp\",\n version: \"1.0.0\"\n});\n\n// Register tools\nserver.registerTool(\n \"example_search_users\",\n {\n title: \"Search Example Users\",\n description: `[Full description as shown above]`,\n inputSchema: UserSearchInputSchema,\n annotations: {\n readOnlyHint: true,\n destructiveHint: false,\n idempotentHint: true,\n openWorldHint: true\n }\n },\n async (params: UserSearchInput) => {\n // Implementation as shown above\n }\n);\n\n// Main function\n// For stdio (local):\nasync function runStdio() {\n if (!process.env.EXAMPLE_API_KEY) {\n console.error(\"ERROR: EXAMPLE_API_KEY environment variable is required\");\n process.exit(1);\n }\n\n const transport = new StdioServerTransport();\n await server.connect(transport);\n console.error(\"MCP server running via stdio\");\n}\n\n// For streamable HTTP (remote):\nasync function runHTTP() {\n if (!process.env.EXAMPLE_API_KEY) {\n console.error(\"ERROR: EXAMPLE_API_KEY environment variable is required\");\n process.exit(1);\n }\n\n const app = express();\n app.use(express.json());\n\n app.post('/mcp', async (req, res) => {\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: undefined,\n enableJsonResponse: true\n });\n res.on('close', () => transport.close());\n await server.connect(transport);\n await transport.handleRequest(req, res, req.body);\n });\n\n const port = parseInt(process.env.PORT || '3000');\n app.listen(port, () => {\n console.error(`MCP server running on http://localhost:${port}/mcp`);\n });\n}\n\n// Choose transport based on environment\nconst transport = process.env.TRANSPORT || 'stdio';\nif (transport === 'http') {\n runHTTP().catch(error => {\n console.error(\"Server error:\", error);\n process.exit(1);\n });\n} else {\n runStdio().catch(error => {\n console.error(\"Server error:\", error);\n process.exit(1);\n });\n}\n```\n\n---\n\n## Advanced MCP Features\n\n### Resource Registration\n\nExpose data as resources for efficient, URI-based access:\n\n```typescript\nimport { ResourceTemplate } from \"@modelcontextprotocol/sdk/types.js\";\n\n// Register a resource with URI template\nserver.registerResource(\n {\n uri: \"file://documents/{name}\",\n name: \"Document Resource\",\n description: \"Access documents by name\",\n mimeType: \"text/plain\"\n },\n async (uri: string) => {\n // Extract parameter from URI\n const match = uri.match(/^file:\\/\\/documents\\/(.+)$/);\n if (!match) {\n throw new Error(\"Invalid URI format\");\n }\n\n const documentName = match[1];\n const content = await loadDocument(documentName);\n\n return {\n contents: [{\n uri,\n mimeType: \"text/plain\",\n text: content\n }]\n };\n }\n);\n\n// List available resources dynamically\nserver.registerResourceList(async () => {\n const documents = await getAvailableDocuments();\n return {\n resources: documents.map(doc => ({\n uri: `file://documents/${doc.name}`,\n name: doc.name,\n mimeType: \"text/plain\",\n description: doc.description\n }))\n };\n});\n```\n\n**When to use Resources vs Tools:**\n- **Resources**: For data access with simple URI-based parameters\n- **Tools**: For complex operations requiring validation and business logic\n- **Resources**: When data is relatively static or template-based\n- **Tools**: When operations have side effects or complex workflows\n\n### Transport Options\n\nThe TypeScript SDK supports two main transport mechanisms:\n\n#### Streamable HTTP (Recommended for Remote Servers)\n\n```typescript\nimport { StreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/streamableHttp.js\";\nimport express from \"express\";\n\nconst app = express();\napp.use(express.json());\n\napp.post('/mcp', async (req, res) => {\n // Create new transport for each request (stateless, prevents request ID collisions)\n const transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: undefined,\n enableJsonResponse: true\n });\n\n res.on('close', () => transport.close());\n\n await server.connect(transport);\n await transport.handleRequest(req, res, req.body);\n});\n\napp.listen(3000);\n```\n\n#### stdio (For Local Integrations)\n\n```typescript\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\n\nconst transport = new StdioServerTransport();\nawait server.connect(transport);\n```\n\n**Transport selection:**\n- **Streamable HTTP**: Web services, remote access, multiple clients\n- **stdio**: Command-line tools, local development, subprocess integration\n\n### Notification Support\n\nNotify clients when server state changes:\n\n```typescript\n// Notify when tools list changes\nserver.notification({\n method: \"notifications/tools/list_changed\"\n});\n\n// Notify when resources change\nserver.notification({\n method: \"notifications/resources/list_changed\"\n});\n```\n\nUse notifications sparingly - only when server capabilities genuinely change.\n\n---\n\n## Code Best Practices\n\n### Code Composability and Reusability\n\nYour implementation MUST prioritize composability and code reuse:\n\n1. **Extract Common Functionality**:\n - Create reusable helper functions for operations used across multiple tools\n - Build shared API clients for HTTP requests instead of duplicating code\n - Centralize error handling logic in utility functions\n - Extract business logic into dedicated functions that can be composed\n - Extract shared markdown or JSON field selection & formatting functionality\n\n2. **Avoid Duplication**:\n - NEVER copy-paste similar code between tools\n - If you find yourself writing similar logic twice, extract it into a function\n - Common operations like pagination, filtering, field selection, and formatting should be shared\n - Authentication/authorization logic should be centralized\n\n## Building and Running\n\nAlways build your TypeScript code before running:\n\n```bash\n# Build the project\nnpm run build\n\n# Run the server\nnpm start\n\n# Development with auto-reload\nnpm run dev\n```\n\nAlways ensure `npm run build` completes successfully before considering the implementation complete.\n\n## Quality Checklist\n\nBefore finalizing your Node/TypeScript MCP server implementation, ensure:\n\n### Strategic Design\n- [ ] Tools enable complete workflows, not just API endpoint wrappers\n- [ ] Tool names reflect natural task subdivisions\n- [ ] Response formats optimize for agent context efficiency\n- [ ] Human-readable identifiers used where appropriate\n- [ ] Error messages guide agents toward correct usage\n\n### Implementation Quality\n- [ ] FOCUSED IMPLEMENTATION: Most important and valuable tools implemented\n- [ ] All tools registered using `registerTool` with complete configuration\n- [ ] All tools include `title`, `description`, `inputSchema`, and `annotations`\n- [ ] Annotations correctly set (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)\n- [ ] All tools use Zod schemas for runtime input validation with `.strict()` enforcement\n- [ ] All Zod schemas have proper constraints and descriptive error messages\n- [ ] All tools have comprehensive descriptions with explicit input/output types\n- [ ] Descriptions include return value examples and complete schema documentation\n- [ ] Error messages are clear, actionable, and educational\n\n### TypeScript Quality\n- [ ] TypeScript interfaces are defined for all data structures\n- [ ] Strict TypeScript is enabled in tsconfig.json\n- [ ] No use of `any` type - use `unknown` or proper types instead\n- [ ] All async functions have explicit Promise<T> return types\n- [ ] Error handling uses proper type guards (e.g., `axios.isAxiosError`, `z.ZodError`)\n\n### Advanced Features (where applicable)\n- [ ] Resources registered for appropriate data endpoints\n- [ ] Appropriate transport configured (stdio or streamable HTTP)\n- [ ] Notifications implemented for dynamic server capabilities\n- [ ] Type-safe with SDK interfaces\n\n### Project Configuration\n- [ ] Package.json includes all necessary dependencies\n- [ ] Build script produces working JavaScript in dist/ directory\n- [ ] Main entry point is properly configured as dist/index.js\n- [ ] Server name follows format: `{service}-mcp-server`\n- [ ] tsconfig.json properly configured with strict mode\n\n### Code Quality\n- [ ] Pagination is properly implemented where applicable\n- [ ] Large responses check CHARACTER_LIMIT constant and truncate with clear messages\n- [ ] Filtering options are provided for potentially large result sets\n- [ ] All network operations handle timeouts and connection errors gracefully\n- [ ] Common functionality is extracted into reusable functions\n- [ ] Return types are consistent across similar operations\n\n### Testing and Build\n- [ ] `npm run build` completes successfully without errors\n- [ ] dist/index.js created and executable\n- [ ] Server runs: `node dist/index.js --help`\n- [ ] All imports resolve correctly\n- [ ] Sample tool calls work as expected"}],"isError":false,"timestamp":1781060900461}}
|
||
{"type":"message","id":"f7eb30ae","parentId":"fc49faa9","timestamp":"2026-06-10T03:08:20.463Z","message":{"role":"toolResult","toolCallId":"functions.read:13","toolName":"read","content":[{"type":"text","text":"# Python MCP Server Implementation Guide\n\n## Overview\n\nThis document provides Python-specific best practices and examples for implementing MCP servers using the MCP Python SDK. It covers server setup, tool registration patterns, input validation with Pydantic, error handling, and complete working examples.\n\n---\n\n## Quick Reference\n\n### Key Imports\n```python\nfrom mcp.server.fastmcp import FastMCP\nfrom pydantic import BaseModel, Field, field_validator, ConfigDict\nfrom typing import Optional, List, Dict, Any\nfrom enum import Enum\nimport httpx\n```\n\n### Server Initialization\n```python\nmcp = FastMCP(\"service_mcp\")\n```\n\n### Tool Registration Pattern\n```python\n@mcp.tool(name=\"tool_name\", annotations={...})\nasync def tool_function(params: InputModel) -> str:\n # Implementation\n pass\n```\n\n---\n\n## MCP Python SDK and FastMCP\n\nThe official MCP Python SDK provides FastMCP, a high-level framework for building MCP servers. It provides:\n- Automatic description and inputSchema generation from function signatures and docstrings\n- Pydantic model integration for input validation\n- Decorator-based tool registration with `@mcp.tool`\n\n**For complete SDK documentation, use WebFetch to load:**\n`https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md`\n\n## Server Naming Convention\n\nPython MCP servers must follow this naming pattern:\n- **Format**: `{service}_mcp` (lowercase with underscores)\n- **Examples**: `github_mcp`, `jira_mcp`, `stripe_mcp`\n\nThe name should be:\n- General (not tied to specific features)\n- Descriptive of the service/API being integrated\n- Easy to infer from the task description\n- Without version numbers or dates\n\n## Tool Implementation\n\n### Tool Naming\n\nUse snake_case for tool names (e.g., \"search_users\", \"create_project\", \"get_channel_info\") with clear, action-oriented names.\n\n**Avoid Naming Conflicts**: Include the service context to prevent overlaps:\n- Use \"slack_send_message\" instead of just \"send_message\"\n- Use \"github_create_issue\" instead of just \"create_issue\"\n- Use \"asana_list_tasks\" instead of just \"list_tasks\"\n\n### Tool Structure with FastMCP\n\nTools are defined using the `@mcp.tool` decorator with Pydantic models for input validation:\n\n```python\nfrom pydantic import BaseModel, Field, ConfigDict\nfrom mcp.server.fastmcp import FastMCP\n\n# Initialize the MCP server\nmcp = FastMCP(\"example_mcp\")\n\n# Define Pydantic model for input validation\nclass ServiceToolInput(BaseModel):\n '''Input model for service tool operation.'''\n model_config = ConfigDict(\n str_strip_whitespace=True, # Auto-strip whitespace from strings\n validate_assignment=True, # Validate on assignment\n extra='forbid' # Forbid extra fields\n )\n\n param1: str = Field(..., description=\"First parameter description (e.g., 'user123', 'project-abc')\", min_length=1, max_length=100)\n param2: Optional[int] = Field(default=None, description=\"Optional integer parameter with constraints\", ge=0, le=1000)\n tags: Optional[List[str]] = Field(default_factory=list, description=\"List of tags to apply\", max_items=10)\n\n@mcp.tool(\n name=\"service_tool_name\",\n annotations={\n \"title\": \"Human-Readable Tool Title\",\n \"readOnlyHint\": True, # Tool does not modify environment\n \"destructiveHint\": False, # Tool does not perform destructive operations\n \"idempotentHint\": True, # Repeated calls have no additional effect\n \"openWorldHint\": False # Tool does not interact with external entities\n }\n)\nasync def service_tool_name(params: ServiceToolInput) -> str:\n '''Tool description automatically becomes the 'description' field.\n\n This tool performs a specific operation on the service. It validates all inputs\n using the ServiceToolInput Pydantic model before processing.\n\n Args:\n params (ServiceToolInput): Validated input parameters containing:\n - param1 (str): First parameter description\n - param2 (Optional[int]): Optional parameter with default\n - tags (Optional[List[str]]): List of tags\n\n Returns:\n str: JSON-formatted response containing operation results\n '''\n # Implementation here\n pass\n```\n\n## Pydantic v2 Key Features\n\n- Use `model_config` instead of nested `Config` class\n- Use `field_validator` instead of deprecated `validator`\n- Use `model_dump()` instead of deprecated `dict()`\n- Validators require `@classmethod` decorator\n- Type hints are required for validator methods\n\n```python\nfrom pydantic import BaseModel, Field, field_validator, ConfigDict\n\nclass CreateUserInput(BaseModel):\n model_config = ConfigDict(\n str_strip_whitespace=True,\n validate_assignment=True\n )\n\n name: str = Field(..., description=\"User's full name\", min_length=1, max_length=100)\n email: str = Field(..., description=\"User's email address\", pattern=r'^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$')\n age: int = Field(..., description=\"User's age\", ge=0, le=150)\n\n @field_validator('email')\n @classmethod\n def validate_email(cls, v: str) -> str:\n if not v.strip():\n raise ValueError(\"Email cannot be empty\")\n return v.lower()\n```\n\n## Response Format Options\n\nSupport multiple output formats for flexibility:\n\n```python\nfrom enum import Enum\n\nclass ResponseFormat(str, Enum):\n '''Output format for tool responses.'''\n MARKDOWN = \"markdown\"\n JSON = \"json\"\n\nclass UserSearchInput(BaseModel):\n query: str = Field(..., description=\"Search query\")\n response_format: ResponseFormat = Field(\n default=ResponseFormat.MARKDOWN,\n description=\"Output format: 'markdown' for human-readable or 'json' for machine-readable\"\n )\n```\n\n**Markdown format**:\n- Use headers, lists, and formatting for clarity\n- Convert timestamps to human-readable format (e.g., \"2024-01-15 10:30:00 UTC\" instead of epoch)\n- Show display names with IDs in parentheses (e.g., \"@john.doe (U123456)\")\n- Omit verbose metadata (e.g., show only one profile image URL, not all sizes)\n- Group related information logically\n\n**JSON format**:\n- Return complete, structured data suitable for programmatic processing\n- Include all available fields and metadata\n- Use consistent field names and types\n\n## Pagination Implementation\n\nFor tools that list resources:\n\n```python\nclass ListInput(BaseModel):\n limit: Optional[int] = Field(default=20, description=\"Maximum results to return\", ge=1, le=100)\n offset: Optional[int] = Field(default=0, description=\"Number of results to skip for pagination\", ge=0)\n\nasync def list_items(params: ListInput) -> str:\n # Make API request with pagination\n data = await api_request(limit=params.limit, offset=params.offset)\n\n # Return pagination info\n response = {\n \"total\": data[\"total\"],\n \"count\": len(data[\"items\"]),\n \"offset\": params.offset,\n \"items\": data[\"items\"],\n \"has_more\": data[\"total\"] > params.offset + len(data[\"items\"]),\n \"next_offset\": params.offset + len(data[\"items\"]) if data[\"total\"] > params.offset + len(data[\"items\"]) else None\n }\n return json.dumps(response, indent=2)\n```\n\n## Error Handling\n\nProvide clear, actionable error messages:\n\n```python\ndef _handle_api_error(e: Exception) -> str:\n '''Consistent error formatting across all tools.'''\n if isinstance(e, httpx.HTTPStatusError):\n if e.response.status_code == 404:\n return \"Error: Resource not found. Please check the ID is correct.\"\n elif e.response.status_code == 403:\n return \"Error: Permission denied. You don't have access to this resource.\"\n elif e.response.status_code == 429:\n return \"Error: Rate limit exceeded. Please wait before making more requests.\"\n return f\"Error: API request failed with status {e.response.status_code}\"\n elif isinstance(e, httpx.TimeoutException):\n return \"Error: Request timed out. Please try again.\"\n return f\"Error: Unexpected error occurred: {type(e).__name__}\"\n```\n\n## Shared Utilities\n\nExtract common functionality into reusable functions:\n\n```python\n# Shared API request function\nasync def _make_api_request(endpoint: str, method: str = \"GET\", **kwargs) -> dict:\n '''Reusable function for all API calls.'''\n async with httpx.AsyncClient() as client:\n response = await client.request(\n method,\n f\"{API_BASE_URL}/{endpoint}\",\n timeout=30.0,\n **kwargs\n )\n response.raise_for_status()\n return response.json()\n```\n\n## Async/Await Best Practices\n\nAlways use async/await for network requests and I/O operations:\n\n```python\n# Good: Async network request\nasync def fetch_data(resource_id: str) -> dict:\n async with httpx.AsyncClient() as client:\n response = await client.get(f\"{API_URL}/resource/{resource_id}\")\n response.raise_for_status()\n return response.json()\n\n# Bad: Synchronous request\ndef fetch_data(resource_id: str) -> dict:\n response = requests.get(f\"{API_URL}/resource/{resource_id}\") # Blocks\n return response.json()\n```\n\n## Type Hints\n\nUse type hints throughout:\n\n```python\nfrom typing import Optional, List, Dict, Any\n\nasync def get_user(user_id: str) -> Dict[str, Any]:\n data = await fetch_user(user_id)\n return {\"id\": data[\"id\"], \"name\": data[\"name\"]}\n```\n\n## Tool Docstrings\n\nEvery tool must have comprehensive docstrings with explicit type information:\n\n```python\nasync def search_users(params: UserSearchInput) -> str:\n '''\n Search for users in the Example system by name, email, or team.\n\n This tool searches across all user profiles in the Example platform,\n supporting partial matches and various search filters. It does NOT\n create or modify users, only searches existing ones.\n\n Args:\n params (UserSearchInput): Validated input parameters containing:\n - query (str): Search string to match against names/emails (e.g., \"john\", \"@example.com\", \"team:marketing\")\n - limit (Optional[int]): Maximum results to return, between 1-100 (default: 20)\n - offset (Optional[int]): Number of results to skip for pagination (default: 0)\n\n Returns:\n str: JSON-formatted string containing search results with the following schema:\n\n Success response:\n {\n \"total\": int, # Total number of matches found\n \"count\": int, # Number of results in this response\n \"offset\": int, # Current pagination offset\n \"users\": [\n {\n \"id\": str, # User ID (e.g., \"U123456789\")\n \"name\": str, # Full name (e.g., \"John Doe\")\n \"email\": str, # Email address (e.g., \"john@example.com\")\n \"team\": str # Team name (e.g., \"Marketing\") - optional\n }\n ]\n }\n\n Error response:\n \"Error: <error message>\" or \"No users found matching '<query>'\"\n\n Examples:\n - Use when: \"Find all marketing team members\" -> params with query=\"team:marketing\"\n - Use when: \"Search for John's account\" -> params with query=\"john\"\n - Don't use when: You need to create a user (use example_create_user instead)\n - Don't use when: You have a user ID and need full details (use example_get_user instead)\n\n Error Handling:\n - Input validation errors are handled by Pydantic model\n - Returns \"Error: Rate limit exceeded\" if too many requests (429 status)\n - Returns \"Error: Invalid API authentication\" if API key is invalid (401 status)\n - Returns formatted list of results or \"No users found matching 'query'\"\n '''\n```\n\n## Complete Example\n\nSee below for a complete Python MCP server example:\n\n```python\n#!/usr/bin/env python3\n'''\nMCP Server for Example Service.\n\nThis server provides tools to interact with Example API, including user search,\nproject management, and data export capabilities.\n'''\n\nfrom typing import Optional, List, Dict, Any\nfrom enum import Enum\nimport httpx\nfrom pydantic import BaseModel, Field, field_validator, ConfigDict\nfrom mcp.server.fastmcp import FastMCP\n\n# Initialize the MCP server\nmcp = FastMCP(\"example_mcp\")\n\n# Constants\nAPI_BASE_URL = \"https://api.example.com/v1\"\n\n# Enums\nclass ResponseFormat(str, Enum):\n '''Output format for tool responses.'''\n MARKDOWN = \"markdown\"\n JSON = \"json\"\n\n# Pydantic Models for Input Validation\nclass UserSearchInput(BaseModel):\n '''Input model for user search operations.'''\n model_config = ConfigDict(\n str_strip_whitespace=True,\n validate_assignment=True\n )\n\n query: str = Field(..., description=\"Search string to match against names/emails\", min_length=2, max_length=200)\n limit: Optional[int] = Field(default=20, description=\"Maximum results to return\", ge=1, le=100)\n offset: Optional[int] = Field(default=0, description=\"Number of results to skip for pagination\", ge=0)\n response_format: ResponseFormat = Field(default=ResponseFormat.MARKDOWN, description=\"Output format\")\n\n @field_validator('query')\n @classmethod\n def validate_query(cls, v: str) -> str:\n if not v.strip():\n raise ValueError(\"Query cannot be empty or whitespace only\")\n return v.strip()\n\n# Shared utility functions\nasync def _make_api_request(endpoint: str, method: str = \"GET\", **kwargs) -> dict:\n '''Reusable function for all API calls.'''\n async with httpx.AsyncClient() as client:\n response = await client.request(\n method,\n f\"{API_BASE_URL}/{endpoint}\",\n timeout=30.0,\n **kwargs\n )\n response.raise_for_status()\n return response.json()\n\ndef _handle_api_error(e: Exception) -> str:\n '''Consistent error formatting across all tools.'''\n if isinstance(e, httpx.HTTPStatusError):\n if e.response.status_code == 404:\n return \"Error: Resource not found. Please check the ID is correct.\"\n elif e.response.status_code == 403:\n return \"Error: Permission denied. You don't have access to this resource.\"\n elif e.response.status_code == 429:\n return \"Error: Rate limit exceeded. Please wait before making more requests.\"\n return f\"Error: API request failed with status {e.response.status_code}\"\n elif isinstance(e, httpx.TimeoutException):\n return \"Error: Request timed out. Please try again.\"\n return f\"Error: Unexpected error occurred: {type(e).__name__}\"\n\n# Tool definitions\n@mcp.tool(\n name=\"example_search_users\",\n annotations={\n \"title\": \"Search Example Users\",\n \"readOnlyHint\": True,\n \"destructiveHint\": False,\n \"idempotentHint\": True,\n \"openWorldHint\": True\n }\n)\nasync def example_search_users(params: UserSearchInput) -> str:\n '''Search for users in the Example system by name, email, or team.\n\n [Full docstring as shown above]\n '''\n try:\n # Make API request using validated parameters\n data = await _make_api_request(\n \"users/search\",\n params={\n \"q\": params.query,\n \"limit\": params.limit,\n \"offset\": params.offset\n }\n )\n\n users = data.get(\"users\", [])\n total = data.get(\"total\", 0)\n\n if not users:\n return f\"No users found matching '{params.query}'\"\n\n # Format response based on requested format\n if params.response_format == ResponseFormat.MARKDOWN:\n lines = [f\"# User Search Results: '{params.query}'\", \"\"]\n lines.append(f\"Found {total} users (showing {len(users)})\")\n lines.append(\"\")\n\n for user in users:\n lines.append(f\"## {user['name']} ({user['id']})\")\n lines.append(f\"- **Email**: {user['email']}\")\n if user.get('team'):\n lines.append(f\"- **Team**: {user['team']}\")\n lines.append(\"\")\n\n return \"\\n\".join(lines)\n\n else:\n # Machine-readable JSON format\n import json\n response = {\n \"total\": total,\n \"count\": len(users),\n \"offset\": params.offset,\n \"users\": users\n }\n return json.dumps(response, indent=2)\n\n except Exception as e:\n return _handle_api_error(e)\n\nif __name__ == \"__main__\":\n mcp.run()\n```\n\n---\n\n## Advanced FastMCP Features\n\n### Context Parameter Injection\n\nFastMCP can automatically inject a `Context` parameter into tools for advanced capabilities like logging, progress reporting, resource reading, and user interaction:\n\n```python\nfrom mcp.server.fastmcp import FastMCP, Context\n\nmcp = FastMCP(\"example_mcp\")\n\n@mcp.tool()\nasync def advanced_search(query: str, ctx: Context) -> str:\n '''Advanced tool with context access for logging and progress.'''\n\n # Report progress for long operations\n await ctx.report_progress(0.25, \"Starting search...\")\n\n # Log information for debugging\n await ctx.log_info(\"Processing query\", {\"query\": query, \"timestamp\": datetime.now()})\n\n # Perform search\n results = await search_api(query)\n await ctx.report_progress(0.75, \"Formatting results...\")\n\n # Access server configuration\n server_name = ctx.fastmcp.name\n\n return format_results(results)\n\n@mcp.tool()\nasync def interactive_tool(resource_id: str, ctx: Context) -> str:\n '''Tool that can request additional input from users.'''\n\n # Request sensitive information when needed\n api_key = await ctx.elicit(\n prompt=\"Please provide your API key:\",\n input_type=\"password\"\n )\n\n # Use the provided key\n return await api_call(resource_id, api_key)\n```\n\n**Context capabilities:**\n- `ctx.report_progress(progress, message)` - Report progress for long operations\n- `ctx.log_info(message, data)` / `ctx.log_error()` / `ctx.log_debug()` - Logging\n- `ctx.elicit(prompt, input_type)` - Request input from users\n- `ctx.fastmcp.name` - Access server configuration\n- `ctx.read_resource(uri)` - Read MCP resources\n\n### Resource Registration\n\nExpose data as resources for efficient, template-based access:\n\n```python\n@mcp.resource(\"file://documents/{name}\")\nasync def get_document(name: str) -> str:\n '''Expose documents as MCP resources.\n\n Resources are useful for static or semi-static data that doesn't\n require complex parameters. They use URI templates for flexible access.\n '''\n document_path = f\"./docs/{name}\"\n with open(document_path, \"r\") as f:\n return f.read()\n\n@mcp.resource(\"config://settings/{key}\")\nasync def get_setting(key: str, ctx: Context) -> str:\n '''Expose configuration as resources with context.'''\n settings = await load_settings()\n return json.dumps(settings.get(key, {}))\n```\n\n**When to use Resources vs Tools:**\n- **Resources**: For data access with simple parameters (URI templates)\n- **Tools**: For complex operations with validation and business logic\n\n### Structured Output Types\n\nFastMCP supports multiple return types beyond strings:\n\n```python\nfrom typing import TypedDict\nfrom dataclasses import dataclass\nfrom pydantic import BaseModel\n\n# TypedDict for structured returns\nclass UserData(TypedDict):\n id: str\n name: str\n email: str\n\n@mcp.tool()\nasync def get_user_typed(user_id: str) -> UserData:\n '''Returns structured data - FastMCP handles serialization.'''\n return {\"id\": user_id, \"name\": \"John Doe\", \"email\": \"john@example.com\"}\n\n# Pydantic models for complex validation\nclass DetailedUser(BaseModel):\n id: str\n name: str\n email: str\n created_at: datetime\n metadata: Dict[str, Any]\n\n@mcp.tool()\nasync def get_user_detailed(user_id: str) -> DetailedUser:\n '''Returns Pydantic model - automatically generates schema.'''\n user = await fetch_user(user_id)\n return DetailedUser(**user)\n```\n\n### Lifespan Management\n\nInitialize resources that persist across requests:\n\n```python\nfrom contextlib import asynccontextmanager\n\n@asynccontextmanager\nasync def app_lifespan():\n '''Manage resources that live for the server's lifetime.'''\n # Initialize connections, load config, etc.\n db = await connect_to_database()\n config = load_configuration()\n\n # Make available to all tools\n yield {\"db\": db, \"config\": config}\n\n # Cleanup on shutdown\n await db.close()\n\nmcp = FastMCP(\"example_mcp\", lifespan=app_lifespan)\n\n@mcp.tool()\nasync def query_data(query: str, ctx: Context) -> str:\n '''Access lifespan resources through context.'''\n db = ctx.request_context.lifespan_state[\"db\"]\n results = await db.query(query)\n return format_results(results)\n```\n\n### Transport Options\n\nFastMCP supports two main transport mechanisms:\n\n```python\n# stdio transport (for local tools) - default\nif __name__ == \"__main__\":\n mcp.run()\n\n# Streamable HTTP transport (for remote servers)\nif __name__ == \"__main__\":\n mcp.run(transport=\"streamable_http\", port=8000)\n```\n\n**Transport selection:**\n- **stdio**: Command-line tools, local integrations, subprocess execution\n- **Streamable HTTP**: Web services, remote access, multiple clients\n\n---\n\n## Code Best Practices\n\n### Code Composability and Reusability\n\nYour implementation MUST prioritize composability and code reuse:\n\n1. **Extract Common Functionality**:\n - Create reusable helper functions for operations used across multiple tools\n - Build shared API clients for HTTP requests instead of duplicating code\n - Centralize error handling logic in utility functions\n - Extract business logic into dedicated functions that can be composed\n - Extract shared markdown or JSON field selection & formatting functionality\n\n2. **Avoid Duplication**:\n - NEVER copy-paste similar code between tools\n - If you find yourself writing similar logic twice, extract it into a function\n - Common operations like pagination, filtering, field selection, and formatting should be shared\n - Authentication/authorization logic should be centralized\n\n### Python-Specific Best Practices\n\n1. **Use Type Hints**: Always include type annotations for function parameters and return values\n2. **Pydantic Models**: Define clear Pydantic models for all input validation\n3. **Avoid Manual Validation**: Let Pydantic handle input validation with constraints\n4. **Proper Imports**: Group imports (standard library, third-party, local)\n5. **Error Handling**: Use specific exception types (httpx.HTTPStatusError, not generic Exception)\n6. **Async Context Managers**: Use `async with` for resources that need cleanup\n7. **Constants**: Define module-level constants in UPPER_CASE\n\n## Quality Checklist\n\nBefore finalizing your Python MCP server implementation, ensure:\n\n### Strategic Design\n- [ ] Tools enable complete workflows, not just API endpoint wrappers\n- [ ] Tool names reflect natural task subdivisions\n- [ ] Response formats optimize for agent context efficiency\n- [ ] Human-readable identifiers used where appropriate\n- [ ] Error messages guide agents toward correct usage\n\n### Implementation Quality\n- [ ] FOCUSED IMPLEMENTATION: Most important and valuable tools implemented\n- [ ] All tools have descriptive names and documentation\n- [ ] Return types are consistent across similar operations\n- [ ] Error handling is implemented for all external calls\n- [ ] Server name follows format: `{service}_mcp`\n- [ ] All network operations use async/await\n- [ ] Common functionality is extracted into reusable functions\n- [ ] Error messages are clear, actionable, and educational\n- [ ] Outputs are properly validated and formatted\n\n### Tool Configuration\n- [ ] All tools implement 'name' and 'annotations' in the decorator\n- [ ] Annotations correctly set (readOnlyHint, destructiveHint, idempotentHint, openWorldHint)\n- [ ] All tools use Pydantic BaseModel for input validation with Field() definitions\n- [ ] All Pydantic Fields have explicit types and descriptions with constraints\n- [ ] All tools have comprehensive docstrings with explicit input/output types\n- [ ] Docstrings include complete schema structure for dict/JSON returns\n- [ ] Pydantic models handle input validation (no manual validation needed)\n\n### Advanced Features (where applicable)\n- [ ] Context injection used for logging, progress, or elicitation\n- [ ] Resources registered for appropriate data endpoints\n- [ ] Lifespan management implemented for persistent connections\n- [ ] Structured output types used (TypedDict, Pydantic models)\n- [ ] Appropriate transport configured (stdio or streamable HTTP)\n\n### Code Quality\n- [ ] File includes proper imports including Pydantic imports\n- [ ] Pagination is properly implemented where applicable\n- [ ] Filtering options are provided for potentially large result sets\n- [ ] All async functions are properly defined with `async def`\n- [ ] HTTP client usage follows async patterns with proper context managers\n- [ ] Type hints are used throughout the code\n- [ ] Constants are defined at module level in UPPER_CASE\n\n### Testing\n- [ ] Server runs successfully: `python your_server.py --help`\n- [ ] All imports resolve correctly\n- [ ] Sample tool calls work as expected\n- [ ] Error scenarios handled gracefully"}],"isError":false,"timestamp":1781060900462}}
|
||
{"type":"message","id":"ace788ff","parentId":"f7eb30ae","timestamp":"2026-06-10T03:08:23.512Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"我已经收集了足够的信息来回答用户的问题。让我查看一下MCP相关的实际运行文档,然后给出完整的回答。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"functions.bash:14","name":"bash","arguments":{"command":"grep -i \"mcp\" /shumengya/project/agent/sproutclaw/packages/coding-agent/docs/README.md /shumengya/project/agent/sproutclaw/packages/coding-agent/README.md /shumengya/project/agent/sproutclaw/README.md 2>/dev/null | head -10"}}],"api":"openai-completions","provider":"opencode-go-openai","model":"kimi-k2.6","usage":{"input":12203,"output":105,"cacheRead":45324,"cacheWrite":0,"totalTokens":57632,"cost":{"input":0.011592849999999998,"output":0.00041999999999999996,"cacheRead":0.00725184,"cacheWrite":0,"total":0.019264689999999998}},"stopReason":"toolUse","timestamp":1781060900465,"responseId":"chatcmpl-bf03e5f364d64b1592ecc728472420fc","responseModel":"accounts/fireworks/models/kimi-k2p6"}}
|
||
{"type":"message","id":"39cddd4a","parentId":"ace788ff","timestamp":"2026-06-10T03:08:23.523Z","message":{"role":"toolResult","toolCallId":"functions.bash:14","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/packages/coding-agent/README.md:- MCP server integration\n/shumengya/project/agent/sproutclaw/packages/coding-agent/README.md:**No MCP.** Build CLI tools with READMEs (see [Skills](#skills)), or build an extension that adds MCP support. [Why?](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/)\n"}],"isError":false,"timestamp":1781060903523}}
|
||
{"type":"message","id":"022fdc94","parentId":"39cddd4a","timestamp":"2026-06-10T03:08:32.036Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"根据我的调研,我现在可以为用户提供一个完整的MCP说明和项目中的MCP工作流程。让我总结一下:\n\n1. MCP(Model Context Protocol)是什么\n2. 项目中的MCP架构和工作流程\n3. 项目中的MCP实际配置情况\n\n让我再确认一下MCP的缓存文件和配置文件的更新逻辑。","thinkingSignature":"reasoning_content"},{"type":"text","text":"根据我的调研,我现在为你详细解释 MCP 及其在项目中的工作流程:\n\n---\n\n## 什么是 MCP?\n\n**MCP**(Model Context Protocol)是 Anthropic 提出的一种**开放协议**,用于标准化 AI Agent 与外部工具、数据源之间的交互。它的核心目标是:\n\n- **统一接口**:让不同 AI 应用(如 Pi、Claude、Cursor 等)能一致地调用外部工具\n- **动态发现**:AI Agent 可以自动发现 MCP 服务器提供的工具,无需硬编码\n- **双向通信**:AI 向 MCP 服务器发请求,服务器返回结果,可以是文本、JSON 或结构化数据\n\n**MCP 的三种核心能力:**\n1. **Tools** — 可执行工具(如搜索、查询、创建 Issue)\n2. **Resources** — 只读数据资源(如文件、文档)\n3. **Prompts** — 预定义提示模板\n\n---\n\n## 项目中的 MCP 工作流程\n\n### 1. 配置文件架构\n\n```\n.pi/agent/\n├── mcp.json # MCP 服务器配置(启用/禁用)\n├── mcp-cache.json # 工具缓存(缓存发现到的工具列表)\n└── mcp-npx-cache.json # npx 包缓存路径\n```\n\n**mcp.json** 配置示例:\n```json\n{\n \"mcpServers\": {\n \"tavily\": { // 已启用的远程服务器\n \"url\": \"https://mcp.tavily.com/mcp/?tavilyApiKey=...\"\n }\n },\n \"mcpServersDisabled\": {\n \"cloudflare-search\": { // 已禁用的服务器\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@yrobot/cf-search-mcp\"],\n \"env\": { ... }\n }\n }\n}\n```\n\n### 2. 服务器发现与缓存\n\n**两种部署方式:**\n\n| 类型 | 配置方式 | 适用场景 |\n|------|---------|---------|\n| **远程 HTTP** | `url` 字段 | 第三方服务(如 Tavily、Google) |\n| **本地 stdio** | `command` + `args` | 本地运行(如 `npx` 启动的 Node 包) |\n\n**发现流程:**\n1. Pi 启动时读取 `mcp.json`\n2. 对每个激活的服务器,连接并请求 **tools/list** 发现可用工具\n3. 将发现的工具元数据(名称、描述、参数 schema)缓存到 `mcp-cache.json`\n\n### 3. 工具管理与权限控制\n\n通过 **WebUI** 提供精细控制:\n\n```typescript\n// 前端类型定义\ninterface McpServerInfo {\n name: string;\n enabled: boolean;\n cached: boolean;\n toolCount: number;\n enabledToolCount: number;\n tools: McpToolInfo[];\n}\n```\n\n**操作粒度:**\n- **服务器级别**:启用/禁用整个 MCP Server\n- **工具级别**:通过 `excludeTools` 排除特定工具\n\n**API 路由:**\n```\nPOST /api/settings/mcp/server/toggle // 启用/禁用服务器\nPOST /api/settings/mcp/tool/toggle // 启用/禁用单个工具\nGET /api/settings // 获取所有 MCP 配置\n```\n\n### 4. 工具调用流程\n\n当 AI Agent 需要调用 MCP 工具时:\n\n```\n1. 用户请求 → 系统提示词包含可用 MCP 工具列表\n2. AI 判断 → 选择要调用的工具(如 tavily_search)\n3. 参数构造 → 按 inputSchema 生成 JSON 参数\n4. MCP 调用 → 通过 mcp 工具发送到对应服务器\n5. 结果返回 → 服务器返回结果,AI 解析并继续处理\n```\n\n### 5. 项目中实际部署的 MCP 服务器\n\n| 服务器 | 状态 | 提供能力 |\n|--------|------|---------|\n| **Tavily** | ✅ 启用 | `tavily_search`、`tavily_extract`、`tavily_crawl`、`tavily_map`、`tavily_research` |\n| **Cloudflare Search** | ❌ 禁用 | `web_search`、`search` |\n\n---\n\n## 项目中的 MCP 代码实现\n\n**后端设置管理**(`webui/backend/settings/mcp-settings.ts`):\n- 读写 `mcp.json` 配置\n- 管理 `mcpServers`(启用)和 `mcpServersDisabled`(禁用)\n- 从 `mcp-cache.json` 读取工具缓存\n- 支持 `excludeTools` 字段控制工具级权限\n\n**WebUI 路由**(`webui/backend/routes/settings.ts`):\n- `/api/settings/mcp/server/toggle` — 切换服务器状态\n- `/api/settings/mcp/tool/toggle` — 切换工具状态\n- 修改后触发 `reload` 让 Agent 重新加载配置\n\n**前端 API**(`webui/frontend/src/api/settings.ts`):\n- `toggleMcpServer(server, enabled)` — 切换服务器\n- `toggleMcpTool(server, tool, enabled)` — 切换工具\n\n---\n\n## 与 Pi 原生工具的对比\n\n| 特性 | MCP | Pi 原生工具 |\n|------|-----|------------|\n| 协议 | 标准化(Anthropic 主导) | Pi 自定义 |\n| 工具来源 | 外部服务器(第三方) | 内置(read, bash, edit, write) |\n| 发现方式 | 动态发现(tools/list) | 硬编码在系统提示 |\n| 配置 | `mcp.json` + `mcp-cache.json` | `settings.json` |\n\n**项目设计哲学**:Pi 本身**不强制依赖 MCP**,更推崇通过 **Skills**(Markdown 说明文件)构建本地工具。但提供 **MCP Gateway** 作为扩展,让用户可以按需接入外部 MCP 生态。\n\n---\n\n## 总结\n\nMCP 在项目中是一个**可选的扩展机制**,而非核心依赖。它通过:\n- `mcp.json` 配置服务器\n- `mcp-cache.json` 缓存工具发现结果\n- WebUI 提供可视化权限控制\n- 标准 JSON-RPC 协议与外部服务器通信\n\n这套架构让 Pi 可以安全地接入 Tavily 搜索、Cloudflare 等第三方 MCP 服务,同时保持对工具粒度的精细控制。"}],"api":"openai-completions","provider":"opencode-go-openai","model":"kimi-k2.6","usage":{"input":214,"output":1429,"cacheRead":57526,"cacheWrite":0,"totalTokens":59169,"cost":{"input":0.00020329999999999998,"output":0.005716,"cacheRead":0.00920416,"cacheWrite":0,"total":0.015123459999999998}},"stopReason":"stop","timestamp":1781060903524,"responseId":"chatcmpl-3fda4d61c71d49a3a6fd0a8a555b38b5","responseModel":"accounts/fireworks/models/kimi-k2p6"}}
|
||
{"type":"message","id":"5a7e54b3","parentId":"022fdc94","timestamp":"2026-06-10T07:32:31.427Z","message":{"role":"user","content":[{"type":"text","text":"sproutclaw项目解析"}],"timestamp":1781076751423}}
|
||
{"type":"message","id":"b2b8e56b","parentId":"5a7e54b3","timestamp":"2026-06-10T07:32:33.558Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"opencode-go-openai","model":"kimi-k2.6","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"aborted","timestamp":1781076751480,"errorMessage":"Operation aborted"}}
|
||
{"type":"session_info","id":"fe07f1d5","parentId":"b2b8e56b","timestamp":"2026-06-10T07:32:45.448Z","name":"sproutclaw项目解析"}
|
||
{"type":"model_change","id":"127c5121","parentId":"fe07f1d5","timestamp":"2026-06-16T13:04:40.446Z","provider":"xiaomimimo-openai","modelId":"mimo-v2.5"}
|
||
{"type":"thinking_level_change","id":"f4062dde","parentId":"127c5121","timestamp":"2026-06-16T13:04:40.446Z","thinkingLevel":"high"}
|
||
{"type":"message","id":"674e2f1f","parentId":"f4062dde","timestamp":"2026-06-16T13:05:03.252Z","message":{"role":"user","content":[{"type":"text","text":"解释一下sproutclaw的上下文工程是怎么实现的,每次发送给大模型的系统提示词会携带哪些信息"}],"timestamp":1781615103252}}
|
||
{"type":"message","id":"0939bbc8","parentId":"674e2f1f","timestamp":"2026-06-16T13:05:11.402Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户想要了解sproutclaw项目的上下文工程实现,以及每次发送给大模型的系统提示词会携带哪些信息。我需要查看项目中的系统提示词构建逻辑、上下文管理相关的代码。\n\n让我先查看一下项目结构,然后搜索相关的代码文件。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_e04447b123514ed48a155e13","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw -type f -name \"*.ts\" | grep -v node_modules | grep -E \"(prompt|context|system|settings|format)\" | head -20"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":50577,"output":128,"cacheRead":10240,"cacheWrite":0,"totalTokens":60945,"cost":{"input":0.0202308,"output":0.000256,"cacheRead":0.0008192,"cacheWrite":0,"total":0.021306}},"stopReason":"toolUse","timestamp":1781615103254,"responseId":"3b633ecaa18446a9901694d5ba066bc9"}}
|
||
{"type":"message","id":"4bfbea55","parentId":"0939bbc8","timestamp":"2026-06-16T13:05:11.581Z","message":{"role":"toolResult","toolCallId":"call_e04447b123514ed48a155e13","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/.pi/agent/extensions-disabled/prompt-url-widget/index.ts\n/shumengya/project/agent/sproutclaw/packages/web-ui/dist/prompts/prompts.d.ts\n/shumengya/project/agent/sproutclaw/packages/web-ui/dist/utils/format.d.ts\n/shumengya/project/agent/sproutclaw/packages/web-ui/dist/storage/stores/settings-store.d.ts\n/shumengya/project/agent/sproutclaw/packages/tui/dist/components/settings-list.d.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/settings-list.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/system-prompt.test.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager-bug.test.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/settings-manager.test.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/session-manager/build-context.test.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/suite/agent-session-prompt.test.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/suite/regressions/2860-replaced-session-context.test.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/suite/regressions/2753-reload-stale-resource-settings.test.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/suite/regressions/3616-settings-inmemory-reload.test.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/test/prompt-templates.test.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/examples/sdk/07-context-files.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/examples/sdk/10-settings.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/examples/sdk/08-prompt-templates.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/examples/sdk/03-custom-prompt.ts\n"}],"isError":false,"timestamp":1781615111581}}
|
||
{"type":"message","id":"6b4c10f4","parentId":"4bfbea55","timestamp":"2026-06-16T13:05:20.480Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下相关的源码文件,了解上下文工程的实现。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_80fa0481f1b6476f8657c5f2","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw/packages -type f -name \"*.ts\" | grep -v node_modules | grep -E \"(system|prompt)\" | grep -v test | grep -v dist | head -20"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":670,"output":90,"cacheRead":60800,"cacheWrite":0,"totalTokens":61560,"cost":{"input":0.000268,"output":0.00017999999999999998,"cacheRead":0.004864,"cacheWrite":0,"total":0.005312}},"stopReason":"toolUse","timestamp":1781615111582,"responseId":"e08835699f3a425da50c0e8e56e50e5c"}}
|
||
{"type":"message","id":"229208d5","parentId":"6b4c10f4","timestamp":"2026-06-16T13:05:20.503Z","message":{"role":"toolResult","toolCallId":"call_80fa0481f1b6476f8657c5f2","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/packages/coding-agent/examples/sdk/08-prompt-templates.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/examples/sdk/03-custom-prompt.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/examples/extensions/prompt-customizer.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/examples/extensions/system-prompt-header.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/examples/extensions/mac-system-theme.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/system-prompt.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/prompt-templates.ts\n/shumengya/project/agent/sproutclaw/packages/agent/src/harness/system-prompt.ts\n/shumengya/project/agent/sproutclaw/packages/agent/src/harness/prompt-templates.ts\n/shumengya/project/agent/sproutclaw/packages/ai/src/providers/openai-prompt-cache.ts\n"}],"isError":false,"timestamp":1781615120503}}
|
||
{"type":"message","id":"a4aa2c59","parentId":"229208d5","timestamp":"2026-06-16T13:05:21.878Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下系统提示词构建的核心文件。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_d6cd08d196be41e6af0be1f0","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/system-prompt.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":373,"output":52,"cacheRead":61440,"cacheWrite":0,"totalTokens":61865,"cost":{"input":0.00014920000000000002,"output":0.000104,"cacheRead":0.0049152,"cacheWrite":0,"total":0.0051684}},"stopReason":"toolUse","timestamp":1781615120503,"responseId":"52a08208da9c43eeb9e7c923dd03fa73"}}
|
||
{"type":"message","id":"562cd4df","parentId":"a4aa2c59","timestamp":"2026-06-16T13:05:21.881Z","message":{"role":"toolResult","toolCallId":"call_d6cd08d196be41e6af0be1f0","toolName":"read","content":[{"type":"text","text":"/**\n * System prompt construction and project context loading\n */\n\nimport { getDocsPath, getExamplesPath, getReadmePath } from \"../config.ts\";\nimport { formatSkillsForPrompt, type Skill } from \"./skills.ts\";\n\nexport interface BuildSystemPromptOptions {\n\t/** Custom system prompt (replaces default). */\n\tcustomPrompt?: string;\n\t/** Tools to include in prompt. Default: [read, bash, edit, write] */\n\tselectedTools?: string[];\n\t/** Optional one-line tool snippets keyed by tool name. */\n\ttoolSnippets?: Record<string, string>;\n\t/** Additional guideline bullets appended to the default system prompt guidelines. */\n\tpromptGuidelines?: string[];\n\t/** Text to append to system prompt. */\n\tappendSystemPrompt?: string;\n\t/** Working directory. */\n\tcwd: string;\n\t/** Pre-loaded context files. */\n\tcontextFiles?: Array<{ path: string; content: string }>;\n\t/** Pre-loaded skills. */\n\tskills?: Skill[];\n}\n\n/** Build the system prompt with tools, guidelines, and context */\nexport function buildSystemPrompt(options: BuildSystemPromptOptions): string {\n\tconst {\n\t\tcustomPrompt,\n\t\tselectedTools,\n\t\ttoolSnippets,\n\t\tpromptGuidelines,\n\t\tappendSystemPrompt,\n\t\tcwd,\n\t\tcontextFiles: providedContextFiles,\n\t\tskills: providedSkills,\n\t} = options;\n\tconst resolvedCwd = cwd;\n\tconst promptCwd = resolvedCwd.replace(/\\\\/g, \"/\");\n\n\tconst now = new Date();\n\tconst year = now.getFullYear();\n\tconst month = String(now.getMonth() + 1).padStart(2, \"0\");\n\tconst day = String(now.getDate()).padStart(2, \"0\");\n\tconst date = `${year}-${month}-${day}`;\n\n\tconst appendSection = appendSystemPrompt ? `\\n\\n${appendSystemPrompt}` : \"\";\n\n\tconst contextFiles = providedContextFiles ?? [];\n\tconst skills = providedSkills ?? [];\n\n\tif (customPrompt) {\n\t\tlet prompt = customPrompt;\n\n\t\tif (appendSection) {\n\t\t\tprompt += appendSection;\n\t\t}\n\n\t\t// Append project context files\n\t\tif (contextFiles.length > 0) {\n\t\t\tprompt += \"\\n\\n<project_context>\\n\\n\";\n\t\t\tprompt += \"Project-specific instructions and guidelines:\\n\\n\";\n\t\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\t\tprompt += `<project_instructions path=\"${filePath}\">\\n${content}\\n</project_instructions>\\n\\n`;\n\t\t\t}\n\t\t\tprompt += \"</project_context>\\n\";\n\t\t}\n\n\t\t// Append skills section (only if read tool is available)\n\t\tconst customPromptHasRead = !selectedTools || selectedTools.includes(\"read\");\n\t\tif (customPromptHasRead && skills.length > 0) {\n\t\t\tprompt += formatSkillsForPrompt(skills);\n\t\t}\n\n\t\t// Add date and working directory last\n\t\tprompt += `\\nCurrent date: ${date}`;\n\t\tprompt += `\\nCurrent working directory: ${promptCwd}`;\n\n\t\treturn prompt;\n\t}\n\n\t// Get absolute paths to documentation and examples\n\tconst readmePath = getReadmePath();\n\tconst docsPath = getDocsPath();\n\tconst examplesPath = getExamplesPath();\n\n\t// Build tools list based on selected tools.\n\t// A tool appears in Available tools only when the caller provides a one-line snippet.\n\tconst tools = selectedTools || [\"read\", \"bash\", \"edit\", \"write\"];\n\tconst visibleTools = tools.filter((name) => !!toolSnippets?.[name]);\n\tconst toolsList =\n\t\tvisibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets![name]}`).join(\"\\n\") : \"(none)\";\n\n\t// Build guidelines based on which tools are actually available\n\tconst guidelinesList: string[] = [];\n\tconst guidelinesSet = new Set<string>();\n\tconst addGuideline = (guideline: string): void => {\n\t\tif (guidelinesSet.has(guideline)) {\n\t\t\treturn;\n\t\t}\n\t\tguidelinesSet.add(guideline);\n\t\tguidelinesList.push(guideline);\n\t};\n\n\tconst hasBash = tools.includes(\"bash\");\n\tconst hasGrep = tools.includes(\"grep\");\n\tconst hasFind = tools.includes(\"find\");\n\tconst hasLs = tools.includes(\"ls\");\n\tconst hasRead = tools.includes(\"read\");\n\n\t// File exploration guidelines\n\tif (hasBash && !hasGrep && !hasFind && !hasLs) {\n\t\taddGuideline(\"Use bash for file operations like ls, rg, find\");\n\t} else if (hasBash && (hasGrep || hasFind || hasLs)) {\n\t\taddGuideline(\"Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)\");\n\t}\n\n\tfor (const guideline of promptGuidelines ?? []) {\n\t\tconst normalized = guideline.trim();\n\t\tif (normalized.length > 0) {\n\t\t\taddGuideline(normalized);\n\t\t}\n\t}\n\n\t// Always include these\n\taddGuideline(\"Be concise in your responses\");\n\taddGuideline(\"Show file paths clearly when working with files\");\n\n\tconst guidelines = guidelinesList.map((g) => `- ${g}`).join(\"\\n\");\n\n\tlet prompt = `You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n${toolsList}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n${guidelines}\n\nPi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):\n- Main documentation: ${readmePath}\n- Additional docs: ${docsPath}\n- Examples: ${examplesPath} (extensions, custom tools, SDK)\n- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory\n- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)\n- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing\n- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`;\n\n\tif (appendSection) {\n\t\tprompt += appendSection;\n\t}\n\n\t// Append project context files\n\tif (contextFiles.length > 0) {\n\t\tprompt += \"\\n\\n<project_context>\\n\\n\";\n\t\tprompt += \"Project-specific instructions and guidelines:\\n\\n\";\n\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\tprompt += `<project_instructions path=\"${filePath}\">\\n${content}\\n</project_instructions>\\n\\n`;\n\t\t}\n\t\tprompt += \"</project_context>\\n\";\n\t}\n\n\t// Append skills section (only if read tool is available)\n\tif (hasRead && skills.length > 0) {\n\t\tprompt += formatSkillsForPrompt(skills);\n\t}\n\n\t// Add date and working directory last\n\tprompt += `\\nCurrent date: ${date}`;\n\tprompt += `\\nCurrent working directory: ${promptCwd}`;\n\n\treturn prompt;\n}\n"}],"isError":false,"timestamp":1781615121881}}
|
||
{"type":"message","id":"acc88b49","parentId":"562cd4df","timestamp":"2026-06-16T13:05:24.107Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我查看一下上下文文件是如何加载的,以及 settings manager 和 session manager 是如何工作的。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_a332228cc5dc47b085b4f465","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw/packages -type f -name \"*.ts\" | grep -v node_modules | grep -E \"(settings|session)\" | grep -v test | grep -v dist | grep -v examples | head -20"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1675,"output":101,"cacheRead":61760,"cacheWrite":0,"totalTokens":63536,"cost":{"input":0.00067,"output":0.000202,"cacheRead":0.0049408,"cacheWrite":0,"total":0.005812800000000001}},"stopReason":"toolUse","timestamp":1781615121882,"responseId":"ed55ac993ac945928df11224109e9d84"}}
|
||
{"type":"message","id":"eb769098","parentId":"acc88b49","timestamp":"2026-06-16T13:05:24.128Z","message":{"role":"toolResult","toolCallId":"call_a332228cc5dc47b085b4f465","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/packages/tui/src/components/settings-list.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/cli/session-picker.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/agent-session.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/settings-manager.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/session-manager.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/agent-session-services.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/agent-session-runtime.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/session-cwd.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/modes/interactive/components/session-selector.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/modes/interactive/components/settings-selector.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts\n/shumengya/project/agent/sproutclaw/packages/agent/src/harness/session/jsonl-storage.ts\n/shumengya/project/agent/sproutclaw/packages/agent/src/harness/session/repo-utils.ts\n/shumengya/project/agent/sproutclaw/packages/agent/src/harness/session/memory-repo.ts\n/shumengya/project/agent/sproutclaw/packages/agent/src/harness/session/jsonl-repo.ts\n/shumengya/project/agent/sproutclaw/packages/agent/src/harness/session/memory-storage.ts\n/shumengya/project/agent/sproutclaw/packages/agent/src/harness/session/session.ts\n/shumengya/project/agent/sproutclaw/packages/agent/src/harness/session/uuid.ts\n/shumengya/project/agent/sproutclaw/packages/ai/src/session-resources.ts\n"}],"isError":false,"timestamp":1781615124128}}
|
||
{"type":"message","id":"f0eea4c7","parentId":"eb769098","timestamp":"2026-06-16T13:05:34.089Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 session manager,了解上下文是如何被加载的。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_d754e77cff30439085615459","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/session-manager.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":568,"output":55,"cacheRead":63424,"cacheWrite":0,"totalTokens":64047,"cost":{"input":0.00022720000000000002,"output":0.00010999999999999999,"cacheRead":0.00507392,"cacheWrite":0,"total":0.00541112}},"stopReason":"toolUse","timestamp":1781615124129,"responseId":"4fc2a1ce43ea4551b65b6dd01fa4981b"}}
|
||
{"type":"message","id":"dc024129","parentId":"f0eea4c7","timestamp":"2026-06-16T13:05:34.093Z","message":{"role":"toolResult","toolCallId":"call_d754e77cff30439085615459","toolName":"read","content":[{"type":"text","text":"import { type AgentMessage, uuidv7 } from \"@earendil-works/pi-agent-core\";\nimport type { ImageContent, Message, TextContent } from \"@earendil-works/pi-ai\";\nimport { randomUUID } from \"crypto\";\nimport {\n\tappendFileSync,\n\tcloseSync,\n\texistsSync,\n\tmkdirSync,\n\topenSync,\n\treaddirSync,\n\treadFileSync,\n\treadSync,\n\tstatSync,\n\twriteFileSync,\n} from \"fs\";\nimport { readdir, readFile, stat } from \"fs/promises\";\nimport { join, resolve } from \"path\";\nimport { getAgentDir as getDefaultAgentDir, getSessionsDir } from \"../config.ts\";\nimport { normalizePath, resolvePath } from \"../utils/paths.ts\";\nimport {\n\ttype BashExecutionMessage,\n\ttype CustomMessage,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateCustomMessage,\n} from \"./messages.ts\";\n\nexport const CURRENT_SESSION_VERSION = 3;\n\nexport interface SessionHeader {\n\ttype: \"session\";\n\tversion?: number; // v1 sessions don't have this\n\tid: string;\n\ttimestamp: string;\n\tcwd: string;\n\tparentSession?: string;\n}\n\nexport interface NewSessionOptions {\n\tid?: string;\n\tparentSession?: string;\n}\n\nexport interface SessionEntryBase {\n\ttype: string;\n\tid: string;\n\tparentId: string | null;\n\ttimestamp: string;\n}\n\nexport interface SessionMessageEntry extends SessionEntryBase {\n\ttype: \"message\";\n\tmessage: AgentMessage;\n}\n\nexport interface ThinkingLevelChangeEntry extends SessionEntryBase {\n\ttype: \"thinking_level_change\";\n\tthinkingLevel: string;\n}\n\nexport interface ModelChangeEntry extends SessionEntryBase {\n\ttype: \"model_change\";\n\tprovider: string;\n\tmodelId: string;\n}\n\nexport interface CompactionEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"compaction\";\n\tsummary: string;\n\tfirstKeptEntryId: string;\n\ttokensBefore: number;\n\t/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */\n\tdetails?: T;\n\t/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */\n\tfromHook?: boolean;\n}\n\nexport interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"branch_summary\";\n\tfromId: string;\n\tsummary: string;\n\t/** Extension-specific data (not sent to LLM) */\n\tdetails?: T;\n\t/** True if generated by an extension, false if pi-generated */\n\tfromHook?: boolean;\n}\n\n/**\n * Custom entry for extensions to store extension-specific data in the session.\n * Use customType to identify your extension's entries.\n *\n * Purpose: Persist extension state across session reloads. On reload, extensions can\n * scan entries for their customType and reconstruct internal state.\n *\n * Does NOT participate in LLM context (ignored by buildSessionContext).\n * For injecting content into context, see CustomMessageEntry.\n */\nexport interface CustomEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom\";\n\tcustomType: string;\n\tdata?: T;\n}\n\n/** Label entry for user-defined bookmarks/markers on entries. */\nexport interface LabelEntry extends SessionEntryBase {\n\ttype: \"label\";\n\ttargetId: string;\n\tlabel: string | undefined;\n}\n\n/** Session metadata entry (e.g., user-defined display name). */\nexport interface SessionInfoEntry extends SessionEntryBase {\n\ttype: \"session_info\";\n\tname?: string;\n}\n\n/**\n * Custom message entry for extensions to inject messages into LLM context.\n * Use customType to identify your extension's entries.\n *\n * Unlike CustomEntry, this DOES participate in LLM context.\n * The content is converted to a user message in buildSessionContext().\n * Use details for extension-specific metadata (not sent to LLM).\n *\n * display controls TUI rendering:\n * - false: hidden entirely\n * - true: rendered with distinct styling (different from user messages)\n */\nexport interface CustomMessageEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom_message\";\n\tcustomType: string;\n\tcontent: string | (TextContent | ImageContent)[];\n\tdetails?: T;\n\tdisplay: boolean;\n}\n\n/** Session entry - has id/parentId for tree structure (returned by \"read\" methods in SessionManager) */\nexport type SessionEntry =\n\t| SessionMessageEntry\n\t| ThinkingLevelChangeEntry\n\t| ModelChangeEntry\n\t| CompactionEntry\n\t| BranchSummaryEntry\n\t| CustomEntry\n\t| CustomMessageEntry\n\t| LabelEntry\n\t| SessionInfoEntry;\n\n/** Raw file entry (includes header) */\nexport type FileEntry = SessionHeader | SessionEntry;\n\n/** Tree node for getTree() - defensive copy of session structure */\nexport interface SessionTreeNode {\n\tentry: SessionEntry;\n\tchildren: SessionTreeNode[];\n\t/** Resolved label for this entry, if any */\n\tlabel?: string;\n\t/** Timestamp of the latest label change for this entry, if any */\n\tlabelTimestamp?: string;\n}\n\nexport interface SessionContext {\n\tmessages: AgentMessage[];\n\tthinkingLevel: string;\n\tmodel: { provider: string; modelId: string } | null;\n}\n\nexport interface SessionInfo {\n\tpath: string;\n\tid: string;\n\t/** Working directory where the session was started. Empty string for old sessions. */\n\tcwd: string;\n\t/** User-defined display name from session_info entries. */\n\tname?: string;\n\t/** Path to the parent session (if this session was forked). */\n\tparentSessionPath?: string;\n\tcreated: Date;\n\tmodified: Date;\n\tmessageCount: number;\n\tfirstMessage: string;\n\tallMessagesText: string;\n}\n\nexport type ReadonlySessionManager = Pick<\n\tSessionManager,\n\t| \"getCwd\"\n\t| \"getSessionDir\"\n\t| \"getSessionId\"\n\t| \"getSessionFile\"\n\t| \"getLeafId\"\n\t| \"getLeafEntry\"\n\t| \"getEntry\"\n\t| \"getLabel\"\n\t| \"getBranch\"\n\t| \"getHeader\"\n\t| \"getEntries\"\n\t| \"getTree\"\n\t| \"getSessionName\"\n>;\n\nfunction createSessionId(): string {\n\treturn uuidv7();\n}\n\n/** Generate a unique short ID (8 hex chars, collision-checked) */\nfunction generateId(byId: { has(id: string): boolean }): string {\n\tfor (let i = 0; i < 100; i++) {\n\t\tconst id = randomUUID().slice(0, 8);\n\t\tif (!byId.has(id)) return id;\n\t}\n\t// Fallback to full UUID if somehow we have collisions\n\treturn randomUUID();\n}\n\n/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */\nfunction migrateV1ToV2(entries: FileEntry[]): void {\n\tconst ids = new Set<string>();\n\tlet prevId: string | null = null;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"session\") {\n\t\t\tentry.version = 2;\n\t\t\tcontinue;\n\t\t}\n\n\t\tentry.id = generateId(ids);\n\t\tentry.parentId = prevId;\n\t\tprevId = entry.id;\n\n\t\t// Convert firstKeptEntryIndex to firstKeptEntryId for compaction\n\t\tif (entry.type === \"compaction\") {\n\t\t\tconst comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };\n\t\t\tif (typeof comp.firstKeptEntryIndex === \"number\") {\n\t\t\t\tconst targetEntry = entries[comp.firstKeptEntryIndex];\n\t\t\t\tif (targetEntry && targetEntry.type !== \"session\") {\n\t\t\t\t\tcomp.firstKeptEntryId = targetEntry.id;\n\t\t\t\t}\n\t\t\t\tdelete comp.firstKeptEntryIndex;\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */\nfunction migrateV2ToV3(entries: FileEntry[]): void {\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"session\") {\n\t\t\tentry.version = 3;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Update message entries with hookMessage role\n\t\tif (entry.type === \"message\") {\n\t\t\tconst msgEntry = entry as SessionMessageEntry;\n\t\t\tif (msgEntry.message && (msgEntry.message as { role: string }).role === \"hookMessage\") {\n\t\t\t\t(msgEntry.message as { role: string }).role = \"custom\";\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * Run all necessary migrations to bring entries to current version.\n * Mutates entries in place. Returns true if any migration was applied.\n */\nfunction migrateToCurrentVersion(entries: FileEntry[]): boolean {\n\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\tconst version = header?.version ?? 1;\n\n\tif (version >= CURRENT_SESSION_VERSION) return false;\n\n\tif (version < 2) migrateV1ToV2(entries);\n\tif (version < 3) migrateV2ToV3(entries);\n\n\treturn true;\n}\n\n/** Exported for testing */\nexport function migrateSessionEntries(entries: FileEntry[]): void {\n\tmigrateToCurrentVersion(entries);\n}\n\n/** Exported for compaction.test.ts */\nexport function parseSessionEntries(content: string): FileEntry[] {\n\tconst entries: FileEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as FileEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\treturn entries;\n}\n\nexport function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\treturn entries[i] as CompactionEntry;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Build the session context from entries using tree traversal.\n * If leafId is provided, walks from that entry to root.\n * Handles compaction and branch summaries along the path.\n */\nexport function buildSessionContext(\n\tentries: SessionEntry[],\n\tleafId?: string | null,\n\tbyId?: Map<string, SessionEntry>,\n): SessionContext {\n\t// Build uuid index if not available\n\tif (!byId) {\n\t\tbyId = new Map<string, SessionEntry>();\n\t\tfor (const entry of entries) {\n\t\t\tbyId.set(entry.id, entry);\n\t\t}\n\t}\n\n\t// Find leaf\n\tlet leaf: SessionEntry | undefined;\n\tif (leafId === null) {\n\t\t// Explicitly null - return no messages (navigated to before first entry)\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\tif (leafId) {\n\t\tleaf = byId.get(leafId);\n\t}\n\tif (!leaf) {\n\t\t// Fallback to last entry (when leafId is undefined)\n\t\tleaf = entries[entries.length - 1];\n\t}\n\n\tif (!leaf) {\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\n\t// Walk from leaf to root, collecting path\n\tconst path: SessionEntry[] = [];\n\tlet current: SessionEntry | undefined = leaf;\n\twhile (current) {\n\t\tpath.unshift(current);\n\t\tcurrent = current.parentId ? byId.get(current.parentId) : undefined;\n\t}\n\n\t// Extract settings and find compaction\n\tlet thinkingLevel = \"off\";\n\tlet model: { provider: string; modelId: string } | null = null;\n\tlet compaction: CompactionEntry | null = null;\n\n\tfor (const entry of path) {\n\t\tif (entry.type === \"thinking_level_change\") {\n\t\t\tthinkingLevel = entry.thinkingLevel;\n\t\t} else if (entry.type === \"model_change\") {\n\t\t\tmodel = { provider: entry.provider, modelId: entry.modelId };\n\t\t} else if (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\tmodel = { provider: entry.message.provider, modelId: entry.message.model };\n\t\t} else if (entry.type === \"compaction\") {\n\t\t\tcompaction = entry;\n\t\t}\n\t}\n\n\t// Build messages and collect corresponding entries\n\t// When there's a compaction, we need to:\n\t// 1. Emit summary first (entry = compaction)\n\t// 2. Emit kept messages (from firstKeptEntryId up to compaction)\n\t// 3. Emit messages after compaction\n\tconst messages: AgentMessage[] = [];\n\n\tconst appendMessage = (entry: SessionEntry) => {\n\t\tif (entry.type === \"message\") {\n\t\t\tmessages.push(entry.message);\n\t\t} else if (entry.type === \"custom_message\") {\n\t\t\tmessages.push(\n\t\t\t\tcreateCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),\n\t\t\t);\n\t\t} else if (entry.type === \"branch_summary\" && entry.summary) {\n\t\t\tmessages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));\n\t\t}\n\t};\n\n\tif (compaction) {\n\t\t// Emit summary first\n\t\tmessages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));\n\n\t\t// Find compaction index in path\n\t\tconst compactionIdx = path.findIndex((e) => e.type === \"compaction\" && e.id === compaction.id);\n\n\t\t// Emit kept messages (before compaction, starting from firstKeptEntryId)\n\t\tlet foundFirstKept = false;\n\t\tfor (let i = 0; i < compactionIdx; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tif (entry.id === compaction.firstKeptEntryId) {\n\t\t\t\tfoundFirstKept = true;\n\t\t\t}\n\t\t\tif (foundFirstKept) {\n\t\t\t\tappendMessage(entry);\n\t\t\t}\n\t\t}\n\n\t\t// Emit messages after compaction\n\t\tfor (let i = compactionIdx + 1; i < path.length; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tappendMessage(entry);\n\t\t}\n\t} else {\n\t\t// No compaction - emit all messages, handle branch summaries and custom messages\n\t\tfor (const entry of path) {\n\t\t\tappendMessage(entry);\n\t\t}\n\t}\n\n\treturn { messages, thinkingLevel, model };\n}\n\n/**\n * Compute the default session directory for a cwd.\n * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.\n */\nexport function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {\n\tconst resolvedCwd = resolvePath(cwd);\n\tconst resolvedAgentDir = resolvePath(agentDir);\n\tconst safePath = `--${resolvedCwd.replace(/^[/\\\\]/, \"\").replace(/[/\\\\:]/g, \"-\")}--`;\n\tconst sessionDir = join(resolvedAgentDir, \"sessions\", safePath);\n\tif (!existsSync(sessionDir)) {\n\t\tmkdirSync(sessionDir, { recursive: true });\n\t}\n\treturn sessionDir;\n}\n\n/** Exported for testing */\nexport function loadEntriesFromFile(filePath: string): FileEntry[] {\n\tconst resolvedFilePath = normalizePath(filePath);\n\tif (!existsSync(resolvedFilePath)) return [];\n\n\tconst content = readFileSync(resolvedFilePath, \"utf8\");\n\tconst entries: FileEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as FileEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\t// Validate session header\n\tif (entries.length === 0) return entries;\n\tconst header = entries[0];\n\tif (header.type !== \"session\" || typeof (header as any).id !== \"string\") {\n\t\treturn [];\n\t}\n\n\treturn entries;\n}\n\nfunction isValidSessionFile(filePath: string): boolean {\n\ttry {\n\t\tconst fd = openSync(filePath, \"r\");\n\t\tconst buffer = Buffer.alloc(512);\n\t\tconst bytesRead = readSync(fd, buffer, 0, 512, 0);\n\t\tcloseSync(fd);\n\t\tconst firstLine = buffer.toString(\"utf8\", 0, bytesRead).split(\"\\n\")[0];\n\t\tif (!firstLine) return false;\n\t\tconst header = JSON.parse(firstLine);\n\t\treturn header.type === \"session\" && typeof header.id === \"string\";\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/** Exported for testing */\nexport function findMostRecentSession(sessionDir: string): string | null {\n\tconst resolvedSessionDir = normalizePath(sessionDir);\n\ttry {\n\t\tconst files = readdirSync(resolvedSessionDir)\n\t\t\t.filter((f) => f.endsWith(\".jsonl\"))\n\t\t\t.map((f) => join(resolvedSessionDir, f))\n\t\t\t.filter(isValidSessionFile)\n\t\t\t.map((path) => ({ path, mtime: statSync(path).mtime }))\n\t\t\t.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());\n\n\t\treturn files[0]?.path || null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction isMessageWithContent(message: AgentMessage): message is Message {\n\treturn typeof (message as Message).role === \"string\" && \"content\" in message;\n}\n\nfunction extractTextContent(message: Message): string {\n\tconst content = message.content;\n\tif (typeof content === \"string\") {\n\t\treturn content;\n\t}\n\treturn content\n\t\t.filter((block): block is TextContent => block.type === \"text\")\n\t\t.map((block) => block.text)\n\t\t.join(\" \");\n}\n\nfunction getLastActivityTime(entries: FileEntry[]): number | undefined {\n\tlet lastActivityTime: number | undefined;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\n\t\tconst message = (entry as SessionMessageEntry).message;\n\t\tif (!isMessageWithContent(message)) continue;\n\t\tif (message.role !== \"user\" && message.role !== \"assistant\") continue;\n\n\t\tconst msgTimestamp = (message as { timestamp?: number }).timestamp;\n\t\tif (typeof msgTimestamp === \"number\") {\n\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst entryTimestamp = (entry as SessionEntryBase).timestamp;\n\t\tif (typeof entryTimestamp === \"string\") {\n\t\t\tconst t = new Date(entryTimestamp).getTime();\n\t\t\tif (!Number.isNaN(t)) {\n\t\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, t);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn lastActivityTime;\n}\n\nfunction getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date {\n\tconst lastActivityTime = getLastActivityTime(entries);\n\tif (typeof lastActivityTime === \"number\" && lastActivityTime > 0) {\n\t\treturn new Date(lastActivityTime);\n\t}\n\n\tconst headerTime = typeof header.timestamp === \"string\" ? new Date(header.timestamp).getTime() : NaN;\n\treturn !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime;\n}\n\nasync function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {\n\ttry {\n\t\tconst content = await readFile(filePath, \"utf8\");\n\t\tconst entries: FileEntry[] = [];\n\t\tconst lines = content.trim().split(\"\\n\");\n\n\t\tfor (const line of lines) {\n\t\t\tif (!line.trim()) continue;\n\t\t\ttry {\n\t\t\t\tentries.push(JSON.parse(line) as FileEntry);\n\t\t\t} catch {\n\t\t\t\t// Skip malformed lines\n\t\t\t}\n\t\t}\n\n\t\tif (entries.length === 0) return null;\n\t\tconst header = entries[0];\n\t\tif (header.type !== \"session\") return null;\n\n\t\tconst stats = await stat(filePath);\n\t\tlet messageCount = 0;\n\t\tlet firstMessage = \"\";\n\t\tconst allMessages: string[] = [];\n\t\tlet name: string | undefined;\n\n\t\tfor (const entry of entries) {\n\t\t\t// Extract session name (use latest, including explicit clears)\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\tconst infoEntry = entry as SessionInfoEntry;\n\t\t\t\tname = infoEntry.name?.trim() || undefined;\n\t\t\t}\n\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tmessageCount++;\n\n\t\t\tconst message = (entry as SessionMessageEntry).message;\n\t\t\tif (!isMessageWithContent(message)) continue;\n\t\t\tif (message.role !== \"user\" && message.role !== \"assistant\") continue;\n\n\t\t\tconst textContent = extractTextContent(message);\n\t\t\tif (!textContent) continue;\n\n\t\t\tallMessages.push(textContent);\n\t\t\tif (!firstMessage && message.role === \"user\") {\n\t\t\t\tfirstMessage = textContent;\n\t\t\t}\n\t\t}\n\n\t\tconst cwd = typeof (header as SessionHeader).cwd === \"string\" ? (header as SessionHeader).cwd : \"\";\n\t\tconst parentSessionPath = (header as SessionHeader).parentSession;\n\n\t\tconst modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime);\n\n\t\treturn {\n\t\t\tpath: filePath,\n\t\t\tid: (header as SessionHeader).id,\n\t\t\tcwd,\n\t\t\tname,\n\t\t\tparentSessionPath,\n\t\t\tcreated: new Date((header as SessionHeader).timestamp),\n\t\t\tmodified,\n\t\t\tmessageCount,\n\t\t\tfirstMessage: firstMessage || \"(no messages)\",\n\t\t\tallMessagesText: allMessages.join(\" \"),\n\t\t};\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nexport type SessionListProgress = (loaded: number, total: number) => void;\n\nconst MAX_CONCURRENT_SESSION_INFO_LOADS = 10;\n\nasync function buildSessionInfosWithConcurrency(\n\tfiles: string[],\n\tonLoaded: () => void,\n): Promise<(SessionInfo | null)[]> {\n\tconst results: (SessionInfo | null)[] = new Array(files.length).fill(null);\n\tconst inFlight = new Set<Promise<void>>();\n\tlet nextIndex = 0;\n\n\tconst startNext = (): void => {\n\t\tconst index = nextIndex++;\n\t\tconst file = files[index];\n\t\tif (!file) return;\n\n\t\tlet task: Promise<void>;\n\t\ttask = buildSessionInfo(file)\n\t\t\t.then((info) => {\n\t\t\t\tresults[index] = info;\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\tresults[index] = null;\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tinFlight.delete(task);\n\t\t\t\tonLoaded();\n\t\t\t});\n\t\tinFlight.add(task);\n\t};\n\n\twhile (nextIndex < files.length || inFlight.size > 0) {\n\t\twhile (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) {\n\t\t\tstartNext();\n\t\t}\n\t\tif (inFlight.size > 0) {\n\t\t\tawait Promise.race(inFlight);\n\t\t}\n\t}\n\n\treturn results;\n}\n\nasync function listSessionsFromDir(\n\tdir: string,\n\tonProgress?: SessionListProgress,\n\tprogressOffset = 0,\n\tprogressTotal?: number,\n): Promise<SessionInfo[]> {\n\tconst sessions: SessionInfo[] = [];\n\tif (!existsSync(dir)) {\n\t\treturn sessions;\n\t}\n\n\ttry {\n\t\tconst dirEntries = await readdir(dir);\n\t\tconst files = dirEntries.filter((f) => f.endsWith(\".jsonl\")).map((f) => join(dir, f));\n\t\tconst total = progressTotal ?? files.length;\n\n\t\tlet loaded = 0;\n\t\tconst results = await buildSessionInfosWithConcurrency(files, () => {\n\t\t\tloaded++;\n\t\t\tonProgress?.(progressOffset + loaded, total);\n\t\t});\n\t\tfor (const info of results) {\n\t\t\tif (info) {\n\t\t\t\tsessions.push(info);\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// Return empty list on error\n\t}\n\n\treturn sessions;\n}\n\n/**\n * Manages conversation sessions as append-only trees stored in JSONL files.\n *\n * Each session entry has an id and parentId forming a tree structure. The \"leaf\"\n * pointer tracks the current position. Appending creates a child of the current leaf.\n * Branching moves the leaf to an earlier entry, allowing new branches without\n * modifying history.\n *\n * Use buildSessionContext() to get the resolved message list for the LLM, which\n * handles compaction summaries and follows the path from root to current leaf.\n */\nexport class SessionManager {\n\tprivate sessionId: string = \"\";\n\tprivate sessionFile: string | undefined;\n\tprivate sessionDir: string;\n\tprivate cwd: string;\n\tprivate persist: boolean;\n\tprivate flushed: boolean = false;\n\tprivate fileEntries: FileEntry[] = [];\n\tprivate byId: Map<string, SessionEntry> = new Map();\n\tprivate labelsById: Map<string, string> = new Map();\n\tprivate labelTimestampsById: Map<string, string> = new Map();\n\tprivate leafId: string | null = null;\n\n\tprivate constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {\n\t\tthis.cwd = resolvePath(cwd);\n\t\tthis.sessionDir = normalizePath(sessionDir);\n\t\tthis.persist = persist;\n\t\tif (persist && this.sessionDir && !existsSync(this.sessionDir)) {\n\t\t\tmkdirSync(this.sessionDir, { recursive: true });\n\t\t}\n\n\t\tif (sessionFile) {\n\t\t\tthis.setSessionFile(sessionFile);\n\t\t} else {\n\t\t\tthis.newSession();\n\t\t}\n\t}\n\n\t/** Switch to a different session file (used for resume and branching) */\n\tsetSessionFile(sessionFile: string): void {\n\t\tthis.sessionFile = resolvePath(sessionFile);\n\t\tif (existsSync(this.sessionFile)) {\n\t\t\tthis.fileEntries = loadEntriesFromFile(this.sessionFile);\n\n\t\t\t// If file was empty or corrupted (no valid header), truncate and start fresh\n\t\t\t// to avoid appending messages without a session header (which breaks the session)\n\t\t\tif (this.fileEntries.length === 0) {\n\t\t\t\tconst explicitPath = this.sessionFile;\n\t\t\t\tthis.newSession();\n\t\t\t\tthis.sessionFile = explicitPath;\n\t\t\t\tthis._rewriteFile();\n\t\t\t\tthis.flushed = true;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst header = this.fileEntries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\t\tthis.sessionId = header?.id ?? createSessionId();\n\n\t\t\tif (migrateToCurrentVersion(this.fileEntries)) {\n\t\t\t\tthis._rewriteFile();\n\t\t\t}\n\n\t\t\tthis._buildIndex();\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tconst explicitPath = this.sessionFile;\n\t\t\tthis.newSession();\n\t\t\tthis.sessionFile = explicitPath; // preserve explicit path from --session flag\n\t\t}\n\t}\n\n\tnewSession(options?: NewSessionOptions): string | undefined {\n\t\tthis.sessionId = options?.id ?? createSessionId();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: this.sessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: options?.parentSession,\n\t\t};\n\t\tthis.fileEntries = [header];\n\t\tthis.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.leafId = null;\n\t\tthis.flushed = false;\n\n\t\tif (this.persist) {\n\t\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\t\tthis.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);\n\t\t}\n\t\treturn this.sessionFile;\n\t}\n\n\tprivate _buildIndex(): void {\n\t\tthis.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.labelTimestampsById.clear();\n\t\tthis.leafId = null;\n\t\tfor (const entry of this.fileEntries) {\n\t\t\tif (entry.type === \"session\") continue;\n\t\t\tthis.byId.set(entry.id, entry);\n\t\t\tthis.leafId = entry.id;\n\t\t\tif (entry.type === \"label\") {\n\t\t\t\tif (entry.label) {\n\t\t\t\t\tthis.labelsById.set(entry.targetId, entry.label);\n\t\t\t\t\tthis.labelTimestampsById.set(entry.targetId, entry.timestamp);\n\t\t\t\t} else {\n\t\t\t\t\tthis.labelsById.delete(entry.targetId);\n\t\t\t\t\tthis.labelTimestampsById.delete(entry.targetId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate _rewriteFile(): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\t\tconst content = `${this.fileEntries.map((e) => JSON.stringify(e)).join(\"\\n\")}\\n`;\n\t\twriteFileSync(this.sessionFile, content);\n\t}\n\n\tisPersisted(): boolean {\n\t\treturn this.persist;\n\t}\n\n\tgetCwd(): string {\n\t\treturn this.cwd;\n\t}\n\n\tgetSessionDir(): string {\n\t\treturn this.sessionDir;\n\t}\n\n\tgetSessionId(): string {\n\t\treturn this.sessionId;\n\t}\n\n\tgetSessionFile(): string | undefined {\n\t\treturn this.sessionFile;\n\t}\n\n\t_persist(entry: SessionEntry): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\n\t\tconst hasAssistant = this.fileEntries.some((e) => e.type === \"message\" && e.message.role === \"assistant\");\n\t\tif (!hasAssistant) {\n\t\t\t// Mark as not flushed so when assistant arrives, all entries get written\n\t\t\tthis.flushed = false;\n\t\t\treturn;\n\t\t}\n\n\t\tif (!this.flushed) {\n\t\t\tfor (const e of this.fileEntries) {\n\t\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(e)}\\n`);\n\t\t\t}\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t}\n\t}\n\n\tprivate _appendEntry(entry: SessionEntry): void {\n\t\tthis.fileEntries.push(entry);\n\t\tthis.byId.set(entry.id, entry);\n\t\tthis.leafId = entry.id;\n\t\tthis._persist(entry);\n\t}\n\n\t/** Append a message as child of current leaf, then advance leaf. Returns entry id.\n\t * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.\n\t * Reason: we want these to be top-level entries in the session, not message session entries,\n\t * so it is easier to find them.\n\t * These need to be appended via appendCompaction() and appendBranchSummary() methods.\n\t */\n\tappendMessage(message: Message | CustomMessage | BashExecutionMessage): string {\n\t\tconst entry: SessionMessageEntry = {\n\t\t\ttype: \"message\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tmessage,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendThinkingLevelChange(thinkingLevel: string): string {\n\t\tconst entry: ThinkingLevelChangeEntry = {\n\t\t\ttype: \"thinking_level_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tthinkingLevel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a model change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendModelChange(provider: string, modelId: string): string {\n\t\tconst entry: ModelChangeEntry = {\n\t\t\ttype: \"model_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tprovider,\n\t\t\tmodelId,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCompaction<T = unknown>(\n\t\tsummary: string,\n\t\tfirstKeptEntryId: string,\n\t\ttokensBefore: number,\n\t\tdetails?: T,\n\t\tfromHook?: boolean,\n\t): string {\n\t\tconst entry: CompactionEntry<T> = {\n\t\t\ttype: \"compaction\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tsummary,\n\t\t\tfirstKeptEntryId,\n\t\t\ttokensBefore,\n\t\t\tdetails,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCustomEntry(customType: string, data?: unknown): string {\n\t\tconst entry: CustomEntry = {\n\t\t\ttype: \"custom\",\n\t\t\tcustomType,\n\t\t\tdata,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a session info entry (e.g., display name). Returns entry id. */\n\tappendSessionInfo(name: string): string {\n\t\tconst entry: SessionInfoEntry = {\n\t\t\ttype: \"session_info\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tname: name.trim(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Get the current session name from the latest session_info entry, if any. */\n\tgetSessionName(): string | undefined {\n\t\t// Walk entries in reverse to find the latest session_info entry.\n\t\t// Empty names explicitly clear the session title.\n\t\tconst entries = this.getEntries();\n\t\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\t\tconst entry = entries[i];\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\treturn entry.name?.trim() || undefined;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Append a custom message entry (for extensions) that participates in LLM context.\n\t * @param customType Extension identifier for filtering on reload\n\t * @param content Message content (string or TextContent/ImageContent array)\n\t * @param display Whether to show in TUI (true = styled display, false = hidden)\n\t * @param details Optional extension-specific metadata (not sent to LLM)\n\t * @returns Entry id\n\t */\n\tappendCustomMessageEntry<T = unknown>(\n\t\tcustomType: string,\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\tdisplay: boolean,\n\t\tdetails?: T,\n\t): string {\n\t\tconst entry: CustomMessageEntry<T> = {\n\t\t\ttype: \"custom_message\",\n\t\t\tcustomType,\n\t\t\tcontent,\n\t\t\tdisplay,\n\t\t\tdetails,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t// =========================================================================\n\t// Tree Traversal\n\t// =========================================================================\n\n\tgetLeafId(): string | null {\n\t\treturn this.leafId;\n\t}\n\n\tgetLeafEntry(): SessionEntry | undefined {\n\t\treturn this.leafId ? this.byId.get(this.leafId) : undefined;\n\t}\n\n\tgetEntry(id: string): SessionEntry | undefined {\n\t\treturn this.byId.get(id);\n\t}\n\n\t/**\n\t * Get all direct children of an entry.\n\t */\n\tgetChildren(parentId: string): SessionEntry[] {\n\t\tconst children: SessionEntry[] = [];\n\t\tfor (const entry of this.byId.values()) {\n\t\t\tif (entry.parentId === parentId) {\n\t\t\t\tchildren.push(entry);\n\t\t\t}\n\t\t}\n\t\treturn children;\n\t}\n\n\t/**\n\t * Get the label for an entry, if any.\n\t */\n\tgetLabel(id: string): string | undefined {\n\t\treturn this.labelsById.get(id);\n\t}\n\n\t/**\n\t * Set or clear a label on an entry.\n\t * Labels are user-defined markers for bookmarking/navigation.\n\t * Pass undefined or empty string to clear the label.\n\t */\n\tappendLabelChange(targetId: string, label: string | undefined): string {\n\t\tif (!this.byId.has(targetId)) {\n\t\t\tthrow new Error(`Entry ${targetId} not found`);\n\t\t}\n\t\tconst entry: LabelEntry = {\n\t\t\ttype: \"label\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\ttargetId,\n\t\t\tlabel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\tif (label) {\n\t\t\tthis.labelsById.set(targetId, label);\n\t\t\tthis.labelTimestampsById.set(targetId, entry.timestamp);\n\t\t} else {\n\t\t\tthis.labelsById.delete(targetId);\n\t\t\tthis.labelTimestampsById.delete(targetId);\n\t\t}\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Walk from entry to root, returning all entries in path order.\n\t * Includes all entry types (messages, compaction, model changes, etc.).\n\t * Use buildSessionContext() to get the resolved messages for the LLM.\n\t */\n\tgetBranch(fromId?: string): SessionEntry[] {\n\t\tconst path: SessionEntry[] = [];\n\t\tconst startId = fromId ?? this.leafId;\n\t\tlet current = startId ? this.byId.get(startId) : undefined;\n\t\twhile (current) {\n\t\t\tpath.unshift(current);\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t\treturn path;\n\t}\n\n\t/**\n\t * Build the session context (what gets sent to the LLM).\n\t * Uses tree traversal from current leaf.\n\t */\n\tbuildSessionContext(): SessionContext {\n\t\treturn buildSessionContext(this.getEntries(), this.leafId, this.byId);\n\t}\n\n\t/**\n\t * Get session header.\n\t */\n\tgetHeader(): SessionHeader | null {\n\t\tconst h = this.fileEntries.find((e) => e.type === \"session\");\n\t\treturn h ? (h as SessionHeader) : null;\n\t}\n\n\t/**\n\t * Get all session entries (excludes header). Returns a shallow copy.\n\t * The session is append-only: use appendXXX() to add entries, branch() to\n\t * change the leaf pointer. Entries cannot be modified or deleted.\n\t */\n\tgetEntries(): SessionEntry[] {\n\t\treturn this.fileEntries.filter((e): e is SessionEntry => e.type !== \"session\");\n\t}\n\n\t/**\n\t * Get the session as a tree structure. Returns a shallow defensive copy of all entries.\n\t * A well-formed session has exactly one root (first entry with parentId === null).\n\t * Orphaned entries (broken parent chain) are also returned as roots.\n\t */\n\tgetTree(): SessionTreeNode[] {\n\t\tconst entries = this.getEntries();\n\t\tconst nodeMap = new Map<string, SessionTreeNode>();\n\t\tconst roots: SessionTreeNode[] = [];\n\n\t\t// Create nodes with resolved labels\n\t\tfor (const entry of entries) {\n\t\t\tconst label = this.labelsById.get(entry.id);\n\t\t\tconst labelTimestamp = this.labelTimestampsById.get(entry.id);\n\t\t\tnodeMap.set(entry.id, { entry, children: [], label, labelTimestamp });\n\t\t}\n\n\t\t// Build tree\n\t\tfor (const entry of entries) {\n\t\t\tconst node = nodeMap.get(entry.id)!;\n\t\t\tif (entry.parentId === null || entry.parentId === entry.id) {\n\t\t\t\troots.push(node);\n\t\t\t} else {\n\t\t\t\tconst parent = nodeMap.get(entry.parentId);\n\t\t\t\tif (parent) {\n\t\t\t\t\tparent.children.push(node);\n\t\t\t\t} else {\n\t\t\t\t\t// Orphan - treat as root\n\t\t\t\t\troots.push(node);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Sort children by timestamp (oldest first, newest at bottom)\n\t\t// Use iterative approach to avoid stack overflow on deep trees\n\t\tconst stack: SessionTreeNode[] = [...roots];\n\t\twhile (stack.length > 0) {\n\t\t\tconst node = stack.pop()!;\n\t\t\tnode.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());\n\t\t\tstack.push(...node.children);\n\t\t}\n\n\t\treturn roots;\n\t}\n\n\t// =========================================================================\n\t// Branching\n\t// =========================================================================\n\n\t/**\n\t * Start a new branch from an earlier entry.\n\t * Moves the leaf pointer to the specified entry. The next appendXXX() call\n\t * will create a child of that entry, forming a new branch. Existing entries\n\t * are not modified or deleted.\n\t */\n\tbranch(branchFromId: string): void {\n\t\tif (!this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t}\n\n\t/**\n\t * Reset the leaf pointer to null (before any entries).\n\t * The next appendXXX() call will create a new root entry (parentId = null).\n\t * Use this when navigating to re-edit the first user message.\n\t */\n\tresetLeaf(): void {\n\t\tthis.leafId = null;\n\t}\n\n\t/**\n\t * Start a new branch with a summary of the abandoned path.\n\t * Same as branch(), but also appends a branch_summary entry that captures\n\t * context from the abandoned conversation path.\n\t */\n\tbranchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string {\n\t\tif (branchFromId !== null && !this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t\tconst entry: BranchSummaryEntry = {\n\t\t\ttype: \"branch_summary\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: branchFromId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tfromId: branchFromId ?? \"root\",\n\t\t\tsummary,\n\t\t\tdetails,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Create a new session file containing only the path from root to the specified leaf.\n\t * Useful for extracting a single conversation path from a branched session.\n\t * Returns the new session file path, or undefined if not persisting.\n\t */\n\tcreateBranchedSession(leafId: string): string | undefined {\n\t\tconst previousSessionFile = this.sessionFile;\n\t\tconst path = this.getBranch(leafId);\n\t\tif (path.length === 0) {\n\t\t\tthrow new Error(`Entry ${leafId} not found`);\n\t\t}\n\n\t\t// Filter out LabelEntry from path - we'll recreate them from the resolved map\n\t\tconst pathWithoutLabels = path.filter((e) => e.type !== \"label\");\n\n\t\tconst newSessionId = createSessionId();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);\n\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: newSessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: this.persist ? previousSessionFile : undefined,\n\t\t};\n\n\t\t// Collect labels for entries in the path\n\t\tconst pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));\n\t\tconst labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = [];\n\t\tfor (const [targetId, label] of this.labelsById) {\n\t\t\tif (pathEntryIds.has(targetId)) {\n\t\t\t\tlabelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId)! });\n\t\t\t}\n\t\t}\n\n\t\tif (this.persist) {\n\t\t\t// Build label entries\n\t\t\tconst lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;\n\t\t\tlet parentId = lastEntryId;\n\t\t\tconst labelEntries: LabelEntry[] = [];\n\t\t\tfor (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {\n\t\t\t\tconst labelEntry: LabelEntry = {\n\t\t\t\t\ttype: \"label\",\n\t\t\t\t\tid: generateId(new Set(pathEntryIds)),\n\t\t\t\t\tparentId,\n\t\t\t\t\ttimestamp: labelTimestamp,\n\t\t\t\t\ttargetId,\n\t\t\t\t\tlabel,\n\t\t\t\t};\n\t\t\t\tpathEntryIds.add(labelEntry.id);\n\t\t\t\tlabelEntries.push(labelEntry);\n\t\t\t\tparentId = labelEntry.id;\n\t\t\t}\n\n\t\t\tthis.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];\n\t\t\tthis.sessionId = newSessionId;\n\t\t\tthis.sessionFile = newSessionFile;\n\t\t\tthis._buildIndex();\n\n\t\t\t// Only write the file now if it contains an assistant message.\n\t\t\t// Otherwise defer to _persist(), which creates the file on the\n\t\t\t// first assistant response, matching the newSession() contract\n\t\t\t// and avoiding the duplicate-header bug when _persist()'s\n\t\t\t// no-assistant guard later resets flushed to false.\n\t\t\tconst hasAssistant = this.fileEntries.some((e) => e.type === \"message\" && e.message.role === \"assistant\");\n\t\t\tif (hasAssistant) {\n\t\t\t\tthis._rewriteFile();\n\t\t\t\tthis.flushed = true;\n\t\t\t} else {\n\t\t\t\tthis.flushed = false;\n\t\t\t}\n\n\t\t\treturn newSessionFile;\n\t\t}\n\n\t\t// In-memory mode: replace current session with the path + labels\n\t\tconst labelEntries: LabelEntry[] = [];\n\t\tlet parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;\n\t\tfor (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {\n\t\t\tconst labelEntry: LabelEntry = {\n\t\t\t\ttype: \"label\",\n\t\t\t\tid: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),\n\t\t\t\tparentId,\n\t\t\t\ttimestamp: labelTimestamp,\n\t\t\t\ttargetId,\n\t\t\t\tlabel,\n\t\t\t};\n\t\t\tlabelEntries.push(labelEntry);\n\t\t\tparentId = labelEntry.id;\n\t\t}\n\t\tthis.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];\n\t\tthis.sessionId = newSessionId;\n\t\tthis._buildIndex();\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Create a new session.\n\t * @param cwd Working directory (stored in session header)\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic create(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);\n\t\treturn new SessionManager(cwd, dir, undefined, true);\n\t}\n\n\t/**\n\t * Open a specific session file.\n\t * @param path Path to session file\n\t * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.\n\t * @param cwdOverride Optional cwd override instead of the session header cwd.\n\t */\n\tstatic open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {\n\t\tconst resolvedPath = resolvePath(path);\n\t\t// Extract cwd from session header if possible, otherwise use process.cwd()\n\t\tconst entries = loadEntriesFromFile(resolvedPath);\n\t\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\tconst cwd = cwdOverride ?? header?.cwd ?? process.cwd();\n\t\t// If no sessionDir provided, derive from file's parent directory\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, \"..\");\n\t\treturn new SessionManager(cwd, dir, resolvedPath, true);\n\t}\n\n\t/**\n\t * Continue the most recent session, or create new if none.\n\t * @param cwd Working directory\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic continueRecent(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);\n\t\tconst mostRecent = findMostRecentSession(dir);\n\t\tif (mostRecent) {\n\t\t\treturn new SessionManager(cwd, dir, mostRecent, true);\n\t\t}\n\t\treturn new SessionManager(cwd, dir, undefined, true);\n\t}\n\n\t/** Create an in-memory session (no file persistence) */\n\tstatic inMemory(cwd: string = process.cwd()): SessionManager {\n\t\treturn new SessionManager(cwd, \"\", undefined, false);\n\t}\n\n\t/**\n\t * Fork a session from another project directory into the current project.\n\t * Creates a new session in the target cwd with the full history from the source session.\n\t * @param sourcePath Path to the source session file\n\t * @param targetCwd Target working directory (where the new session will be stored)\n\t * @param sessionDir Optional session directory. If omitted, uses default for targetCwd.\n\t */\n\tstatic forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager {\n\t\tconst resolvedSourcePath = resolvePath(sourcePath);\n\t\tconst resolvedTargetCwd = resolvePath(targetCwd);\n\t\tconst sourceEntries = loadEntriesFromFile(resolvedSourcePath);\n\t\tif (sourceEntries.length === 0) {\n\t\t\tthrow new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`);\n\t\t}\n\n\t\tconst sourceHeader = sourceEntries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\tif (!sourceHeader) {\n\t\t\tthrow new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);\n\t\t}\n\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\n\t\t// Create new session file with new ID but forked content\n\t\tconst newSessionId = createSessionId();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);\n\n\t\t// Write new header pointing to source as parent, with updated cwd\n\t\tconst newHeader: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: newSessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: resolvedTargetCwd,\n\t\t\tparentSession: resolvedSourcePath,\n\t\t};\n\t\tappendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\\n`);\n\n\t\t// Copy all non-header entries from source\n\t\tfor (const entry of sourceEntries) {\n\t\t\tif (entry.type !== \"session\") {\n\t\t\t\tappendFileSync(newSessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t\t}\n\t\t}\n\n\t\treturn new SessionManager(resolvedTargetCwd, dir, newSessionFile, true);\n\t}\n\n\t/**\n\t * List all sessions for a directory.\n\t * @param cwd Working directory (used to compute default session directory)\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);\n\t\tconst sessions = await listSessionsFromDir(dir, onProgress);\n\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\treturn sessions;\n\t}\n\n\t/**\n\t * List all sessions across all project directories.\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async listAll(onProgress?: SessionListProgress): Promise<SessionInfo[]> {\n\t\tconst sessionsDir = getSessionsDir();\n\n\t\ttry {\n\t\t\tif (!existsSync(sessionsDir)) {\n\t\t\t\treturn [];\n\t\t\t}\n\t\t\tconst entries = await readdir(sessionsDir, { withFileTypes: true });\n\t\t\tconst dirs = entries.filter((e) => e.isDirectory()).map((e) => join(sessionsDir, e.name));\n\n\t\t\t// Count total files first for accurate progress\n\t\t\tlet totalFiles = 0;\n\t\t\tconst dirFiles: string[][] = [];\n\t\t\tfor (const dir of dirs) {\n\t\t\t\ttry {\n\t\t\t\t\tconst files = (await readdir(dir)).filter((f) => f.endsWith(\".jsonl\"));\n\t\t\t\t\tdirFiles.push(files.map((f) => join(dir, f)));\n\t\t\t\t\ttotalFiles += files.length;\n\t\t\t\t} catch {\n\t\t\t\t\tdirFiles.push([]);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Process all files with progress tracking\n\t\t\tlet loaded = 0;\n\t\t\tconst sessions: SessionInfo[] = [];\n\t\t\tconst allFiles = dirFiles.flat();\n\n\t\t\tconst results = await buildSessionInfosWithConcurrency(allFiles, () => {\n\t\t\t\tloaded++;\n\t\t\t\tonProgress?.(loaded, totalFiles);\n\t\t\t});\n\n\t\t\tfor (const info of results) {\n\t\t\t\tif (info) {\n\t\t\t\t\tsessions.push(info);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\t\treturn sessions;\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n}\n"}],"isError":false,"timestamp":1781615134091}}
|
||
{"type":"message","id":"9b25fc2a","parentId":"dc024129","timestamp":"2026-06-16T13:05:37.221Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我查看一下 agent session 是如何加载上下文的。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_39013c9675284db88d4914d1","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/agent-session.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":11174,"output":55,"cacheRead":63936,"cacheWrite":0,"totalTokens":75165,"cost":{"input":0.004469600000000001,"output":0.00010999999999999999,"cacheRead":0.0051148800000000005,"cacheWrite":0,"total":0.009694480000000002}},"stopReason":"toolUse","timestamp":1781615134097,"responseId":"4ded69b01016438f961c15acf6410e0b"}}
|
||
{"type":"message","id":"8ce5280e","parentId":"9b25fc2a","timestamp":"2026-06-16T13:05:37.231Z","message":{"role":"toolResult","toolCallId":"call_39013c9675284db88d4914d1","toolName":"read","content":[{"type":"text","text":"/**\n * AgentSession - Core abstraction for agent lifecycle and session management.\n *\n * This class is shared between all run modes (interactive, print, rpc).\n * It encapsulates:\n * - Agent state access\n * - Event subscription with automatic session persistence\n * - Model and thinking level management\n * - Compaction (manual and auto)\n * - Bash execution\n * - Session switching and branching\n *\n * Modes use this class and add their own I/O layer on top.\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { basename, dirname } from \"node:path\";\nimport type {\n\tAgent,\n\tAgentEvent,\n\tAgentMessage,\n\tAgentState,\n\tAgentTool,\n\tThinkingLevel,\n} from \"@earendil-works/pi-agent-core\";\nimport type { AssistantMessage, ImageContent, Message, Model, TextContent } from \"@earendil-works/pi-ai\";\nimport {\n\tclampThinkingLevel,\n\tcleanupSessionResources,\n\tgetSupportedThinkingLevels,\n\tisContextOverflow,\n\tmodelsAreEqual,\n\tresetApiProviders,\n\tstreamSimple,\n} from \"@earendil-works/pi-ai\";\nimport { theme } from \"../modes/interactive/theme/theme.ts\";\nimport { stripFrontmatter } from \"../utils/frontmatter.ts\";\nimport { resolvePath } from \"../utils/paths.ts\";\nimport { sleep } from \"../utils/sleep.ts\";\nimport { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from \"./auth-guidance.ts\";\nimport { type BashResult, executeBashWithOperations } from \"./bash-executor.ts\";\nimport {\n\ttype CompactionResult,\n\tcalculateContextTokens,\n\tcollectEntriesForBranchSummary,\n\tcompact,\n\testimateContextTokens,\n\tgenerateBranchSummary,\n\tprepareCompaction,\n\tshouldCompact,\n} from \"./compaction/index.ts\";\nimport { DEFAULT_THINKING_LEVEL } from \"./defaults.ts\";\nimport { exportSessionToHtml, type ToolHtmlRenderer } from \"./export-html/index.ts\";\nimport { createToolHtmlRenderer } from \"./export-html/tool-renderer.ts\";\nimport {\n\ttype ContextUsage,\n\ttype ExtensionCommandContextActions,\n\ttype ExtensionErrorListener,\n\tExtensionRunner,\n\ttype ExtensionUIContext,\n\ttype InputSource,\n\ttype MessageEndEvent,\n\ttype MessageStartEvent,\n\ttype MessageUpdateEvent,\n\ttype ReplacedSessionContext,\n\ttype SessionBeforeCompactResult,\n\ttype SessionBeforeTreeResult,\n\ttype SessionStartEvent,\n\ttype ShutdownHandler,\n\ttype ToolDefinition,\n\ttype ToolExecutionEndEvent,\n\ttype ToolExecutionStartEvent,\n\ttype ToolExecutionUpdateEvent,\n\ttype ToolInfo,\n\ttype TreePreparation,\n\ttype TurnEndEvent,\n\ttype TurnStartEvent,\n\twrapRegisteredTools,\n} from \"./extensions/index.ts\";\nimport { emitSessionShutdownEvent } from \"./extensions/runner.ts\";\nimport type { BashExecutionMessage, CustomMessage } from \"./messages.ts\";\nimport type { ModelRegistry } from \"./model-registry.ts\";\nimport { expandPromptTemplate, type PromptTemplate } from \"./prompt-templates.ts\";\nimport type { ResourceExtensionPaths, ResourceLoader } from \"./resource-loader.ts\";\nimport type { BranchSummaryEntry, CompactionEntry, SessionManager } from \"./session-manager.ts\";\nimport { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from \"./session-manager.ts\";\nimport type { SettingsManager } from \"./settings-manager.ts\";\nimport type { SlashCommandInfo } from \"./slash-commands.ts\";\nimport { createSyntheticSourceInfo, type SourceInfo } from \"./source-info.ts\";\nimport { type BuildSystemPromptOptions, buildSystemPrompt } from \"./system-prompt.ts\";\nimport { type BashOperations, createLocalBashOperations } from \"./tools/bash.ts\";\nimport { createAllToolDefinitions } from \"./tools/index.ts\";\nimport { createToolDefinitionFromAgentTool } from \"./tools/tool-definition-wrapper.ts\";\n\n// ============================================================================\n// Skill Block Parsing\n// ============================================================================\n\n/** Parsed skill block from a user message */\nexport interface ParsedSkillBlock {\n\tname: string;\n\tlocation: string;\n\tcontent: string;\n\tuserMessage: string | undefined;\n}\n\n/**\n * Parse a skill block from message text.\n * Returns null if the text doesn't contain a skill block.\n */\nexport function parseSkillBlock(text: string): ParsedSkillBlock | null {\n\tconst match = text.match(/^<skill name=\"([^\"]+)\" location=\"([^\"]+)\">\\n([\\s\\S]*?)\\n<\\/skill>(?:\\n\\n([\\s\\S]+))?$/);\n\tif (!match) return null;\n\treturn {\n\t\tname: match[1],\n\t\tlocation: match[2],\n\t\tcontent: match[3],\n\t\tuserMessage: match[4]?.trim() || undefined,\n\t};\n}\n\n/** Session-specific events that extend the core AgentEvent */\nexport type AgentSessionEvent =\n\t| Exclude<AgentEvent, { type: \"agent_end\" }>\n\t| {\n\t\t\ttype: \"agent_end\";\n\t\t\tmessages: AgentMessage[];\n\t\t\twillRetry: boolean;\n\t }\n\t| {\n\t\t\ttype: \"queue_update\";\n\t\t\tsteering: readonly string[];\n\t\t\tfollowUp: readonly string[];\n\t }\n\t| { type: \"compaction_start\"; reason: \"manual\" | \"threshold\" | \"overflow\" }\n\t| { type: \"session_info_changed\"; name: string | undefined }\n\t| { type: \"thinking_level_changed\"; level: ThinkingLevel }\n\t| {\n\t\t\ttype: \"compaction_end\";\n\t\t\treason: \"manual\" | \"threshold\" | \"overflow\";\n\t\t\tresult: CompactionResult | undefined;\n\t\t\taborted: boolean;\n\t\t\twillRetry: boolean;\n\t\t\terrorMessage?: string;\n\t }\n\t| { type: \"auto_retry_start\"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }\n\t| { type: \"auto_retry_end\"; success: boolean; attempt: number; finalError?: string };\n\n/** Listener function for agent session events */\nexport type AgentSessionEventListener = (event: AgentSessionEvent) => void;\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface AgentSessionConfig {\n\tagent: Agent;\n\tsessionManager: SessionManager;\n\tsettingsManager: SettingsManager;\n\tcwd: string;\n\t/** Models to cycle through with Ctrl+P (from --models flag) */\n\tscopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;\n\t/** Resource loader for skills, prompts, themes, context files, system prompt */\n\tresourceLoader: ResourceLoader;\n\t/** SDK custom tools registered outside extensions */\n\tcustomTools?: ToolDefinition[];\n\t/** Model registry for API key resolution and model discovery */\n\tmodelRegistry: ModelRegistry;\n\t/** Initial active built-in tool names. Default: [read, bash, edit, write] */\n\tinitialActiveToolNames?: string[];\n\t/** Optional allowlist of tool names. When provided, only these tool names are exposed. */\n\tallowedToolNames?: string[];\n\t/**\n\t * Override base tools (useful for custom runtimes).\n\t *\n\t * These are synthesized into minimal ToolDefinitions internally so AgentSession can keep\n\t * a definition-first registry even when callers provide plain AgentTool instances.\n\t */\n\tbaseToolsOverride?: Record<string, AgentTool>;\n\t/** Mutable ref used by Agent to access the current ExtensionRunner */\n\textensionRunnerRef?: { current?: ExtensionRunner };\n\t/** Session start event metadata emitted when extensions bind to this runtime. */\n\tsessionStartEvent?: SessionStartEvent;\n}\n\nexport interface ExtensionBindings {\n\tuiContext?: ExtensionUIContext;\n\tcommandContextActions?: ExtensionCommandContextActions;\n\tabortHandler?: () => void;\n\tshutdownHandler?: ShutdownHandler;\n\tonError?: ExtensionErrorListener;\n}\n\n/** Options for AgentSession.prompt() */\nexport interface PromptOptions {\n\t/** Whether to expand file-based prompt templates (default: true) */\n\texpandPromptTemplates?: boolean;\n\t/** Image attachments */\n\timages?: ImageContent[];\n\t/** When streaming, how to queue the message: \"steer\" (interrupt) or \"followUp\" (wait). Required if streaming. */\n\tstreamingBehavior?: \"steer\" | \"followUp\";\n\t/** Source of input for extension input event handlers. Defaults to \"interactive\". */\n\tsource?: InputSource;\n\t/** Internal hook used by RPC mode to observe prompt preflight acceptance or rejection. */\n\tpreflightResult?: (success: boolean) => void;\n}\n\n/** Result from cycleModel() */\nexport interface ModelCycleResult {\n\tmodel: Model<any>;\n\tthinkingLevel: ThinkingLevel;\n\t/** Whether cycling through scoped models (--models flag) or all available */\n\tisScoped: boolean;\n}\n\n/** Session statistics for /session command */\nexport interface SessionStats {\n\tsessionFile: string | undefined;\n\tsessionId: string;\n\tuserMessages: number;\n\tassistantMessages: number;\n\ttoolCalls: number;\n\ttoolResults: number;\n\ttotalMessages: number;\n\ttokens: {\n\t\tinput: number;\n\t\toutput: number;\n\t\tcacheRead: number;\n\t\tcacheWrite: number;\n\t\ttotal: number;\n\t};\n\tcost: number;\n\tcontextUsage?: ContextUsage;\n}\n\ninterface ToolDefinitionEntry {\n\tdefinition: ToolDefinition;\n\tsourceInfo: SourceInfo;\n}\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/** Standard thinking levels */\nconst THINKING_LEVELS: ThinkingLevel[] = [\"off\", \"minimal\", \"low\", \"medium\", \"high\"];\n\n// ============================================================================\n// AgentSession Class\n// ============================================================================\n\nexport class AgentSession {\n\treadonly agent: Agent;\n\treadonly sessionManager: SessionManager;\n\treadonly settingsManager: SettingsManager;\n\n\tprivate _scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;\n\n\t// Event subscription state\n\tprivate _unsubscribeAgent?: () => void;\n\tprivate _eventListeners: AgentSessionEventListener[] = [];\n\n\t/** Tracks pending steering messages for UI display. Removed when delivered. */\n\tprivate _steeringMessages: string[] = [];\n\t/** Tracks pending follow-up messages for UI display. Removed when delivered. */\n\tprivate _followUpMessages: string[] = [];\n\t/** Messages queued to be included with the next user prompt as context (\"asides\"). */\n\tprivate _pendingNextTurnMessages: CustomMessage[] = [];\n\n\t// Compaction state\n\tprivate _compactionAbortController: AbortController | undefined = undefined;\n\tprivate _autoCompactionAbortController: AbortController | undefined = undefined;\n\tprivate _overflowRecoveryAttempted = false;\n\n\t// Branch summarization state\n\tprivate _branchSummaryAbortController: AbortController | undefined = undefined;\n\n\t// Retry state\n\tprivate _retryAbortController: AbortController | undefined = undefined;\n\tprivate _retryAttempt = 0;\n\n\t// Bash execution state\n\tprivate _bashAbortController: AbortController | undefined = undefined;\n\tprivate _pendingBashMessages: BashExecutionMessage[] = [];\n\n\t// Extension system\n\tprivate _extensionRunner!: ExtensionRunner;\n\tprivate _turnIndex = 0;\n\n\tprivate _resourceLoader: ResourceLoader;\n\tprivate _customTools: ToolDefinition[];\n\tprivate _baseToolDefinitions: Map<string, ToolDefinition> = new Map();\n\tprivate _cwd: string;\n\tprivate _extensionRunnerRef?: { current?: ExtensionRunner };\n\tprivate _initialActiveToolNames?: string[];\n\tprivate _allowedToolNames?: Set<string>;\n\tprivate _baseToolsOverride?: Record<string, AgentTool>;\n\tprivate _sessionStartEvent: SessionStartEvent;\n\tprivate _extensionUIContext?: ExtensionUIContext;\n\tprivate _extensionCommandContextActions?: ExtensionCommandContextActions;\n\tprivate _extensionAbortHandler?: () => void;\n\tprivate _extensionShutdownHandler?: ShutdownHandler;\n\tprivate _extensionErrorListener?: ExtensionErrorListener;\n\tprivate _extensionErrorUnsubscriber?: () => void;\n\n\t// Model registry for API key resolution\n\tprivate _modelRegistry: ModelRegistry;\n\n\t// Tool registry for extension getTools/setTools\n\tprivate _toolRegistry: Map<string, AgentTool> = new Map();\n\tprivate _toolDefinitions: Map<string, ToolDefinitionEntry> = new Map();\n\tprivate _toolPromptSnippets: Map<string, string> = new Map();\n\tprivate _toolPromptGuidelines: Map<string, string[]> = new Map();\n\n\t// Base system prompt (without extension appends) - used to apply fresh appends each turn\n\tprivate _baseSystemPrompt = \"\";\n\tprivate _baseSystemPromptOptions!: BuildSystemPromptOptions;\n\n\tconstructor(config: AgentSessionConfig) {\n\t\tthis.agent = config.agent;\n\t\tthis.sessionManager = config.sessionManager;\n\t\tthis.settingsManager = config.settingsManager;\n\t\tthis._scopedModels = config.scopedModels ?? [];\n\t\tthis._resourceLoader = config.resourceLoader;\n\t\tthis._customTools = config.customTools ?? [];\n\t\tthis._cwd = config.cwd;\n\t\tthis._modelRegistry = config.modelRegistry;\n\t\tthis._extensionRunnerRef = config.extensionRunnerRef;\n\t\tthis._initialActiveToolNames = config.initialActiveToolNames;\n\t\tthis._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined;\n\t\tthis._baseToolsOverride = config.baseToolsOverride;\n\t\tthis._sessionStartEvent = config.sessionStartEvent ?? { type: \"session_start\", reason: \"startup\" };\n\n\t\t// Always subscribe to agent events for internal handling\n\t\t// (session persistence, extensions, auto-compaction, retry logic)\n\t\tthis._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);\n\t\tthis._installAgentToolHooks();\n\n\t\tthis._buildRuntime({\n\t\t\tactiveToolNames: this._initialActiveToolNames,\n\t\t\tincludeAllExtensionTools: true,\n\t\t});\n\t}\n\n\t/** Model registry for API key resolution and model discovery */\n\tget modelRegistry(): ModelRegistry {\n\t\treturn this._modelRegistry;\n\t}\n\n\tprivate async _getRequiredRequestAuth(model: Model<any>): Promise<{\n\t\tapiKey: string;\n\t\theaders?: Record<string, string>;\n\t}> {\n\t\tconst result = await this._modelRegistry.getApiKeyAndHeaders(model);\n\t\tif (!result.ok) {\n\t\t\tif (result.error.startsWith(\"No API key found\")) {\n\t\t\t\tthrow new Error(formatNoApiKeyFoundMessage(model.provider));\n\t\t\t}\n\t\t\tthrow new Error(result.error);\n\t\t}\n\t\tif (result.apiKey) {\n\t\t\treturn { apiKey: result.apiKey, headers: result.headers };\n\t\t}\n\n\t\tconst isOAuth = this._modelRegistry.isUsingOAuth(model);\n\t\tif (isOAuth) {\n\t\t\tthrow new Error(\n\t\t\t\t`Authentication failed for \"${model.provider}\". ` +\n\t\t\t\t\t`Credentials may have expired or network is unavailable. ` +\n\t\t\t\t\t`Run '/login ${model.provider}' to re-authenticate.`,\n\t\t\t);\n\t\t}\n\t\tthrow new Error(formatNoApiKeyFoundMessage(model.provider));\n\t}\n\n\tprivate async _getCompactionRequestAuth(model: Model<any>): Promise<{\n\t\tapiKey?: string;\n\t\theaders?: Record<string, string>;\n\t}> {\n\t\tif (this.agent.streamFn === streamSimple) {\n\t\t\treturn this._getRequiredRequestAuth(model);\n\t\t}\n\n\t\tconst result = await this._modelRegistry.getApiKeyAndHeaders(model);\n\t\treturn result.ok ? { apiKey: result.apiKey, headers: result.headers } : {};\n\t}\n\n\t/**\n\t * Install tool hooks once on the Agent instance.\n\t *\n\t * The callbacks read `this._extensionRunner` at execution time, so extension reload swaps in the\n\t * new runner without reinstalling hooks. Extension-specific tool wrappers are still used to adapt\n\t * registered tool execution to the extension context. Tool call and tool result interception now\n\t * happens here instead of in wrappers.\n\t */\n\tprivate _installAgentToolHooks(): void {\n\t\tthis.agent.beforeToolCall = async ({ toolCall, args }) => {\n\t\t\tconst runner = this._extensionRunner;\n\t\t\tif (!runner.hasHandlers(\"tool_call\")) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\treturn await runner.emitToolCall({\n\t\t\t\t\ttype: \"tool_call\",\n\t\t\t\t\ttoolName: toolCall.name,\n\t\t\t\t\ttoolCallId: toolCall.id,\n\t\t\t\t\tinput: args as Record<string, unknown>,\n\t\t\t\t});\n\t\t\t} catch (err) {\n\t\t\t\tif (err instanceof Error) {\n\t\t\t\t\tthrow err;\n\t\t\t\t}\n\t\t\t\tthrow new Error(`Extension failed, blocking execution: ${String(err)}`);\n\t\t\t}\n\t\t};\n\n\t\tthis.agent.afterToolCall = async ({ toolCall, args, result, isError }) => {\n\t\t\tconst runner = this._extensionRunner;\n\t\t\tif (!runner.hasHandlers(\"tool_result\")) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\n\t\t\tconst hookResult = await runner.emitToolResult({\n\t\t\t\ttype: \"tool_result\",\n\t\t\t\ttoolName: toolCall.name,\n\t\t\t\ttoolCallId: toolCall.id,\n\t\t\t\tinput: args as Record<string, unknown>,\n\t\t\t\tcontent: result.content,\n\t\t\t\tdetails: result.details,\n\t\t\t\tisError,\n\t\t\t});\n\n\t\t\tif (!hookResult) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tcontent: hookResult.content,\n\t\t\t\tdetails: hookResult.details,\n\t\t\t\tisError: hookResult.isError ?? isError,\n\t\t\t};\n\t\t};\n\t}\n\n\t// =========================================================================\n\t// Event Subscription\n\t// =========================================================================\n\n\t/** Emit an event to all listeners */\n\tprivate _emit(event: AgentSessionEvent): void {\n\t\tfor (const l of this._eventListeners) {\n\t\t\tl(event);\n\t\t}\n\t}\n\n\tprivate _emitQueueUpdate(): void {\n\t\tthis._emit({\n\t\t\ttype: \"queue_update\",\n\t\t\tsteering: [...this._steeringMessages],\n\t\t\tfollowUp: [...this._followUpMessages],\n\t\t});\n\t}\n\n\t// Track last assistant message for auto-compaction check\n\tprivate _lastAssistantMessage: AssistantMessage | undefined = undefined;\n\n\t/** Internal handler for agent events - shared by subscribe and reconnect */\n\tprivate _handleAgentEvent = async (event: AgentEvent): Promise<void> => {\n\t\t// When a user message starts, check if it's from either queue and remove it BEFORE emitting\n\t\t// This ensures the UI sees the updated queue state\n\t\tif (event.type === \"message_start\" && event.message.role === \"user\") {\n\t\t\tthis._overflowRecoveryAttempted = false;\n\t\t\tconst messageText = this._getUserMessageText(event.message);\n\t\t\tif (messageText) {\n\t\t\t\t// Check steering queue first\n\t\t\t\tconst steeringIndex = this._steeringMessages.indexOf(messageText);\n\t\t\t\tif (steeringIndex !== -1) {\n\t\t\t\t\tthis._steeringMessages.splice(steeringIndex, 1);\n\t\t\t\t\tthis._emitQueueUpdate();\n\t\t\t\t} else {\n\t\t\t\t\t// Check follow-up queue\n\t\t\t\t\tconst followUpIndex = this._followUpMessages.indexOf(messageText);\n\t\t\t\t\tif (followUpIndex !== -1) {\n\t\t\t\t\t\tthis._followUpMessages.splice(followUpIndex, 1);\n\t\t\t\t\t\tthis._emitQueueUpdate();\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Emit to extensions first\n\t\tawait this._emitExtensionEvent(event);\n\n\t\t// Notify all listeners\n\t\tthis._emit(event.type === \"agent_end\" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);\n\n\t\t// Handle session persistence\n\t\tif (event.type === \"message_end\") {\n\t\t\t// Check if this is a custom message from extensions\n\t\t\tif (event.message.role === \"custom\") {\n\t\t\t\t// Persist as CustomMessageEntry\n\t\t\t\tthis.sessionManager.appendCustomMessageEntry(\n\t\t\t\t\tevent.message.customType,\n\t\t\t\t\tevent.message.content,\n\t\t\t\t\tevent.message.display,\n\t\t\t\t\tevent.message.details,\n\t\t\t\t);\n\t\t\t} else if (\n\t\t\t\tevent.message.role === \"user\" ||\n\t\t\t\tevent.message.role === \"assistant\" ||\n\t\t\t\tevent.message.role === \"toolResult\"\n\t\t\t) {\n\t\t\t\t// Regular LLM message - persist as SessionMessageEntry\n\t\t\t\tthis.sessionManager.appendMessage(event.message);\n\t\t\t}\n\t\t\t// Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere\n\n\t\t\t// Track assistant message for auto-compaction (checked on agent_end)\n\t\t\tif (event.message.role === \"assistant\") {\n\t\t\t\tthis._lastAssistantMessage = event.message;\n\n\t\t\t\tconst assistantMsg = event.message as AssistantMessage;\n\t\t\t\tif (assistantMsg.stopReason !== \"error\") {\n\t\t\t\t\tthis._overflowRecoveryAttempted = false;\n\t\t\t\t}\n\n\t\t\t\t// Reset retry counter immediately on successful assistant response\n\t\t\t\t// This prevents accumulation across multiple LLM calls within a turn\n\t\t\t\tif (assistantMsg.stopReason !== \"error\" && this._retryAttempt > 0) {\n\t\t\t\t\tthis._emit({\n\t\t\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tattempt: this._retryAttempt,\n\t\t\t\t\t});\n\t\t\t\t\tthis._retryAttempt = 0;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t};\n\n\tprivate _willRetryAfterAgentEnd(event: Extract<AgentEvent, { type: \"agent_end\" }>): boolean {\n\t\tconst settings = this.settingsManager.getRetrySettings();\n\t\tif (!settings.enabled || this._retryAttempt >= settings.maxRetries) {\n\t\t\treturn false;\n\t\t}\n\n\t\tfor (let i = event.messages.length - 1; i >= 0; i--) {\n\t\t\tconst message = event.messages[i];\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\treturn this._isRetryableError(message as AssistantMessage);\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t}\n\n\t/** Extract text content from a message */\n\tprivate _getUserMessageText(message: Message): string {\n\t\tif (message.role !== \"user\") return \"\";\n\t\tconst content = message.content;\n\t\tif (typeof content === \"string\") return content;\n\t\tconst textBlocks = content.filter((c) => c.type === \"text\");\n\t\treturn textBlocks.map((c) => (c as TextContent).text).join(\"\");\n\t}\n\n\t/** Find the last assistant message in agent state (including aborted ones) */\n\tprivate _findLastAssistantMessage(): AssistantMessage | undefined {\n\t\tconst messages = this.agent.state.messages;\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst msg = messages[i];\n\t\t\tif (msg.role === \"assistant\") {\n\t\t\t\treturn msg as AssistantMessage;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate _replaceMessageInPlace(target: AgentMessage, replacement: AgentMessage): void {\n\t\t// Agent-core stores the finalized message object in its state before emitting message_end.\n\t\t// SessionManager persistence happens later in _handleAgentEvent() with event.message.\n\t\t// Mutating this object in place keeps agent state, later turn/agent events, listeners,\n\t\t// and the eventual SessionManager.appendMessage(event.message) persistence in sync.\n\t\tif (target === replacement) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst targetRecord = target as unknown as Record<string, unknown>;\n\t\tfor (const key of Object.keys(targetRecord)) {\n\t\t\tdelete targetRecord[key];\n\t\t}\n\t\tObject.assign(targetRecord, replacement);\n\t}\n\n\t/** Emit extension events based on agent events */\n\tprivate async _emitExtensionEvent(event: AgentEvent): Promise<void> {\n\t\tif (event.type === \"agent_start\") {\n\t\t\tthis._turnIndex = 0;\n\t\t\tawait this._extensionRunner.emit({ type: \"agent_start\" });\n\t\t} else if (event.type === \"agent_end\") {\n\t\t\tawait this._extensionRunner.emit({ type: \"agent_end\", messages: event.messages });\n\t\t} else if (event.type === \"turn_start\") {\n\t\t\tconst extensionEvent: TurnStartEvent = {\n\t\t\t\ttype: \"turn_start\",\n\t\t\t\tturnIndex: this._turnIndex,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t} else if (event.type === \"turn_end\") {\n\t\t\tconst extensionEvent: TurnEndEvent = {\n\t\t\t\ttype: \"turn_end\",\n\t\t\t\tturnIndex: this._turnIndex,\n\t\t\t\tmessage: event.message,\n\t\t\t\ttoolResults: event.toolResults,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t\tthis._turnIndex++;\n\t\t} else if (event.type === \"message_start\") {\n\t\t\tconst extensionEvent: MessageStartEvent = {\n\t\t\t\ttype: \"message_start\",\n\t\t\t\tmessage: event.message,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t} else if (event.type === \"message_update\") {\n\t\t\tconst extensionEvent: MessageUpdateEvent = {\n\t\t\t\ttype: \"message_update\",\n\t\t\t\tmessage: event.message,\n\t\t\t\tassistantMessageEvent: event.assistantMessageEvent,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t} else if (event.type === \"message_end\") {\n\t\t\tconst extensionEvent: MessageEndEvent = {\n\t\t\t\ttype: \"message_end\",\n\t\t\t\tmessage: event.message,\n\t\t\t};\n\t\t\tconst replacement = await this._extensionRunner.emitMessageEnd(extensionEvent);\n\t\t\tif (replacement) {\n\t\t\t\tthis._replaceMessageInPlace(event.message, replacement);\n\t\t\t}\n\t\t} else if (event.type === \"tool_execution_start\") {\n\t\t\tconst extensionEvent: ToolExecutionStartEvent = {\n\t\t\t\ttype: \"tool_execution_start\",\n\t\t\t\ttoolCallId: event.toolCallId,\n\t\t\t\ttoolName: event.toolName,\n\t\t\t\targs: event.args,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t} else if (event.type === \"tool_execution_update\") {\n\t\t\tconst extensionEvent: ToolExecutionUpdateEvent = {\n\t\t\t\ttype: \"tool_execution_update\",\n\t\t\t\ttoolCallId: event.toolCallId,\n\t\t\t\ttoolName: event.toolName,\n\t\t\t\targs: event.args,\n\t\t\t\tpartialResult: event.partialResult,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t} else if (event.type === \"tool_execution_end\") {\n\t\t\tconst extensionEvent: ToolExecutionEndEvent = {\n\t\t\t\ttype: \"tool_execution_end\",\n\t\t\t\ttoolCallId: event.toolCallId,\n\t\t\t\ttoolName: event.toolName,\n\t\t\t\tresult: event.result,\n\t\t\t\tisError: event.isError,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t}\n\t}\n\n\t/**\n\t * Subscribe to agent events.\n\t * Session persistence is handled internally (saves messages on message_end).\n\t * Multiple listeners can be added. Returns unsubscribe function for this listener.\n\t */\n\tsubscribe(listener: AgentSessionEventListener): () => void {\n\t\tthis._eventListeners.push(listener);\n\n\t\t// Return unsubscribe function for this specific listener\n\t\treturn () => {\n\t\t\tconst index = this._eventListeners.indexOf(listener);\n\t\t\tif (index !== -1) {\n\t\t\t\tthis._eventListeners.splice(index, 1);\n\t\t\t}\n\t\t};\n\t}\n\n\t/**\n\t * Temporarily disconnect from agent events.\n\t * User listeners are preserved and will receive events again after resubscribe().\n\t * Used internally during operations that need to pause event processing.\n\t */\n\tprivate _disconnectFromAgent(): void {\n\t\tif (this._unsubscribeAgent) {\n\t\t\tthis._unsubscribeAgent();\n\t\t\tthis._unsubscribeAgent = undefined;\n\t\t}\n\t}\n\n\t/**\n\t * Reconnect to agent events after _disconnectFromAgent().\n\t * Preserves all existing listeners.\n\t */\n\tprivate _reconnectToAgent(): void {\n\t\tif (this._unsubscribeAgent) return; // Already connected\n\t\tthis._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);\n\t}\n\n\t/**\n\t * Remove all listeners and disconnect from agent.\n\t * Call this when completely done with the session.\n\t */\n\tdispose(): void {\n\t\tthis._extensionRunner.invalidate(\n\t\t\t\"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().\",\n\t\t);\n\t\tthis._disconnectFromAgent();\n\t\tthis._eventListeners = [];\n\t\tcleanupSessionResources(this.sessionId);\n\t}\n\n\t// =========================================================================\n\t// Read-only State Access\n\t// =========================================================================\n\n\t/** Full agent state */\n\tget state(): AgentState {\n\t\treturn this.agent.state;\n\t}\n\n\t/** Current model (may be undefined if not yet selected) */\n\tget model(): Model<any> | undefined {\n\t\treturn this.agent.state.model;\n\t}\n\n\t/** Current thinking level */\n\tget thinkingLevel(): ThinkingLevel {\n\t\treturn this.agent.state.thinkingLevel;\n\t}\n\n\t/** Whether agent is currently streaming a response */\n\tget isStreaming(): boolean {\n\t\treturn this.agent.state.isStreaming;\n\t}\n\n\t/** Current effective system prompt (includes any per-turn extension modifications) */\n\tget systemPrompt(): string {\n\t\treturn this.agent.state.systemPrompt;\n\t}\n\n\t/** Current retry attempt (0 if not retrying) */\n\tget retryAttempt(): number {\n\t\treturn this._retryAttempt;\n\t}\n\n\t/**\n\t * Get the names of currently active tools.\n\t * Returns the names of tools currently set on the agent.\n\t */\n\tgetActiveToolNames(): string[] {\n\t\treturn this.agent.state.tools.map((t) => t.name);\n\t}\n\n\t/**\n\t * Get all configured tools with name, description, parameter schema, and source metadata.\n\t */\n\tgetAllTools(): ToolInfo[] {\n\t\treturn Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({\n\t\t\tname: definition.name,\n\t\t\tdescription: definition.description,\n\t\t\tparameters: definition.parameters,\n\t\t\tsourceInfo,\n\t\t}));\n\t}\n\n\tgetToolDefinition(name: string): ToolDefinition | undefined {\n\t\treturn this._toolDefinitions.get(name)?.definition;\n\t}\n\n\t/**\n\t * Set active tools by name.\n\t * Only tools in the registry can be enabled. Unknown tool names are ignored.\n\t * Also rebuilds the system prompt to reflect the new tool set.\n\t * Changes take effect on the next agent turn.\n\t */\n\tsetActiveToolsByName(toolNames: string[]): void {\n\t\tconst tools: AgentTool[] = [];\n\t\tconst validToolNames: string[] = [];\n\t\tfor (const name of toolNames) {\n\t\t\tconst tool = this._toolRegistry.get(name);\n\t\t\tif (tool) {\n\t\t\t\ttools.push(tool);\n\t\t\t\tvalidToolNames.push(name);\n\t\t\t}\n\t\t}\n\t\tthis.agent.state.tools = tools;\n\n\t\t// Rebuild base system prompt with new tool set\n\t\tthis._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);\n\t\tthis.agent.state.systemPrompt = this._baseSystemPrompt;\n\t}\n\n\t/** Whether compaction or branch summarization is currently running */\n\tget isCompacting(): boolean {\n\t\treturn (\n\t\t\tthis._autoCompactionAbortController !== undefined ||\n\t\t\tthis._compactionAbortController !== undefined ||\n\t\t\tthis._branchSummaryAbortController !== undefined\n\t\t);\n\t}\n\n\t/** All messages including custom types like BashExecutionMessage */\n\tget messages(): AgentMessage[] {\n\t\treturn this.agent.state.messages;\n\t}\n\n\t/** Current steering mode */\n\tget steeringMode(): \"all\" | \"one-at-a-time\" {\n\t\treturn this.agent.steeringMode;\n\t}\n\n\t/** Current follow-up mode */\n\tget followUpMode(): \"all\" | \"one-at-a-time\" {\n\t\treturn this.agent.followUpMode;\n\t}\n\n\t/** Current session file path, or undefined if sessions are disabled */\n\tget sessionFile(): string | undefined {\n\t\treturn this.sessionManager.getSessionFile();\n\t}\n\n\t/** Current session ID */\n\tget sessionId(): string {\n\t\treturn this.sessionManager.getSessionId();\n\t}\n\n\t/** Current session display name, if set */\n\tget sessionName(): string | undefined {\n\t\treturn this.sessionManager.getSessionName();\n\t}\n\n\t/**\n\t * Monotonic counter emitted on each completed turn ({@link TurnEndEvent.turnIndex}); resets on agent_start.\n\t * Mirrors the value shown next to ✓ Turn N complete-style UI (N equals this counter after idle).\n\t */\n\tget turnIndex(): number {\n\t\treturn this._turnIndex;\n\t}\n\n\t/** Scoped models for cycling (from --models flag) */\n\tget scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {\n\t\treturn this._scopedModels;\n\t}\n\n\t/** Update scoped models for cycling */\n\tsetScopedModels(scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>): void {\n\t\tthis._scopedModels = scopedModels;\n\t}\n\n\t/** File-based prompt templates */\n\tget promptTemplates(): ReadonlyArray<PromptTemplate> {\n\t\treturn this._resourceLoader.getPrompts().prompts;\n\t}\n\n\tprivate _normalizePromptSnippet(text: string | undefined): string | undefined {\n\t\tif (!text) return undefined;\n\t\tconst oneLine = text\n\t\t\t.replace(/[\\r\\n]+/g, \" \")\n\t\t\t.replace(/\\s+/g, \" \")\n\t\t\t.trim();\n\t\treturn oneLine.length > 0 ? oneLine : undefined;\n\t}\n\n\tprivate _normalizePromptGuidelines(guidelines: string[] | undefined): string[] {\n\t\tif (!guidelines || guidelines.length === 0) {\n\t\t\treturn [];\n\t\t}\n\n\t\tconst unique = new Set<string>();\n\t\tfor (const guideline of guidelines) {\n\t\t\tconst normalized = guideline.trim();\n\t\t\tif (normalized.length > 0) {\n\t\t\t\tunique.add(normalized);\n\t\t\t}\n\t\t}\n\t\treturn Array.from(unique);\n\t}\n\n\tprivate _rebuildSystemPrompt(toolNames: string[]): string {\n\t\tconst validToolNames = toolNames.filter((name) => this._toolRegistry.has(name));\n\t\tconst toolSnippets: Record<string, string> = {};\n\t\tconst promptGuidelines: string[] = [];\n\t\tfor (const name of validToolNames) {\n\t\t\tconst snippet = this._toolPromptSnippets.get(name);\n\t\t\tif (snippet) {\n\t\t\t\ttoolSnippets[name] = snippet;\n\t\t\t}\n\n\t\t\tconst toolGuidelines = this._toolPromptGuidelines.get(name);\n\t\t\tif (toolGuidelines) {\n\t\t\t\tpromptGuidelines.push(...toolGuidelines);\n\t\t\t}\n\t\t}\n\n\t\tconst loaderSystemPrompt = this._resourceLoader.getSystemPrompt();\n\t\tconst loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();\n\t\tconst appendSystemPrompt =\n\t\t\tloaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join(\"\\n\\n\") : undefined;\n\t\tconst loadedSkills = this._resourceLoader.getSkills().skills;\n\t\tconst loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;\n\n\t\tthis._baseSystemPromptOptions = {\n\t\t\tcwd: this._cwd,\n\t\t\tskills: loadedSkills,\n\t\t\tcontextFiles: loadedContextFiles,\n\t\t\tcustomPrompt: loaderSystemPrompt,\n\t\t\tappendSystemPrompt,\n\t\t\tselectedTools: validToolNames,\n\t\t\ttoolSnippets,\n\t\t\tpromptGuidelines,\n\t\t};\n\t\treturn buildSystemPrompt(this._baseSystemPromptOptions);\n\t}\n\n\t// =========================================================================\n\t// Prompting\n\t// =========================================================================\n\n\tprivate async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {\n\t\ttry {\n\t\t\tawait this.agent.prompt(messages);\n\t\t\twhile (await this._handlePostAgentRun()) {\n\t\t\t\tawait this.agent.continue();\n\t\t\t}\n\t\t} finally {\n\t\t\tthis._flushPendingBashMessages();\n\t\t}\n\t}\n\n\tprivate async _handlePostAgentRun(): Promise<boolean> {\n\t\tconst msg = this._lastAssistantMessage;\n\t\tthis._lastAssistantMessage = undefined;\n\t\tif (!msg) {\n\t\t\treturn false;\n\t\t}\n\n\t\tif (this._isRetryableError(msg) && (await this._prepareRetry(msg))) {\n\t\t\treturn true;\n\t\t}\n\n\t\tif (msg.stopReason === \"error\" && this._retryAttempt > 0) {\n\t\t\tthis._emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: false,\n\t\t\t\tattempt: this._retryAttempt,\n\t\t\t\tfinalError: msg.errorMessage,\n\t\t\t});\n\t\t\tthis._retryAttempt = 0;\n\t\t}\n\n\t\treturn await this._checkCompaction(msg);\n\t}\n\n\t/**\n\t * Send a prompt to the agent.\n\t * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming\n\t * - Expands file-based prompt templates by default\n\t * - During streaming, queues via steer() or followUp() based on streamingBehavior option\n\t * - Validates model and API key before sending (when not streaming)\n\t * @throws Error if streaming and no streamingBehavior specified\n\t * @throws Error if no model selected or no API key available (when not streaming)\n\t */\n\tasync prompt(text: string, options?: PromptOptions): Promise<void> {\n\t\tconst expandPromptTemplates = options?.expandPromptTemplates ?? true;\n\t\tconst preflightResult = options?.preflightResult;\n\t\tlet messages: AgentMessage[] | undefined;\n\n\t\ttry {\n\t\t\t// Handle extension commands first (execute immediately, even during streaming)\n\t\t\t// Extension commands manage their own LLM interaction via pi.sendMessage()\n\t\t\tif (expandPromptTemplates && text.startsWith(\"/\")) {\n\t\t\t\tconst handled = await this._tryExecuteExtensionCommand(text);\n\t\t\t\tif (handled) {\n\t\t\t\t\t// Extension command executed, no prompt to send\n\t\t\t\t\tpreflightResult?.(true);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Emit input event for extension interception (before skill/template expansion)\n\t\t\tlet currentText = text;\n\t\t\tlet currentImages = options?.images;\n\t\t\tif (this._extensionRunner.hasHandlers(\"input\")) {\n\t\t\t\tconst inputResult = await this._extensionRunner.emitInput(\n\t\t\t\t\tcurrentText,\n\t\t\t\t\tcurrentImages,\n\t\t\t\t\toptions?.source ?? \"interactive\",\n\t\t\t\t);\n\t\t\t\tif (inputResult.action === \"handled\") {\n\t\t\t\t\tpreflightResult?.(true);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (inputResult.action === \"transform\") {\n\t\t\t\t\tcurrentText = inputResult.text;\n\t\t\t\t\tcurrentImages = inputResult.images ?? currentImages;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Expand skill commands (/skill:name args) and prompt templates (/template args)\n\t\t\tlet expandedText = currentText;\n\t\t\tif (expandPromptTemplates) {\n\t\t\t\texpandedText = this._expandSkillCommand(expandedText);\n\t\t\t\texpandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);\n\t\t\t}\n\n\t\t\t// If streaming, queue via steer() or followUp() based on option\n\t\t\tif (this.isStreaming) {\n\t\t\t\tif (!options?.streamingBehavior) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (options.streamingBehavior === \"followUp\") {\n\t\t\t\t\tawait this._queueFollowUp(expandedText, currentImages);\n\t\t\t\t} else {\n\t\t\t\t\tawait this._queueSteer(expandedText, currentImages);\n\t\t\t\t}\n\t\t\t\tpreflightResult?.(true);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Flush any pending bash messages before the new prompt\n\t\t\tthis._flushPendingBashMessages();\n\n\t\t\t// Validate model\n\t\t\tif (!this.model) {\n\t\t\t\tthrow new Error(formatNoModelSelectedMessage());\n\t\t\t}\n\n\t\t\tif (!this._modelRegistry.hasConfiguredAuth(this.model)) {\n\t\t\t\tconst isOAuth = this._modelRegistry.isUsingOAuth(this.model);\n\t\t\t\tif (isOAuth) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`Authentication failed for \"${this.model.provider}\". ` +\n\t\t\t\t\t\t\t`Credentials may have expired or network is unavailable. ` +\n\t\t\t\t\t\t\t`Run '/login ${this.model.provider}' to re-authenticate.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tthrow new Error(formatNoApiKeyFoundMessage(this.model.provider));\n\t\t\t}\n\n\t\t\t// Check if we need to compact before sending (catches aborted responses)\n\t\t\tconst lastAssistant = this._findLastAssistantMessage();\n\t\t\tif (lastAssistant && (await this._checkCompaction(lastAssistant, false))) {\n\t\t\t\ttry {\n\t\t\t\t\tawait this.agent.continue();\n\t\t\t\t\twhile (await this._handlePostAgentRun()) {\n\t\t\t\t\t\tawait this.agent.continue();\n\t\t\t\t\t}\n\t\t\t\t} finally {\n\t\t\t\t\tthis._flushPendingBashMessages();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Build messages array (custom message if any, then user message)\n\t\t\tmessages = [];\n\n\t\t\t// Add user message\n\t\t\tconst userContent: (TextContent | ImageContent)[] = [{ type: \"text\", text: expandedText }];\n\t\t\tif (currentImages) {\n\t\t\t\tuserContent.push(...currentImages);\n\t\t\t}\n\t\t\tmessages.push({\n\t\t\t\trole: \"user\",\n\t\t\t\tcontent: userContent,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t});\n\n\t\t\t// Inject any pending \"nextTurn\" messages as context alongside the user message\n\t\t\tfor (const msg of this._pendingNextTurnMessages) {\n\t\t\t\tmessages.push(msg);\n\t\t\t}\n\t\t\tthis._pendingNextTurnMessages = [];\n\n\t\t\t// Emit before_agent_start extension event\n\t\t\tconst result = await this._extensionRunner.emitBeforeAgentStart(\n\t\t\t\texpandedText,\n\t\t\t\tcurrentImages,\n\t\t\t\tthis._baseSystemPrompt,\n\t\t\t\tthis._baseSystemPromptOptions,\n\t\t\t);\n\t\t\t// Add all custom messages from extensions\n\t\t\tif (result?.messages) {\n\t\t\t\tfor (const msg of result.messages) {\n\t\t\t\t\tmessages.push({\n\t\t\t\t\t\trole: \"custom\",\n\t\t\t\t\t\tcustomType: msg.customType,\n\t\t\t\t\t\tcontent: msg.content,\n\t\t\t\t\t\tdisplay: msg.display,\n\t\t\t\t\t\tdetails: msg.details,\n\t\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Apply extension-modified system prompt, or reset to base\n\t\t\tif (result?.systemPrompt) {\n\t\t\t\tthis.agent.state.systemPrompt = result.systemPrompt;\n\t\t\t} else {\n\t\t\t\t// Ensure we're using the base prompt (in case previous turn had modifications)\n\t\t\t\tthis.agent.state.systemPrompt = this._baseSystemPrompt;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tpreflightResult?.(false);\n\t\t\tthrow error;\n\t\t}\n\n\t\tif (!messages) {\n\t\t\treturn;\n\t\t}\n\n\t\tpreflightResult?.(true);\n\t\tawait this._runAgentPrompt(messages);\n\t}\n\n\t/**\n\t * Try to execute an extension command. Returns true if command was found and executed.\n\t */\n\tprivate async _tryExecuteExtensionCommand(text: string): Promise<boolean> {\n\t\t// Parse command name and args\n\t\tconst spaceIndex = text.indexOf(\" \");\n\t\tconst commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);\n\t\tconst args = spaceIndex === -1 ? \"\" : text.slice(spaceIndex + 1);\n\n\t\tconst command = this._extensionRunner.getCommand(commandName);\n\t\tif (!command) return false;\n\n\t\t// Get command context from extension runner (includes session control methods)\n\t\tconst ctx = this._extensionRunner.createCommandContext();\n\n\t\ttry {\n\t\t\tawait command.handler(args, ctx);\n\t\t\treturn true;\n\t\t} catch (err) {\n\t\t\t// Emit error via extension runner\n\t\t\tthis._extensionRunner.emitError({\n\t\t\t\textensionPath: `command:${commandName}`,\n\t\t\t\tevent: \"command\",\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\treturn true;\n\t\t}\n\t}\n\n\t/**\n\t * Expand skill commands (/skill:name args) to their full content.\n\t * Returns the expanded text, or the original text if not a skill command or skill not found.\n\t * Emits errors via extension runner if file read fails.\n\t */\n\tprivate _expandSkillCommand(text: string): string {\n\t\tif (!text.startsWith(\"/skill:\")) return text;\n\n\t\tconst spaceIndex = text.indexOf(\" \");\n\t\tconst skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);\n\t\tconst args = spaceIndex === -1 ? \"\" : text.slice(spaceIndex + 1).trim();\n\n\t\tconst skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);\n\t\tif (!skill) return text; // Unknown skill, pass through\n\n\t\ttry {\n\t\t\tconst content = readFileSync(skill.filePath, \"utf-8\");\n\t\t\tconst body = stripFrontmatter(content).trim();\n\t\t\tconst skillBlock = `<skill name=\"${skill.name}\" location=\"${skill.filePath}\">\\nReferences are relative to ${skill.baseDir}.\\n\\n${body}\\n</skill>`;\n\t\t\treturn args ? `${skillBlock}\\n\\n${args}` : skillBlock;\n\t\t} catch (err) {\n\t\t\t// Emit error like extension commands do\n\t\t\tthis._extensionRunner.emitError({\n\t\t\t\textensionPath: skill.filePath,\n\t\t\t\tevent: \"skill_expansion\",\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\treturn text; // Return original on error\n\t\t}\n\t}\n\n\t/**\n\t * Queue a steering message while the agent is running.\n\t * Delivered after the current assistant turn finishes executing its tool calls,\n\t * before the next LLM call.\n\t * Expands skill commands and prompt templates. Errors on extension commands.\n\t * @param images Optional image attachments to include with the message\n\t * @throws Error if text is an extension command\n\t */\n\tasync steer(text: string, images?: ImageContent[]): Promise<void> {\n\t\t// Check for extension commands (cannot be queued)\n\t\tif (text.startsWith(\"/\")) {\n\t\t\tthis._throwIfExtensionCommand(text);\n\t\t}\n\n\t\t// Expand skill commands and prompt templates\n\t\tlet expandedText = this._expandSkillCommand(text);\n\t\texpandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);\n\n\t\tawait this._queueSteer(expandedText, images);\n\t}\n\n\t/**\n\t * Queue a follow-up message to be processed after the agent finishes.\n\t * Delivered only when agent has no more tool calls or steering messages.\n\t * Expands skill commands and prompt templates. Errors on extension commands.\n\t * @param images Optional image attachments to include with the message\n\t * @throws Error if text is an extension command\n\t */\n\tasync followUp(text: string, images?: ImageContent[]): Promise<void> {\n\t\t// Check for extension commands (cannot be queued)\n\t\tif (text.startsWith(\"/\")) {\n\t\t\tthis._throwIfExtensionCommand(text);\n\t\t}\n\n\t\t// Expand skill commands and prompt templates\n\t\tlet expandedText = this._expandSkillCommand(text);\n\t\texpandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);\n\n\t\tawait this._queueFollowUp(expandedText, images);\n\t}\n\n\t/**\n\t * Internal: Queue a steering message (already expanded, no extension command check).\n\t */\n\tprivate async _queueSteer(text: string, images?: ImageContent[]): Promise<void> {\n\t\tthis._steeringMessages.push(text);\n\t\tthis._emitQueueUpdate();\n\t\tconst content: (TextContent | ImageContent)[] = [{ type: \"text\", text }];\n\t\tif (images) {\n\t\t\tcontent.push(...images);\n\t\t}\n\t\tthis.agent.steer({\n\t\t\trole: \"user\",\n\t\t\tcontent,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\t/**\n\t * Internal: Queue a follow-up message (already expanded, no extension command check).\n\t */\n\tprivate async _queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {\n\t\tthis._followUpMessages.push(text);\n\t\tthis._emitQueueUpdate();\n\t\tconst content: (TextContent | ImageContent)[] = [{ type: \"text\", text }];\n\t\tif (images) {\n\t\t\tcontent.push(...images);\n\t\t}\n\t\tthis.agent.followUp({\n\t\t\trole: \"user\",\n\t\t\tcontent,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\t/**\n\t * Throw an error if the text is an extension command.\n\t */\n\tprivate _throwIfExtensionCommand(text: string): void {\n\t\tconst spaceIndex = text.indexOf(\" \");\n\t\tconst commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);\n\t\tconst command = this._extensionRunner.getCommand(commandName);\n\n\t\tif (command) {\n\t\t\tthrow new Error(\n\t\t\t\t`Extension command \"/${commandName}\" cannot be queued. Use prompt() or execute the command when not streaming.`,\n\t\t\t);\n\t\t}\n\t}\n\n\t/**\n\t * Send a custom message to the session. Creates a CustomMessageEntry.\n\t *\n\t * Handles three cases:\n\t * - Streaming: queues message, processed when loop pulls from queue\n\t * - Not streaming + triggerTurn: appends to state/session, starts new turn\n\t * - Not streaming + no trigger: appends to state/session, no turn\n\t *\n\t * @param message Custom message with customType, content, display, details\n\t * @param options.triggerTurn If true and not streaming, triggers a new LLM turn\n\t * @param options.deliverAs Delivery mode: \"steer\", \"followUp\", or \"nextTurn\"\n\t */\n\tasync sendCustomMessage<T = unknown>(\n\t\tmessage: Pick<CustomMessage<T>, \"customType\" | \"content\" | \"display\" | \"details\">,\n\t\toptions?: { triggerTurn?: boolean; deliverAs?: \"steer\" | \"followUp\" | \"nextTurn\" },\n\t): Promise<void> {\n\t\tconst appMessage = {\n\t\t\trole: \"custom\" as const,\n\t\t\tcustomType: message.customType,\n\t\t\tcontent: message.content,\n\t\t\tdisplay: message.display,\n\t\t\tdetails: message.details,\n\t\t\ttimestamp: Date.now(),\n\t\t} satisfies CustomMessage<T>;\n\t\tif (options?.deliverAs === \"nextTurn\") {\n\t\t\tthis._pendingNextTurnMessages.push(appMessage);\n\t\t} else if (this.isStreaming) {\n\t\t\tif (options?.deliverAs === \"followUp\") {\n\t\t\t\tthis.agent.followUp(appMessage);\n\t\t\t} else {\n\t\t\t\tthis.agent.steer(appMessage);\n\t\t\t}\n\t\t} else if (options?.triggerTurn) {\n\t\t\tawait this._runAgentPrompt(appMessage);\n\t\t} else {\n\t\t\tthis.agent.state.messages.push(appMessage);\n\t\t\tthis.sessionManager.appendCustomMessageEntry(\n\t\t\t\tmessage.customType,\n\t\t\t\tmessage.content,\n\t\t\t\tmessage.display,\n\t\t\t\tmessage.details,\n\t\t\t);\n\t\t\tthis._emit({ type: \"message_start\", message: appMessage });\n\t\t\tthis._emit({ type: \"message_end\", message: appMessage });\n\t\t}\n\t}\n\n\t/**\n\t * Send a user message to the agent. Always triggers a turn.\n\t * When the agent is streaming, use deliverAs to specify how to queue the message.\n\t *\n\t * @param content User message content (string or content array)\n\t * @param options.deliverAs Delivery mode when streaming: \"steer\" or \"followUp\"\n\t */\n\tasync sendUserMessage(\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\toptions?: { deliverAs?: \"steer\" | \"followUp\" },\n\t): Promise<void> {\n\t\t// Normalize content to text string + optional images\n\t\tlet text: string;\n\t\tlet images: ImageContent[] | undefined;\n\n\t\tif (typeof content === \"string\") {\n\t\t\ttext = content;\n\t\t} else {\n\t\t\tconst textParts: string[] = [];\n\t\t\timages = [];\n\t\t\tfor (const part of content) {\n\t\t\t\tif (part.type === \"text\") {\n\t\t\t\t\ttextParts.push(part.text);\n\t\t\t\t} else {\n\t\t\t\t\timages.push(part);\n\t\t\t\t}\n\t\t\t}\n\t\t\ttext = textParts.join(\"\\n\");\n\t\t\tif (images.length === 0) images = undefined;\n\t\t}\n\n\t\t// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion\n\t\tawait this.prompt(text, {\n\t\t\texpandPromptTemplates: false,\n\t\t\tstreamingBehavior: options?.deliverAs,\n\t\t\timages,\n\t\t\tsource: \"extension\",\n\t\t});\n\t}\n\n\t/**\n\t * Clear all queued messages and return them.\n\t * Useful for restoring to editor when user aborts.\n\t * @returns Object with steering and followUp arrays\n\t */\n\tclearQueue(): { steering: string[]; followUp: string[] } {\n\t\tconst steering = [...this._steeringMessages];\n\t\tconst followUp = [...this._followUpMessages];\n\t\tthis._steeringMessages = [];\n\t\tthis._followUpMessages = [];\n\t\tthis.agent.clearAllQueues();\n\t\tthis._emitQueueUpdate();\n\t\treturn { steering, followUp };\n\t}\n\n\t/** Number of pending messages (includes both steering and follow-up) */\n\tget pendingMessageCount(): number {\n\t\treturn this._steeringMessages.length + this._followUpMessages.length;\n\t}\n\n\t/** Get pending steering messages (read-only) */\n\tgetSteeringMessages(): readonly string[] {\n\t\treturn this._steeringMessages;\n\t}\n\n\t/** Get pending follow-up messages (read-only) */\n\tgetFollowUpMessages(): readonly string[] {\n\t\treturn this._followUpMessages;\n\t}\n\n\tget resourceLoader(): ResourceLoader {\n\t\treturn this._resourceLoader;\n\t}\n\n\t/**\n\t * Abort current operation and wait for agent to become idle.\n\t */\n\tasync abort(): Promise<void> {\n\t\tthis.abortRetry();\n\t\tthis.agent.abort();\n\t\tawait this.agent.waitForIdle();\n\t}\n\n\t// =========================================================================\n\t// Model Management\n\t// =========================================================================\n\n\tprivate async _emitModelSelect(\n\t\tnextModel: Model<any>,\n\t\tpreviousModel: Model<any> | undefined,\n\t\tsource: \"set\" | \"cycle\" | \"restore\",\n\t): Promise<void> {\n\t\tif (modelsAreEqual(previousModel, nextModel)) return;\n\t\tawait this._extensionRunner.emit({\n\t\t\ttype: \"model_select\",\n\t\t\tmodel: nextModel,\n\t\t\tpreviousModel,\n\t\t\tsource,\n\t\t});\n\t}\n\n\t/**\n\t * Set model directly.\n\t * Validates that auth is configured, saves to session and settings.\n\t * @throws Error if no auth is configured for the model\n\t */\n\tasync setModel(model: Model<any>): Promise<void> {\n\t\tif (!this._modelRegistry.hasConfiguredAuth(model)) {\n\t\t\tthrow new Error(`No API key for ${model.provider}/${model.id}`);\n\t\t}\n\n\t\tconst previousModel = this.model;\n\t\tconst thinkingLevel = this._getThinkingLevelForModelSwitch();\n\t\tthis.agent.state.model = model;\n\t\tthis.sessionManager.appendModelChange(model.provider, model.id);\n\t\tthis.settingsManager.setDefaultModelAndProvider(model.provider, model.id);\n\n\t\t// Re-clamp thinking level for new model's capabilities\n\t\tthis.setThinkingLevel(thinkingLevel);\n\n\t\tawait this._emitModelSelect(model, previousModel, \"set\");\n\t}\n\n\t/**\n\t * Cycle to next/previous model.\n\t * Uses scoped models (from --models flag) if available, otherwise all available models.\n\t * @param direction - \"forward\" (default) or \"backward\"\n\t * @returns The new model info, or undefined if only one model available\n\t */\n\tasync cycleModel(direction: \"forward\" | \"backward\" = \"forward\"): Promise<ModelCycleResult | undefined> {\n\t\tif (this._scopedModels.length > 0) {\n\t\t\treturn this._cycleScopedModel(direction);\n\t\t}\n\t\treturn this._cycleAvailableModel(direction);\n\t}\n\n\tprivate async _cycleScopedModel(direction: \"forward\" | \"backward\"): Promise<ModelCycleResult | undefined> {\n\t\tconst scopedModels = this._scopedModels.filter((scoped) => this._modelRegistry.hasConfiguredAuth(scoped.model));\n\t\tif (scopedModels.length <= 1) return undefined;\n\n\t\tconst currentModel = this.model;\n\t\tlet currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));\n\n\t\tif (currentIndex === -1) currentIndex = 0;\n\t\tconst len = scopedModels.length;\n\t\tconst nextIndex = direction === \"forward\" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;\n\t\tconst next = scopedModels[nextIndex];\n\t\tconst thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel);\n\n\t\t// Apply model\n\t\tthis.agent.state.model = next.model;\n\t\tthis.sessionManager.appendModelChange(next.model.provider, next.model.id);\n\t\tthis.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);\n\n\t\t// Apply thinking level.\n\t\t// - Explicit scoped model thinking level overrides current session level\n\t\t// - Undefined scoped model thinking level inherits the current session preference\n\t\t// setThinkingLevel clamps to model capabilities.\n\t\tthis.setThinkingLevel(thinkingLevel);\n\n\t\tawait this._emitModelSelect(next.model, currentModel, \"cycle\");\n\n\t\treturn { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };\n\t}\n\n\tprivate async _cycleAvailableModel(direction: \"forward\" | \"backward\"): Promise<ModelCycleResult | undefined> {\n\t\tconst availableModels = await this._modelRegistry.getAvailable();\n\t\tif (availableModels.length <= 1) return undefined;\n\n\t\tconst currentModel = this.model;\n\t\tlet currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));\n\n\t\tif (currentIndex === -1) currentIndex = 0;\n\t\tconst len = availableModels.length;\n\t\tconst nextIndex = direction === \"forward\" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;\n\t\tconst nextModel = availableModels[nextIndex];\n\n\t\tconst thinkingLevel = this._getThinkingLevelForModelSwitch();\n\t\tthis.agent.state.model = nextModel;\n\t\tthis.sessionManager.appendModelChange(nextModel.provider, nextModel.id);\n\t\tthis.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);\n\n\n[Showing lines 1-1500 of 3098 (50.0KB limit). Use offset=1501 to continue.]"}],"details":{"truncation":{"content":"/**\n * AgentSession - Core abstraction for agent lifecycle and session management.\n *\n * This class is shared between all run modes (interactive, print, rpc).\n * It encapsulates:\n * - Agent state access\n * - Event subscription with automatic session persistence\n * - Model and thinking level management\n * - Compaction (manual and auto)\n * - Bash execution\n * - Session switching and branching\n *\n * Modes use this class and add their own I/O layer on top.\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { basename, dirname } from \"node:path\";\nimport type {\n\tAgent,\n\tAgentEvent,\n\tAgentMessage,\n\tAgentState,\n\tAgentTool,\n\tThinkingLevel,\n} from \"@earendil-works/pi-agent-core\";\nimport type { AssistantMessage, ImageContent, Message, Model, TextContent } from \"@earendil-works/pi-ai\";\nimport {\n\tclampThinkingLevel,\n\tcleanupSessionResources,\n\tgetSupportedThinkingLevels,\n\tisContextOverflow,\n\tmodelsAreEqual,\n\tresetApiProviders,\n\tstreamSimple,\n} from \"@earendil-works/pi-ai\";\nimport { theme } from \"../modes/interactive/theme/theme.ts\";\nimport { stripFrontmatter } from \"../utils/frontmatter.ts\";\nimport { resolvePath } from \"../utils/paths.ts\";\nimport { sleep } from \"../utils/sleep.ts\";\nimport { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from \"./auth-guidance.ts\";\nimport { type BashResult, executeBashWithOperations } from \"./bash-executor.ts\";\nimport {\n\ttype CompactionResult,\n\tcalculateContextTokens,\n\tcollectEntriesForBranchSummary,\n\tcompact,\n\testimateContextTokens,\n\tgenerateBranchSummary,\n\tprepareCompaction,\n\tshouldCompact,\n} from \"./compaction/index.ts\";\nimport { DEFAULT_THINKING_LEVEL } from \"./defaults.ts\";\nimport { exportSessionToHtml, type ToolHtmlRenderer } from \"./export-html/index.ts\";\nimport { createToolHtmlRenderer } from \"./export-html/tool-renderer.ts\";\nimport {\n\ttype ContextUsage,\n\ttype ExtensionCommandContextActions,\n\ttype ExtensionErrorListener,\n\tExtensionRunner,\n\ttype ExtensionUIContext,\n\ttype InputSource,\n\ttype MessageEndEvent,\n\ttype MessageStartEvent,\n\ttype MessageUpdateEvent,\n\ttype ReplacedSessionContext,\n\ttype SessionBeforeCompactResult,\n\ttype SessionBeforeTreeResult,\n\ttype SessionStartEvent,\n\ttype ShutdownHandler,\n\ttype ToolDefinition,\n\ttype ToolExecutionEndEvent,\n\ttype ToolExecutionStartEvent,\n\ttype ToolExecutionUpdateEvent,\n\ttype ToolInfo,\n\ttype TreePreparation,\n\ttype TurnEndEvent,\n\ttype TurnStartEvent,\n\twrapRegisteredTools,\n} from \"./extensions/index.ts\";\nimport { emitSessionShutdownEvent } from \"./extensions/runner.ts\";\nimport type { BashExecutionMessage, CustomMessage } from \"./messages.ts\";\nimport type { ModelRegistry } from \"./model-registry.ts\";\nimport { expandPromptTemplate, type PromptTemplate } from \"./prompt-templates.ts\";\nimport type { ResourceExtensionPaths, ResourceLoader } from \"./resource-loader.ts\";\nimport type { BranchSummaryEntry, CompactionEntry, SessionManager } from \"./session-manager.ts\";\nimport { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from \"./session-manager.ts\";\nimport type { SettingsManager } from \"./settings-manager.ts\";\nimport type { SlashCommandInfo } from \"./slash-commands.ts\";\nimport { createSyntheticSourceInfo, type SourceInfo } from \"./source-info.ts\";\nimport { type BuildSystemPromptOptions, buildSystemPrompt } from \"./system-prompt.ts\";\nimport { type BashOperations, createLocalBashOperations } from \"./tools/bash.ts\";\nimport { createAllToolDefinitions } from \"./tools/index.ts\";\nimport { createToolDefinitionFromAgentTool } from \"./tools/tool-definition-wrapper.ts\";\n\n// ============================================================================\n// Skill Block Parsing\n// ============================================================================\n\n/** Parsed skill block from a user message */\nexport interface ParsedSkillBlock {\n\tname: string;\n\tlocation: string;\n\tcontent: string;\n\tuserMessage: string | undefined;\n}\n\n/**\n * Parse a skill block from message text.\n * Returns null if the text doesn't contain a skill block.\n */\nexport function parseSkillBlock(text: string): ParsedSkillBlock | null {\n\tconst match = text.match(/^<skill name=\"([^\"]+)\" location=\"([^\"]+)\">\\n([\\s\\S]*?)\\n<\\/skill>(?:\\n\\n([\\s\\S]+))?$/);\n\tif (!match) return null;\n\treturn {\n\t\tname: match[1],\n\t\tlocation: match[2],\n\t\tcontent: match[3],\n\t\tuserMessage: match[4]?.trim() || undefined,\n\t};\n}\n\n/** Session-specific events that extend the core AgentEvent */\nexport type AgentSessionEvent =\n\t| Exclude<AgentEvent, { type: \"agent_end\" }>\n\t| {\n\t\t\ttype: \"agent_end\";\n\t\t\tmessages: AgentMessage[];\n\t\t\twillRetry: boolean;\n\t }\n\t| {\n\t\t\ttype: \"queue_update\";\n\t\t\tsteering: readonly string[];\n\t\t\tfollowUp: readonly string[];\n\t }\n\t| { type: \"compaction_start\"; reason: \"manual\" | \"threshold\" | \"overflow\" }\n\t| { type: \"session_info_changed\"; name: string | undefined }\n\t| { type: \"thinking_level_changed\"; level: ThinkingLevel }\n\t| {\n\t\t\ttype: \"compaction_end\";\n\t\t\treason: \"manual\" | \"threshold\" | \"overflow\";\n\t\t\tresult: CompactionResult | undefined;\n\t\t\taborted: boolean;\n\t\t\twillRetry: boolean;\n\t\t\terrorMessage?: string;\n\t }\n\t| { type: \"auto_retry_start\"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }\n\t| { type: \"auto_retry_end\"; success: boolean; attempt: number; finalError?: string };\n\n/** Listener function for agent session events */\nexport type AgentSessionEventListener = (event: AgentSessionEvent) => void;\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface AgentSessionConfig {\n\tagent: Agent;\n\tsessionManager: SessionManager;\n\tsettingsManager: SettingsManager;\n\tcwd: string;\n\t/** Models to cycle through with Ctrl+P (from --models flag) */\n\tscopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;\n\t/** Resource loader for skills, prompts, themes, context files, system prompt */\n\tresourceLoader: ResourceLoader;\n\t/** SDK custom tools registered outside extensions */\n\tcustomTools?: ToolDefinition[];\n\t/** Model registry for API key resolution and model discovery */\n\tmodelRegistry: ModelRegistry;\n\t/** Initial active built-in tool names. Default: [read, bash, edit, write] */\n\tinitialActiveToolNames?: string[];\n\t/** Optional allowlist of tool names. When provided, only these tool names are exposed. */\n\tallowedToolNames?: string[];\n\t/**\n\t * Override base tools (useful for custom runtimes).\n\t *\n\t * These are synthesized into minimal ToolDefinitions internally so AgentSession can keep\n\t * a definition-first registry even when callers provide plain AgentTool instances.\n\t */\n\tbaseToolsOverride?: Record<string, AgentTool>;\n\t/** Mutable ref used by Agent to access the current ExtensionRunner */\n\textensionRunnerRef?: { current?: ExtensionRunner };\n\t/** Session start event metadata emitted when extensions bind to this runtime. */\n\tsessionStartEvent?: SessionStartEvent;\n}\n\nexport interface ExtensionBindings {\n\tuiContext?: ExtensionUIContext;\n\tcommandContextActions?: ExtensionCommandContextActions;\n\tabortHandler?: () => void;\n\tshutdownHandler?: ShutdownHandler;\n\tonError?: ExtensionErrorListener;\n}\n\n/** Options for AgentSession.prompt() */\nexport interface PromptOptions {\n\t/** Whether to expand file-based prompt templates (default: true) */\n\texpandPromptTemplates?: boolean;\n\t/** Image attachments */\n\timages?: ImageContent[];\n\t/** When streaming, how to queue the message: \"steer\" (interrupt) or \"followUp\" (wait). Required if streaming. */\n\tstreamingBehavior?: \"steer\" | \"followUp\";\n\t/** Source of input for extension input event handlers. Defaults to \"interactive\". */\n\tsource?: InputSource;\n\t/** Internal hook used by RPC mode to observe prompt preflight acceptance or rejection. */\n\tpreflightResult?: (success: boolean) => void;\n}\n\n/** Result from cycleModel() */\nexport interface ModelCycleResult {\n\tmodel: Model<any>;\n\tthinkingLevel: ThinkingLevel;\n\t/** Whether cycling through scoped models (--models flag) or all available */\n\tisScoped: boolean;\n}\n\n/** Session statistics for /session command */\nexport interface SessionStats {\n\tsessionFile: string | undefined;\n\tsessionId: string;\n\tuserMessages: number;\n\tassistantMessages: number;\n\ttoolCalls: number;\n\ttoolResults: number;\n\ttotalMessages: number;\n\ttokens: {\n\t\tinput: number;\n\t\toutput: number;\n\t\tcacheRead: number;\n\t\tcacheWrite: number;\n\t\ttotal: number;\n\t};\n\tcost: number;\n\tcontextUsage?: ContextUsage;\n}\n\ninterface ToolDefinitionEntry {\n\tdefinition: ToolDefinition;\n\tsourceInfo: SourceInfo;\n}\n\n// ============================================================================\n// Constants\n// ============================================================================\n\n/** Standard thinking levels */\nconst THINKING_LEVELS: ThinkingLevel[] = [\"off\", \"minimal\", \"low\", \"medium\", \"high\"];\n\n// ============================================================================\n// AgentSession Class\n// ============================================================================\n\nexport class AgentSession {\n\treadonly agent: Agent;\n\treadonly sessionManager: SessionManager;\n\treadonly settingsManager: SettingsManager;\n\n\tprivate _scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;\n\n\t// Event subscription state\n\tprivate _unsubscribeAgent?: () => void;\n\tprivate _eventListeners: AgentSessionEventListener[] = [];\n\n\t/** Tracks pending steering messages for UI display. Removed when delivered. */\n\tprivate _steeringMessages: string[] = [];\n\t/** Tracks pending follow-up messages for UI display. Removed when delivered. */\n\tprivate _followUpMessages: string[] = [];\n\t/** Messages queued to be included with the next user prompt as context (\"asides\"). */\n\tprivate _pendingNextTurnMessages: CustomMessage[] = [];\n\n\t// Compaction state\n\tprivate _compactionAbortController: AbortController | undefined = undefined;\n\tprivate _autoCompactionAbortController: AbortController | undefined = undefined;\n\tprivate _overflowRecoveryAttempted = false;\n\n\t// Branch summarization state\n\tprivate _branchSummaryAbortController: AbortController | undefined = undefined;\n\n\t// Retry state\n\tprivate _retryAbortController: AbortController | undefined = undefined;\n\tprivate _retryAttempt = 0;\n\n\t// Bash execution state\n\tprivate _bashAbortController: AbortController | undefined = undefined;\n\tprivate _pendingBashMessages: BashExecutionMessage[] = [];\n\n\t// Extension system\n\tprivate _extensionRunner!: ExtensionRunner;\n\tprivate _turnIndex = 0;\n\n\tprivate _resourceLoader: ResourceLoader;\n\tprivate _customTools: ToolDefinition[];\n\tprivate _baseToolDefinitions: Map<string, ToolDefinition> = new Map();\n\tprivate _cwd: string;\n\tprivate _extensionRunnerRef?: { current?: ExtensionRunner };\n\tprivate _initialActiveToolNames?: string[];\n\tprivate _allowedToolNames?: Set<string>;\n\tprivate _baseToolsOverride?: Record<string, AgentTool>;\n\tprivate _sessionStartEvent: SessionStartEvent;\n\tprivate _extensionUIContext?: ExtensionUIContext;\n\tprivate _extensionCommandContextActions?: ExtensionCommandContextActions;\n\tprivate _extensionAbortHandler?: () => void;\n\tprivate _extensionShutdownHandler?: ShutdownHandler;\n\tprivate _extensionErrorListener?: ExtensionErrorListener;\n\tprivate _extensionErrorUnsubscriber?: () => void;\n\n\t// Model registry for API key resolution\n\tprivate _modelRegistry: ModelRegistry;\n\n\t// Tool registry for extension getTools/setTools\n\tprivate _toolRegistry: Map<string, AgentTool> = new Map();\n\tprivate _toolDefinitions: Map<string, ToolDefinitionEntry> = new Map();\n\tprivate _toolPromptSnippets: Map<string, string> = new Map();\n\tprivate _toolPromptGuidelines: Map<string, string[]> = new Map();\n\n\t// Base system prompt (without extension appends) - used to apply fresh appends each turn\n\tprivate _baseSystemPrompt = \"\";\n\tprivate _baseSystemPromptOptions!: BuildSystemPromptOptions;\n\n\tconstructor(config: AgentSessionConfig) {\n\t\tthis.agent = config.agent;\n\t\tthis.sessionManager = config.sessionManager;\n\t\tthis.settingsManager = config.settingsManager;\n\t\tthis._scopedModels = config.scopedModels ?? [];\n\t\tthis._resourceLoader = config.resourceLoader;\n\t\tthis._customTools = config.customTools ?? [];\n\t\tthis._cwd = config.cwd;\n\t\tthis._modelRegistry = config.modelRegistry;\n\t\tthis._extensionRunnerRef = config.extensionRunnerRef;\n\t\tthis._initialActiveToolNames = config.initialActiveToolNames;\n\t\tthis._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined;\n\t\tthis._baseToolsOverride = config.baseToolsOverride;\n\t\tthis._sessionStartEvent = config.sessionStartEvent ?? { type: \"session_start\", reason: \"startup\" };\n\n\t\t// Always subscribe to agent events for internal handling\n\t\t// (session persistence, extensions, auto-compaction, retry logic)\n\t\tthis._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);\n\t\tthis._installAgentToolHooks();\n\n\t\tthis._buildRuntime({\n\t\t\tactiveToolNames: this._initialActiveToolNames,\n\t\t\tincludeAllExtensionTools: true,\n\t\t});\n\t}\n\n\t/** Model registry for API key resolution and model discovery */\n\tget modelRegistry(): ModelRegistry {\n\t\treturn this._modelRegistry;\n\t}\n\n\tprivate async _getRequiredRequestAuth(model: Model<any>): Promise<{\n\t\tapiKey: string;\n\t\theaders?: Record<string, string>;\n\t}> {\n\t\tconst result = await this._modelRegistry.getApiKeyAndHeaders(model);\n\t\tif (!result.ok) {\n\t\t\tif (result.error.startsWith(\"No API key found\")) {\n\t\t\t\tthrow new Error(formatNoApiKeyFoundMessage(model.provider));\n\t\t\t}\n\t\t\tthrow new Error(result.error);\n\t\t}\n\t\tif (result.apiKey) {\n\t\t\treturn { apiKey: result.apiKey, headers: result.headers };\n\t\t}\n\n\t\tconst isOAuth = this._modelRegistry.isUsingOAuth(model);\n\t\tif (isOAuth) {\n\t\t\tthrow new Error(\n\t\t\t\t`Authentication failed for \"${model.provider}\". ` +\n\t\t\t\t\t`Credentials may have expired or network is unavailable. ` +\n\t\t\t\t\t`Run '/login ${model.provider}' to re-authenticate.`,\n\t\t\t);\n\t\t}\n\t\tthrow new Error(formatNoApiKeyFoundMessage(model.provider));\n\t}\n\n\tprivate async _getCompactionRequestAuth(model: Model<any>): Promise<{\n\t\tapiKey?: string;\n\t\theaders?: Record<string, string>;\n\t}> {\n\t\tif (this.agent.streamFn === streamSimple) {\n\t\t\treturn this._getRequiredRequestAuth(model);\n\t\t}\n\n\t\tconst result = await this._modelRegistry.getApiKeyAndHeaders(model);\n\t\treturn result.ok ? { apiKey: result.apiKey, headers: result.headers } : {};\n\t}\n\n\t/**\n\t * Install tool hooks once on the Agent instance.\n\t *\n\t * The callbacks read `this._extensionRunner` at execution time, so extension reload swaps in the\n\t * new runner without reinstalling hooks. Extension-specific tool wrappers are still used to adapt\n\t * registered tool execution to the extension context. Tool call and tool result interception now\n\t * happens here instead of in wrappers.\n\t */\n\tprivate _installAgentToolHooks(): void {\n\t\tthis.agent.beforeToolCall = async ({ toolCall, args }) => {\n\t\t\tconst runner = this._extensionRunner;\n\t\t\tif (!runner.hasHandlers(\"tool_call\")) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\treturn await runner.emitToolCall({\n\t\t\t\t\ttype: \"tool_call\",\n\t\t\t\t\ttoolName: toolCall.name,\n\t\t\t\t\ttoolCallId: toolCall.id,\n\t\t\t\t\tinput: args as Record<string, unknown>,\n\t\t\t\t});\n\t\t\t} catch (err) {\n\t\t\t\tif (err instanceof Error) {\n\t\t\t\t\tthrow err;\n\t\t\t\t}\n\t\t\t\tthrow new Error(`Extension failed, blocking execution: ${String(err)}`);\n\t\t\t}\n\t\t};\n\n\t\tthis.agent.afterToolCall = async ({ toolCall, args, result, isError }) => {\n\t\t\tconst runner = this._extensionRunner;\n\t\t\tif (!runner.hasHandlers(\"tool_result\")) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\n\t\t\tconst hookResult = await runner.emitToolResult({\n\t\t\t\ttype: \"tool_result\",\n\t\t\t\ttoolName: toolCall.name,\n\t\t\t\ttoolCallId: toolCall.id,\n\t\t\t\tinput: args as Record<string, unknown>,\n\t\t\t\tcontent: result.content,\n\t\t\t\tdetails: result.details,\n\t\t\t\tisError,\n\t\t\t});\n\n\t\t\tif (!hookResult) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tcontent: hookResult.content,\n\t\t\t\tdetails: hookResult.details,\n\t\t\t\tisError: hookResult.isError ?? isError,\n\t\t\t};\n\t\t};\n\t}\n\n\t// =========================================================================\n\t// Event Subscription\n\t// =========================================================================\n\n\t/** Emit an event to all listeners */\n\tprivate _emit(event: AgentSessionEvent): void {\n\t\tfor (const l of this._eventListeners) {\n\t\t\tl(event);\n\t\t}\n\t}\n\n\tprivate _emitQueueUpdate(): void {\n\t\tthis._emit({\n\t\t\ttype: \"queue_update\",\n\t\t\tsteering: [...this._steeringMessages],\n\t\t\tfollowUp: [...this._followUpMessages],\n\t\t});\n\t}\n\n\t// Track last assistant message for auto-compaction check\n\tprivate _lastAssistantMessage: AssistantMessage | undefined = undefined;\n\n\t/** Internal handler for agent events - shared by subscribe and reconnect */\n\tprivate _handleAgentEvent = async (event: AgentEvent): Promise<void> => {\n\t\t// When a user message starts, check if it's from either queue and remove it BEFORE emitting\n\t\t// This ensures the UI sees the updated queue state\n\t\tif (event.type === \"message_start\" && event.message.role === \"user\") {\n\t\t\tthis._overflowRecoveryAttempted = false;\n\t\t\tconst messageText = this._getUserMessageText(event.message);\n\t\t\tif (messageText) {\n\t\t\t\t// Check steering queue first\n\t\t\t\tconst steeringIndex = this._steeringMessages.indexOf(messageText);\n\t\t\t\tif (steeringIndex !== -1) {\n\t\t\t\t\tthis._steeringMessages.splice(steeringIndex, 1);\n\t\t\t\t\tthis._emitQueueUpdate();\n\t\t\t\t} else {\n\t\t\t\t\t// Check follow-up queue\n\t\t\t\t\tconst followUpIndex = this._followUpMessages.indexOf(messageText);\n\t\t\t\t\tif (followUpIndex !== -1) {\n\t\t\t\t\t\tthis._followUpMessages.splice(followUpIndex, 1);\n\t\t\t\t\t\tthis._emitQueueUpdate();\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Emit to extensions first\n\t\tawait this._emitExtensionEvent(event);\n\n\t\t// Notify all listeners\n\t\tthis._emit(event.type === \"agent_end\" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);\n\n\t\t// Handle session persistence\n\t\tif (event.type === \"message_end\") {\n\t\t\t// Check if this is a custom message from extensions\n\t\t\tif (event.message.role === \"custom\") {\n\t\t\t\t// Persist as CustomMessageEntry\n\t\t\t\tthis.sessionManager.appendCustomMessageEntry(\n\t\t\t\t\tevent.message.customType,\n\t\t\t\t\tevent.message.content,\n\t\t\t\t\tevent.message.display,\n\t\t\t\t\tevent.message.details,\n\t\t\t\t);\n\t\t\t} else if (\n\t\t\t\tevent.message.role === \"user\" ||\n\t\t\t\tevent.message.role === \"assistant\" ||\n\t\t\t\tevent.message.role === \"toolResult\"\n\t\t\t) {\n\t\t\t\t// Regular LLM message - persist as SessionMessageEntry\n\t\t\t\tthis.sessionManager.appendMessage(event.message);\n\t\t\t}\n\t\t\t// Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere\n\n\t\t\t// Track assistant message for auto-compaction (checked on agent_end)\n\t\t\tif (event.message.role === \"assistant\") {\n\t\t\t\tthis._lastAssistantMessage = event.message;\n\n\t\t\t\tconst assistantMsg = event.message as AssistantMessage;\n\t\t\t\tif (assistantMsg.stopReason !== \"error\") {\n\t\t\t\t\tthis._overflowRecoveryAttempted = false;\n\t\t\t\t}\n\n\t\t\t\t// Reset retry counter immediately on successful assistant response\n\t\t\t\t// This prevents accumulation across multiple LLM calls within a turn\n\t\t\t\tif (assistantMsg.stopReason !== \"error\" && this._retryAttempt > 0) {\n\t\t\t\t\tthis._emit({\n\t\t\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tattempt: this._retryAttempt,\n\t\t\t\t\t});\n\t\t\t\t\tthis._retryAttempt = 0;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t};\n\n\tprivate _willRetryAfterAgentEnd(event: Extract<AgentEvent, { type: \"agent_end\" }>): boolean {\n\t\tconst settings = this.settingsManager.getRetrySettings();\n\t\tif (!settings.enabled || this._retryAttempt >= settings.maxRetries) {\n\t\t\treturn false;\n\t\t}\n\n\t\tfor (let i = event.messages.length - 1; i >= 0; i--) {\n\t\t\tconst message = event.messages[i];\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\treturn this._isRetryableError(message as AssistantMessage);\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t}\n\n\t/** Extract text content from a message */\n\tprivate _getUserMessageText(message: Message): string {\n\t\tif (message.role !== \"user\") return \"\";\n\t\tconst content = message.content;\n\t\tif (typeof content === \"string\") return content;\n\t\tconst textBlocks = content.filter((c) => c.type === \"text\");\n\t\treturn textBlocks.map((c) => (c as TextContent).text).join(\"\");\n\t}\n\n\t/** Find the last assistant message in agent state (including aborted ones) */\n\tprivate _findLastAssistantMessage(): AssistantMessage | undefined {\n\t\tconst messages = this.agent.state.messages;\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst msg = messages[i];\n\t\t\tif (msg.role === \"assistant\") {\n\t\t\t\treturn msg as AssistantMessage;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate _replaceMessageInPlace(target: AgentMessage, replacement: AgentMessage): void {\n\t\t// Agent-core stores the finalized message object in its state before emitting message_end.\n\t\t// SessionManager persistence happens later in _handleAgentEvent() with event.message.\n\t\t// Mutating this object in place keeps agent state, later turn/agent events, listeners,\n\t\t// and the eventual SessionManager.appendMessage(event.message) persistence in sync.\n\t\tif (target === replacement) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst targetRecord = target as unknown as Record<string, unknown>;\n\t\tfor (const key of Object.keys(targetRecord)) {\n\t\t\tdelete targetRecord[key];\n\t\t}\n\t\tObject.assign(targetRecord, replacement);\n\t}\n\n\t/** Emit extension events based on agent events */\n\tprivate async _emitExtensionEvent(event: AgentEvent): Promise<void> {\n\t\tif (event.type === \"agent_start\") {\n\t\t\tthis._turnIndex = 0;\n\t\t\tawait this._extensionRunner.emit({ type: \"agent_start\" });\n\t\t} else if (event.type === \"agent_end\") {\n\t\t\tawait this._extensionRunner.emit({ type: \"agent_end\", messages: event.messages });\n\t\t} else if (event.type === \"turn_start\") {\n\t\t\tconst extensionEvent: TurnStartEvent = {\n\t\t\t\ttype: \"turn_start\",\n\t\t\t\tturnIndex: this._turnIndex,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t} else if (event.type === \"turn_end\") {\n\t\t\tconst extensionEvent: TurnEndEvent = {\n\t\t\t\ttype: \"turn_end\",\n\t\t\t\tturnIndex: this._turnIndex,\n\t\t\t\tmessage: event.message,\n\t\t\t\ttoolResults: event.toolResults,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t\tthis._turnIndex++;\n\t\t} else if (event.type === \"message_start\") {\n\t\t\tconst extensionEvent: MessageStartEvent = {\n\t\t\t\ttype: \"message_start\",\n\t\t\t\tmessage: event.message,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t} else if (event.type === \"message_update\") {\n\t\t\tconst extensionEvent: MessageUpdateEvent = {\n\t\t\t\ttype: \"message_update\",\n\t\t\t\tmessage: event.message,\n\t\t\t\tassistantMessageEvent: event.assistantMessageEvent,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t} else if (event.type === \"message_end\") {\n\t\t\tconst extensionEvent: MessageEndEvent = {\n\t\t\t\ttype: \"message_end\",\n\t\t\t\tmessage: event.message,\n\t\t\t};\n\t\t\tconst replacement = await this._extensionRunner.emitMessageEnd(extensionEvent);\n\t\t\tif (replacement) {\n\t\t\t\tthis._replaceMessageInPlace(event.message, replacement);\n\t\t\t}\n\t\t} else if (event.type === \"tool_execution_start\") {\n\t\t\tconst extensionEvent: ToolExecutionStartEvent = {\n\t\t\t\ttype: \"tool_execution_start\",\n\t\t\t\ttoolCallId: event.toolCallId,\n\t\t\t\ttoolName: event.toolName,\n\t\t\t\targs: event.args,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t} else if (event.type === \"tool_execution_update\") {\n\t\t\tconst extensionEvent: ToolExecutionUpdateEvent = {\n\t\t\t\ttype: \"tool_execution_update\",\n\t\t\t\ttoolCallId: event.toolCallId,\n\t\t\t\ttoolName: event.toolName,\n\t\t\t\targs: event.args,\n\t\t\t\tpartialResult: event.partialResult,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t} else if (event.type === \"tool_execution_end\") {\n\t\t\tconst extensionEvent: ToolExecutionEndEvent = {\n\t\t\t\ttype: \"tool_execution_end\",\n\t\t\t\ttoolCallId: event.toolCallId,\n\t\t\t\ttoolName: event.toolName,\n\t\t\t\tresult: event.result,\n\t\t\t\tisError: event.isError,\n\t\t\t};\n\t\t\tawait this._extensionRunner.emit(extensionEvent);\n\t\t}\n\t}\n\n\t/**\n\t * Subscribe to agent events.\n\t * Session persistence is handled internally (saves messages on message_end).\n\t * Multiple listeners can be added. Returns unsubscribe function for this listener.\n\t */\n\tsubscribe(listener: AgentSessionEventListener): () => void {\n\t\tthis._eventListeners.push(listener);\n\n\t\t// Return unsubscribe function for this specific listener\n\t\treturn () => {\n\t\t\tconst index = this._eventListeners.indexOf(listener);\n\t\t\tif (index !== -1) {\n\t\t\t\tthis._eventListeners.splice(index, 1);\n\t\t\t}\n\t\t};\n\t}\n\n\t/**\n\t * Temporarily disconnect from agent events.\n\t * User listeners are preserved and will receive events again after resubscribe().\n\t * Used internally during operations that need to pause event processing.\n\t */\n\tprivate _disconnectFromAgent(): void {\n\t\tif (this._unsubscribeAgent) {\n\t\t\tthis._unsubscribeAgent();\n\t\t\tthis._unsubscribeAgent = undefined;\n\t\t}\n\t}\n\n\t/**\n\t * Reconnect to agent events after _disconnectFromAgent().\n\t * Preserves all existing listeners.\n\t */\n\tprivate _reconnectToAgent(): void {\n\t\tif (this._unsubscribeAgent) return; // Already connected\n\t\tthis._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);\n\t}\n\n\t/**\n\t * Remove all listeners and disconnect from agent.\n\t * Call this when completely done with the session.\n\t */\n\tdispose(): void {\n\t\tthis._extensionRunner.invalidate(\n\t\t\t\"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().\",\n\t\t);\n\t\tthis._disconnectFromAgent();\n\t\tthis._eventListeners = [];\n\t\tcleanupSessionResources(this.sessionId);\n\t}\n\n\t// =========================================================================\n\t// Read-only State Access\n\t// =========================================================================\n\n\t/** Full agent state */\n\tget state(): AgentState {\n\t\treturn this.agent.state;\n\t}\n\n\t/** Current model (may be undefined if not yet selected) */\n\tget model(): Model<any> | undefined {\n\t\treturn this.agent.state.model;\n\t}\n\n\t/** Current thinking level */\n\tget thinkingLevel(): ThinkingLevel {\n\t\treturn this.agent.state.thinkingLevel;\n\t}\n\n\t/** Whether agent is currently streaming a response */\n\tget isStreaming(): boolean {\n\t\treturn this.agent.state.isStreaming;\n\t}\n\n\t/** Current effective system prompt (includes any per-turn extension modifications) */\n\tget systemPrompt(): string {\n\t\treturn this.agent.state.systemPrompt;\n\t}\n\n\t/** Current retry attempt (0 if not retrying) */\n\tget retryAttempt(): number {\n\t\treturn this._retryAttempt;\n\t}\n\n\t/**\n\t * Get the names of currently active tools.\n\t * Returns the names of tools currently set on the agent.\n\t */\n\tgetActiveToolNames(): string[] {\n\t\treturn this.agent.state.tools.map((t) => t.name);\n\t}\n\n\t/**\n\t * Get all configured tools with name, description, parameter schema, and source metadata.\n\t */\n\tgetAllTools(): ToolInfo[] {\n\t\treturn Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({\n\t\t\tname: definition.name,\n\t\t\tdescription: definition.description,\n\t\t\tparameters: definition.parameters,\n\t\t\tsourceInfo,\n\t\t}));\n\t}\n\n\tgetToolDefinition(name: string): ToolDefinition | undefined {\n\t\treturn this._toolDefinitions.get(name)?.definition;\n\t}\n\n\t/**\n\t * Set active tools by name.\n\t * Only tools in the registry can be enabled. Unknown tool names are ignored.\n\t * Also rebuilds the system prompt to reflect the new tool set.\n\t * Changes take effect on the next agent turn.\n\t */\n\tsetActiveToolsByName(toolNames: string[]): void {\n\t\tconst tools: AgentTool[] = [];\n\t\tconst validToolNames: string[] = [];\n\t\tfor (const name of toolNames) {\n\t\t\tconst tool = this._toolRegistry.get(name);\n\t\t\tif (tool) {\n\t\t\t\ttools.push(tool);\n\t\t\t\tvalidToolNames.push(name);\n\t\t\t}\n\t\t}\n\t\tthis.agent.state.tools = tools;\n\n\t\t// Rebuild base system prompt with new tool set\n\t\tthis._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);\n\t\tthis.agent.state.systemPrompt = this._baseSystemPrompt;\n\t}\n\n\t/** Whether compaction or branch summarization is currently running */\n\tget isCompacting(): boolean {\n\t\treturn (\n\t\t\tthis._autoCompactionAbortController !== undefined ||\n\t\t\tthis._compactionAbortController !== undefined ||\n\t\t\tthis._branchSummaryAbortController !== undefined\n\t\t);\n\t}\n\n\t/** All messages including custom types like BashExecutionMessage */\n\tget messages(): AgentMessage[] {\n\t\treturn this.agent.state.messages;\n\t}\n\n\t/** Current steering mode */\n\tget steeringMode(): \"all\" | \"one-at-a-time\" {\n\t\treturn this.agent.steeringMode;\n\t}\n\n\t/** Current follow-up mode */\n\tget followUpMode(): \"all\" | \"one-at-a-time\" {\n\t\treturn this.agent.followUpMode;\n\t}\n\n\t/** Current session file path, or undefined if sessions are disabled */\n\tget sessionFile(): string | undefined {\n\t\treturn this.sessionManager.getSessionFile();\n\t}\n\n\t/** Current session ID */\n\tget sessionId(): string {\n\t\treturn this.sessionManager.getSessionId();\n\t}\n\n\t/** Current session display name, if set */\n\tget sessionName(): string | undefined {\n\t\treturn this.sessionManager.getSessionName();\n\t}\n\n\t/**\n\t * Monotonic counter emitted on each completed turn ({@link TurnEndEvent.turnIndex}); resets on agent_start.\n\t * Mirrors the value shown next to ✓ Turn N complete-style UI (N equals this counter after idle).\n\t */\n\tget turnIndex(): number {\n\t\treturn this._turnIndex;\n\t}\n\n\t/** Scoped models for cycling (from --models flag) */\n\tget scopedModels(): ReadonlyArray<{ model: Model<any>; thinkingLevel?: ThinkingLevel }> {\n\t\treturn this._scopedModels;\n\t}\n\n\t/** Update scoped models for cycling */\n\tsetScopedModels(scopedModels: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>): void {\n\t\tthis._scopedModels = scopedModels;\n\t}\n\n\t/** File-based prompt templates */\n\tget promptTemplates(): ReadonlyArray<PromptTemplate> {\n\t\treturn this._resourceLoader.getPrompts().prompts;\n\t}\n\n\tprivate _normalizePromptSnippet(text: string | undefined): string | undefined {\n\t\tif (!text) return undefined;\n\t\tconst oneLine = text\n\t\t\t.replace(/[\\r\\n]+/g, \" \")\n\t\t\t.replace(/\\s+/g, \" \")\n\t\t\t.trim();\n\t\treturn oneLine.length > 0 ? oneLine : undefined;\n\t}\n\n\tprivate _normalizePromptGuidelines(guidelines: string[] | undefined): string[] {\n\t\tif (!guidelines || guidelines.length === 0) {\n\t\t\treturn [];\n\t\t}\n\n\t\tconst unique = new Set<string>();\n\t\tfor (const guideline of guidelines) {\n\t\t\tconst normalized = guideline.trim();\n\t\t\tif (normalized.length > 0) {\n\t\t\t\tunique.add(normalized);\n\t\t\t}\n\t\t}\n\t\treturn Array.from(unique);\n\t}\n\n\tprivate _rebuildSystemPrompt(toolNames: string[]): string {\n\t\tconst validToolNames = toolNames.filter((name) => this._toolRegistry.has(name));\n\t\tconst toolSnippets: Record<string, string> = {};\n\t\tconst promptGuidelines: string[] = [];\n\t\tfor (const name of validToolNames) {\n\t\t\tconst snippet = this._toolPromptSnippets.get(name);\n\t\t\tif (snippet) {\n\t\t\t\ttoolSnippets[name] = snippet;\n\t\t\t}\n\n\t\t\tconst toolGuidelines = this._toolPromptGuidelines.get(name);\n\t\t\tif (toolGuidelines) {\n\t\t\t\tpromptGuidelines.push(...toolGuidelines);\n\t\t\t}\n\t\t}\n\n\t\tconst loaderSystemPrompt = this._resourceLoader.getSystemPrompt();\n\t\tconst loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt();\n\t\tconst appendSystemPrompt =\n\t\t\tloaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join(\"\\n\\n\") : undefined;\n\t\tconst loadedSkills = this._resourceLoader.getSkills().skills;\n\t\tconst loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;\n\n\t\tthis._baseSystemPromptOptions = {\n\t\t\tcwd: this._cwd,\n\t\t\tskills: loadedSkills,\n\t\t\tcontextFiles: loadedContextFiles,\n\t\t\tcustomPrompt: loaderSystemPrompt,\n\t\t\tappendSystemPrompt,\n\t\t\tselectedTools: validToolNames,\n\t\t\ttoolSnippets,\n\t\t\tpromptGuidelines,\n\t\t};\n\t\treturn buildSystemPrompt(this._baseSystemPromptOptions);\n\t}\n\n\t// =========================================================================\n\t// Prompting\n\t// =========================================================================\n\n\tprivate async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {\n\t\ttry {\n\t\t\tawait this.agent.prompt(messages);\n\t\t\twhile (await this._handlePostAgentRun()) {\n\t\t\t\tawait this.agent.continue();\n\t\t\t}\n\t\t} finally {\n\t\t\tthis._flushPendingBashMessages();\n\t\t}\n\t}\n\n\tprivate async _handlePostAgentRun(): Promise<boolean> {\n\t\tconst msg = this._lastAssistantMessage;\n\t\tthis._lastAssistantMessage = undefined;\n\t\tif (!msg) {\n\t\t\treturn false;\n\t\t}\n\n\t\tif (this._isRetryableError(msg) && (await this._prepareRetry(msg))) {\n\t\t\treturn true;\n\t\t}\n\n\t\tif (msg.stopReason === \"error\" && this._retryAttempt > 0) {\n\t\t\tthis._emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: false,\n\t\t\t\tattempt: this._retryAttempt,\n\t\t\t\tfinalError: msg.errorMessage,\n\t\t\t});\n\t\t\tthis._retryAttempt = 0;\n\t\t}\n\n\t\treturn await this._checkCompaction(msg);\n\t}\n\n\t/**\n\t * Send a prompt to the agent.\n\t * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming\n\t * - Expands file-based prompt templates by default\n\t * - During streaming, queues via steer() or followUp() based on streamingBehavior option\n\t * - Validates model and API key before sending (when not streaming)\n\t * @throws Error if streaming and no streamingBehavior specified\n\t * @throws Error if no model selected or no API key available (when not streaming)\n\t */\n\tasync prompt(text: string, options?: PromptOptions): Promise<void> {\n\t\tconst expandPromptTemplates = options?.expandPromptTemplates ?? true;\n\t\tconst preflightResult = options?.preflightResult;\n\t\tlet messages: AgentMessage[] | undefined;\n\n\t\ttry {\n\t\t\t// Handle extension commands first (execute immediately, even during streaming)\n\t\t\t// Extension commands manage their own LLM interaction via pi.sendMessage()\n\t\t\tif (expandPromptTemplates && text.startsWith(\"/\")) {\n\t\t\t\tconst handled = await this._tryExecuteExtensionCommand(text);\n\t\t\t\tif (handled) {\n\t\t\t\t\t// Extension command executed, no prompt to send\n\t\t\t\t\tpreflightResult?.(true);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Emit input event for extension interception (before skill/template expansion)\n\t\t\tlet currentText = text;\n\t\t\tlet currentImages = options?.images;\n\t\t\tif (this._extensionRunner.hasHandlers(\"input\")) {\n\t\t\t\tconst inputResult = await this._extensionRunner.emitInput(\n\t\t\t\t\tcurrentText,\n\t\t\t\t\tcurrentImages,\n\t\t\t\t\toptions?.source ?? \"interactive\",\n\t\t\t\t);\n\t\t\t\tif (inputResult.action === \"handled\") {\n\t\t\t\t\tpreflightResult?.(true);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (inputResult.action === \"transform\") {\n\t\t\t\t\tcurrentText = inputResult.text;\n\t\t\t\t\tcurrentImages = inputResult.images ?? currentImages;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Expand skill commands (/skill:name args) and prompt templates (/template args)\n\t\t\tlet expandedText = currentText;\n\t\t\tif (expandPromptTemplates) {\n\t\t\t\texpandedText = this._expandSkillCommand(expandedText);\n\t\t\t\texpandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);\n\t\t\t}\n\n\t\t\t// If streaming, queue via steer() or followUp() based on option\n\t\t\tif (this.isStreaming) {\n\t\t\t\tif (!options?.streamingBehavior) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tif (options.streamingBehavior === \"followUp\") {\n\t\t\t\t\tawait this._queueFollowUp(expandedText, currentImages);\n\t\t\t\t} else {\n\t\t\t\t\tawait this._queueSteer(expandedText, currentImages);\n\t\t\t\t}\n\t\t\t\tpreflightResult?.(true);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Flush any pending bash messages before the new prompt\n\t\t\tthis._flushPendingBashMessages();\n\n\t\t\t// Validate model\n\t\t\tif (!this.model) {\n\t\t\t\tthrow new Error(formatNoModelSelectedMessage());\n\t\t\t}\n\n\t\t\tif (!this._modelRegistry.hasConfiguredAuth(this.model)) {\n\t\t\t\tconst isOAuth = this._modelRegistry.isUsingOAuth(this.model);\n\t\t\t\tif (isOAuth) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`Authentication failed for \"${this.model.provider}\". ` +\n\t\t\t\t\t\t\t`Credentials may have expired or network is unavailable. ` +\n\t\t\t\t\t\t\t`Run '/login ${this.model.provider}' to re-authenticate.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tthrow new Error(formatNoApiKeyFoundMessage(this.model.provider));\n\t\t\t}\n\n\t\t\t// Check if we need to compact before sending (catches aborted responses)\n\t\t\tconst lastAssistant = this._findLastAssistantMessage();\n\t\t\tif (lastAssistant && (await this._checkCompaction(lastAssistant, false))) {\n\t\t\t\ttry {\n\t\t\t\t\tawait this.agent.continue();\n\t\t\t\t\twhile (await this._handlePostAgentRun()) {\n\t\t\t\t\t\tawait this.agent.continue();\n\t\t\t\t\t}\n\t\t\t\t} finally {\n\t\t\t\t\tthis._flushPendingBashMessages();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Build messages array (custom message if any, then user message)\n\t\t\tmessages = [];\n\n\t\t\t// Add user message\n\t\t\tconst userContent: (TextContent | ImageContent)[] = [{ type: \"text\", text: expandedText }];\n\t\t\tif (currentImages) {\n\t\t\t\tuserContent.push(...currentImages);\n\t\t\t}\n\t\t\tmessages.push({\n\t\t\t\trole: \"user\",\n\t\t\t\tcontent: userContent,\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t});\n\n\t\t\t// Inject any pending \"nextTurn\" messages as context alongside the user message\n\t\t\tfor (const msg of this._pendingNextTurnMessages) {\n\t\t\t\tmessages.push(msg);\n\t\t\t}\n\t\t\tthis._pendingNextTurnMessages = [];\n\n\t\t\t// Emit before_agent_start extension event\n\t\t\tconst result = await this._extensionRunner.emitBeforeAgentStart(\n\t\t\t\texpandedText,\n\t\t\t\tcurrentImages,\n\t\t\t\tthis._baseSystemPrompt,\n\t\t\t\tthis._baseSystemPromptOptions,\n\t\t\t);\n\t\t\t// Add all custom messages from extensions\n\t\t\tif (result?.messages) {\n\t\t\t\tfor (const msg of result.messages) {\n\t\t\t\t\tmessages.push({\n\t\t\t\t\t\trole: \"custom\",\n\t\t\t\t\t\tcustomType: msg.customType,\n\t\t\t\t\t\tcontent: msg.content,\n\t\t\t\t\t\tdisplay: msg.display,\n\t\t\t\t\t\tdetails: msg.details,\n\t\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Apply extension-modified system prompt, or reset to base\n\t\t\tif (result?.systemPrompt) {\n\t\t\t\tthis.agent.state.systemPrompt = result.systemPrompt;\n\t\t\t} else {\n\t\t\t\t// Ensure we're using the base prompt (in case previous turn had modifications)\n\t\t\t\tthis.agent.state.systemPrompt = this._baseSystemPrompt;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tpreflightResult?.(false);\n\t\t\tthrow error;\n\t\t}\n\n\t\tif (!messages) {\n\t\t\treturn;\n\t\t}\n\n\t\tpreflightResult?.(true);\n\t\tawait this._runAgentPrompt(messages);\n\t}\n\n\t/**\n\t * Try to execute an extension command. Returns true if command was found and executed.\n\t */\n\tprivate async _tryExecuteExtensionCommand(text: string): Promise<boolean> {\n\t\t// Parse command name and args\n\t\tconst spaceIndex = text.indexOf(\" \");\n\t\tconst commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);\n\t\tconst args = spaceIndex === -1 ? \"\" : text.slice(spaceIndex + 1);\n\n\t\tconst command = this._extensionRunner.getCommand(commandName);\n\t\tif (!command) return false;\n\n\t\t// Get command context from extension runner (includes session control methods)\n\t\tconst ctx = this._extensionRunner.createCommandContext();\n\n\t\ttry {\n\t\t\tawait command.handler(args, ctx);\n\t\t\treturn true;\n\t\t} catch (err) {\n\t\t\t// Emit error via extension runner\n\t\t\tthis._extensionRunner.emitError({\n\t\t\t\textensionPath: `command:${commandName}`,\n\t\t\t\tevent: \"command\",\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\treturn true;\n\t\t}\n\t}\n\n\t/**\n\t * Expand skill commands (/skill:name args) to their full content.\n\t * Returns the expanded text, or the original text if not a skill command or skill not found.\n\t * Emits errors via extension runner if file read fails.\n\t */\n\tprivate _expandSkillCommand(text: string): string {\n\t\tif (!text.startsWith(\"/skill:\")) return text;\n\n\t\tconst spaceIndex = text.indexOf(\" \");\n\t\tconst skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);\n\t\tconst args = spaceIndex === -1 ? \"\" : text.slice(spaceIndex + 1).trim();\n\n\t\tconst skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);\n\t\tif (!skill) return text; // Unknown skill, pass through\n\n\t\ttry {\n\t\t\tconst content = readFileSync(skill.filePath, \"utf-8\");\n\t\t\tconst body = stripFrontmatter(content).trim();\n\t\t\tconst skillBlock = `<skill name=\"${skill.name}\" location=\"${skill.filePath}\">\\nReferences are relative to ${skill.baseDir}.\\n\\n${body}\\n</skill>`;\n\t\t\treturn args ? `${skillBlock}\\n\\n${args}` : skillBlock;\n\t\t} catch (err) {\n\t\t\t// Emit error like extension commands do\n\t\t\tthis._extensionRunner.emitError({\n\t\t\t\textensionPath: skill.filePath,\n\t\t\t\tevent: \"skill_expansion\",\n\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t});\n\t\t\treturn text; // Return original on error\n\t\t}\n\t}\n\n\t/**\n\t * Queue a steering message while the agent is running.\n\t * Delivered after the current assistant turn finishes executing its tool calls,\n\t * before the next LLM call.\n\t * Expands skill commands and prompt templates. Errors on extension commands.\n\t * @param images Optional image attachments to include with the message\n\t * @throws Error if text is an extension command\n\t */\n\tasync steer(text: string, images?: ImageContent[]): Promise<void> {\n\t\t// Check for extension commands (cannot be queued)\n\t\tif (text.startsWith(\"/\")) {\n\t\t\tthis._throwIfExtensionCommand(text);\n\t\t}\n\n\t\t// Expand skill commands and prompt templates\n\t\tlet expandedText = this._expandSkillCommand(text);\n\t\texpandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);\n\n\t\tawait this._queueSteer(expandedText, images);\n\t}\n\n\t/**\n\t * Queue a follow-up message to be processed after the agent finishes.\n\t * Delivered only when agent has no more tool calls or steering messages.\n\t * Expands skill commands and prompt templates. Errors on extension commands.\n\t * @param images Optional image attachments to include with the message\n\t * @throws Error if text is an extension command\n\t */\n\tasync followUp(text: string, images?: ImageContent[]): Promise<void> {\n\t\t// Check for extension commands (cannot be queued)\n\t\tif (text.startsWith(\"/\")) {\n\t\t\tthis._throwIfExtensionCommand(text);\n\t\t}\n\n\t\t// Expand skill commands and prompt templates\n\t\tlet expandedText = this._expandSkillCommand(text);\n\t\texpandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);\n\n\t\tawait this._queueFollowUp(expandedText, images);\n\t}\n\n\t/**\n\t * Internal: Queue a steering message (already expanded, no extension command check).\n\t */\n\tprivate async _queueSteer(text: string, images?: ImageContent[]): Promise<void> {\n\t\tthis._steeringMessages.push(text);\n\t\tthis._emitQueueUpdate();\n\t\tconst content: (TextContent | ImageContent)[] = [{ type: \"text\", text }];\n\t\tif (images) {\n\t\t\tcontent.push(...images);\n\t\t}\n\t\tthis.agent.steer({\n\t\t\trole: \"user\",\n\t\t\tcontent,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\t/**\n\t * Internal: Queue a follow-up message (already expanded, no extension command check).\n\t */\n\tprivate async _queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {\n\t\tthis._followUpMessages.push(text);\n\t\tthis._emitQueueUpdate();\n\t\tconst content: (TextContent | ImageContent)[] = [{ type: \"text\", text }];\n\t\tif (images) {\n\t\t\tcontent.push(...images);\n\t\t}\n\t\tthis.agent.followUp({\n\t\t\trole: \"user\",\n\t\t\tcontent,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\t/**\n\t * Throw an error if the text is an extension command.\n\t */\n\tprivate _throwIfExtensionCommand(text: string): void {\n\t\tconst spaceIndex = text.indexOf(\" \");\n\t\tconst commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);\n\t\tconst command = this._extensionRunner.getCommand(commandName);\n\n\t\tif (command) {\n\t\t\tthrow new Error(\n\t\t\t\t`Extension command \"/${commandName}\" cannot be queued. Use prompt() or execute the command when not streaming.`,\n\t\t\t);\n\t\t}\n\t}\n\n\t/**\n\t * Send a custom message to the session. Creates a CustomMessageEntry.\n\t *\n\t * Handles three cases:\n\t * - Streaming: queues message, processed when loop pulls from queue\n\t * - Not streaming + triggerTurn: appends to state/session, starts new turn\n\t * - Not streaming + no trigger: appends to state/session, no turn\n\t *\n\t * @param message Custom message with customType, content, display, details\n\t * @param options.triggerTurn If true and not streaming, triggers a new LLM turn\n\t * @param options.deliverAs Delivery mode: \"steer\", \"followUp\", or \"nextTurn\"\n\t */\n\tasync sendCustomMessage<T = unknown>(\n\t\tmessage: Pick<CustomMessage<T>, \"customType\" | \"content\" | \"display\" | \"details\">,\n\t\toptions?: { triggerTurn?: boolean; deliverAs?: \"steer\" | \"followUp\" | \"nextTurn\" },\n\t): Promise<void> {\n\t\tconst appMessage = {\n\t\t\trole: \"custom\" as const,\n\t\t\tcustomType: message.customType,\n\t\t\tcontent: message.content,\n\t\t\tdisplay: message.display,\n\t\t\tdetails: message.details,\n\t\t\ttimestamp: Date.now(),\n\t\t} satisfies CustomMessage<T>;\n\t\tif (options?.deliverAs === \"nextTurn\") {\n\t\t\tthis._pendingNextTurnMessages.push(appMessage);\n\t\t} else if (this.isStreaming) {\n\t\t\tif (options?.deliverAs === \"followUp\") {\n\t\t\t\tthis.agent.followUp(appMessage);\n\t\t\t} else {\n\t\t\t\tthis.agent.steer(appMessage);\n\t\t\t}\n\t\t} else if (options?.triggerTurn) {\n\t\t\tawait this._runAgentPrompt(appMessage);\n\t\t} else {\n\t\t\tthis.agent.state.messages.push(appMessage);\n\t\t\tthis.sessionManager.appendCustomMessageEntry(\n\t\t\t\tmessage.customType,\n\t\t\t\tmessage.content,\n\t\t\t\tmessage.display,\n\t\t\t\tmessage.details,\n\t\t\t);\n\t\t\tthis._emit({ type: \"message_start\", message: appMessage });\n\t\t\tthis._emit({ type: \"message_end\", message: appMessage });\n\t\t}\n\t}\n\n\t/**\n\t * Send a user message to the agent. Always triggers a turn.\n\t * When the agent is streaming, use deliverAs to specify how to queue the message.\n\t *\n\t * @param content User message content (string or content array)\n\t * @param options.deliverAs Delivery mode when streaming: \"steer\" or \"followUp\"\n\t */\n\tasync sendUserMessage(\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\toptions?: { deliverAs?: \"steer\" | \"followUp\" },\n\t): Promise<void> {\n\t\t// Normalize content to text string + optional images\n\t\tlet text: string;\n\t\tlet images: ImageContent[] | undefined;\n\n\t\tif (typeof content === \"string\") {\n\t\t\ttext = content;\n\t\t} else {\n\t\t\tconst textParts: string[] = [];\n\t\t\timages = [];\n\t\t\tfor (const part of content) {\n\t\t\t\tif (part.type === \"text\") {\n\t\t\t\t\ttextParts.push(part.text);\n\t\t\t\t} else {\n\t\t\t\t\timages.push(part);\n\t\t\t\t}\n\t\t\t}\n\t\t\ttext = textParts.join(\"\\n\");\n\t\t\tif (images.length === 0) images = undefined;\n\t\t}\n\n\t\t// Use prompt() with expandPromptTemplates: false to skip command handling and template expansion\n\t\tawait this.prompt(text, {\n\t\t\texpandPromptTemplates: false,\n\t\t\tstreamingBehavior: options?.deliverAs,\n\t\t\timages,\n\t\t\tsource: \"extension\",\n\t\t});\n\t}\n\n\t/**\n\t * Clear all queued messages and return them.\n\t * Useful for restoring to editor when user aborts.\n\t * @returns Object with steering and followUp arrays\n\t */\n\tclearQueue(): { steering: string[]; followUp: string[] } {\n\t\tconst steering = [...this._steeringMessages];\n\t\tconst followUp = [...this._followUpMessages];\n\t\tthis._steeringMessages = [];\n\t\tthis._followUpMessages = [];\n\t\tthis.agent.clearAllQueues();\n\t\tthis._emitQueueUpdate();\n\t\treturn { steering, followUp };\n\t}\n\n\t/** Number of pending messages (includes both steering and follow-up) */\n\tget pendingMessageCount(): number {\n\t\treturn this._steeringMessages.length + this._followUpMessages.length;\n\t}\n\n\t/** Get pending steering messages (read-only) */\n\tgetSteeringMessages(): readonly string[] {\n\t\treturn this._steeringMessages;\n\t}\n\n\t/** Get pending follow-up messages (read-only) */\n\tgetFollowUpMessages(): readonly string[] {\n\t\treturn this._followUpMessages;\n\t}\n\n\tget resourceLoader(): ResourceLoader {\n\t\treturn this._resourceLoader;\n\t}\n\n\t/**\n\t * Abort current operation and wait for agent to become idle.\n\t */\n\tasync abort(): Promise<void> {\n\t\tthis.abortRetry();\n\t\tthis.agent.abort();\n\t\tawait this.agent.waitForIdle();\n\t}\n\n\t// =========================================================================\n\t// Model Management\n\t// =========================================================================\n\n\tprivate async _emitModelSelect(\n\t\tnextModel: Model<any>,\n\t\tpreviousModel: Model<any> | undefined,\n\t\tsource: \"set\" | \"cycle\" | \"restore\",\n\t): Promise<void> {\n\t\tif (modelsAreEqual(previousModel, nextModel)) return;\n\t\tawait this._extensionRunner.emit({\n\t\t\ttype: \"model_select\",\n\t\t\tmodel: nextModel,\n\t\t\tpreviousModel,\n\t\t\tsource,\n\t\t});\n\t}\n\n\t/**\n\t * Set model directly.\n\t * Validates that auth is configured, saves to session and settings.\n\t * @throws Error if no auth is configured for the model\n\t */\n\tasync setModel(model: Model<any>): Promise<void> {\n\t\tif (!this._modelRegistry.hasConfiguredAuth(model)) {\n\t\t\tthrow new Error(`No API key for ${model.provider}/${model.id}`);\n\t\t}\n\n\t\tconst previousModel = this.model;\n\t\tconst thinkingLevel = this._getThinkingLevelForModelSwitch();\n\t\tthis.agent.state.model = model;\n\t\tthis.sessionManager.appendModelChange(model.provider, model.id);\n\t\tthis.settingsManager.setDefaultModelAndProvider(model.provider, model.id);\n\n\t\t// Re-clamp thinking level for new model's capabilities\n\t\tthis.setThinkingLevel(thinkingLevel);\n\n\t\tawait this._emitModelSelect(model, previousModel, \"set\");\n\t}\n\n\t/**\n\t * Cycle to next/previous model.\n\t * Uses scoped models (from --models flag) if available, otherwise all available models.\n\t * @param direction - \"forward\" (default) or \"backward\"\n\t * @returns The new model info, or undefined if only one model available\n\t */\n\tasync cycleModel(direction: \"forward\" | \"backward\" = \"forward\"): Promise<ModelCycleResult | undefined> {\n\t\tif (this._scopedModels.length > 0) {\n\t\t\treturn this._cycleScopedModel(direction);\n\t\t}\n\t\treturn this._cycleAvailableModel(direction);\n\t}\n\n\tprivate async _cycleScopedModel(direction: \"forward\" | \"backward\"): Promise<ModelCycleResult | undefined> {\n\t\tconst scopedModels = this._scopedModels.filter((scoped) => this._modelRegistry.hasConfiguredAuth(scoped.model));\n\t\tif (scopedModels.length <= 1) return undefined;\n\n\t\tconst currentModel = this.model;\n\t\tlet currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel));\n\n\t\tif (currentIndex === -1) currentIndex = 0;\n\t\tconst len = scopedModels.length;\n\t\tconst nextIndex = direction === \"forward\" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;\n\t\tconst next = scopedModels[nextIndex];\n\t\tconst thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel);\n\n\t\t// Apply model\n\t\tthis.agent.state.model = next.model;\n\t\tthis.sessionManager.appendModelChange(next.model.provider, next.model.id);\n\t\tthis.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id);\n\n\t\t// Apply thinking level.\n\t\t// - Explicit scoped model thinking level overrides current session level\n\t\t// - Undefined scoped model thinking level inherits the current session preference\n\t\t// setThinkingLevel clamps to model capabilities.\n\t\tthis.setThinkingLevel(thinkingLevel);\n\n\t\tawait this._emitModelSelect(next.model, currentModel, \"cycle\");\n\n\t\treturn { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };\n\t}\n\n\tprivate async _cycleAvailableModel(direction: \"forward\" | \"backward\"): Promise<ModelCycleResult | undefined> {\n\t\tconst availableModels = await this._modelRegistry.getAvailable();\n\t\tif (availableModels.length <= 1) return undefined;\n\n\t\tconst currentModel = this.model;\n\t\tlet currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel));\n\n\t\tif (currentIndex === -1) currentIndex = 0;\n\t\tconst len = availableModels.length;\n\t\tconst nextIndex = direction === \"forward\" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;\n\t\tconst nextModel = availableModels[nextIndex];\n\n\t\tconst thinkingLevel = this._getThinkingLevelForModelSwitch();\n\t\tthis.agent.state.model = nextModel;\n\t\tthis.sessionManager.appendModelChange(nextModel.provider, nextModel.id);\n\t\tthis.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);\n","truncated":true,"truncatedBy":"bytes","totalLines":3097,"totalBytes":103318,"outputLines":1500,"outputBytes":51156,"lastLinePartial":false,"firstLineExceedsLimit":false,"maxLines":2000,"maxBytes":51200}},"isError":false,"timestamp":1781615137229}}
|
||
{"type":"message","id":"09dce693","parentId":"8ce5280e","timestamp":"2026-06-16T13:05:40.036Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我继续查看 agent-session.ts 文件的剩余部分。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_42c5d0d13a45478d9ccecdc3","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/agent-session.ts","offset":1501,"limit":1500}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":12023,"output":77,"cacheRead":75072,"cacheWrite":0,"totalTokens":87172,"cost":{"input":0.0048092000000000005,"output":0.000154,"cacheRead":0.00600576,"cacheWrite":0,"total":0.01096896}},"stopReason":"toolUse","timestamp":1781615137236,"responseId":"732a9acbb60148878a5f67d9f375ddad"}}
|
||
{"type":"message","id":"fafffde3","parentId":"09dce693","timestamp":"2026-06-16T13:05:40.040Z","message":{"role":"toolResult","toolCallId":"call_42c5d0d13a45478d9ccecdc3","toolName":"read","content":[{"type":"text","text":"\t\t// Re-clamp thinking level for new model's capabilities\n\t\tthis.setThinkingLevel(thinkingLevel);\n\n\t\tawait this._emitModelSelect(nextModel, currentModel, \"cycle\");\n\n\t\treturn { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };\n\t}\n\n\t// =========================================================================\n\t// Thinking Level Management\n\t// =========================================================================\n\n\t/**\n\t * Set thinking level.\n\t * Clamps to model capabilities based on available thinking levels.\n\t * Saves to session and settings only if the level actually changes.\n\t */\n\tsetThinkingLevel(level: ThinkingLevel): void {\n\t\tconst availableLevels = this.getAvailableThinkingLevels();\n\t\tconst effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);\n\n\t\t// Only persist if actually changing\n\t\tconst previousLevel = this.agent.state.thinkingLevel;\n\t\tconst isChanging = effectiveLevel !== previousLevel;\n\n\t\tthis.agent.state.thinkingLevel = effectiveLevel;\n\n\t\tif (isChanging) {\n\t\t\tthis.sessionManager.appendThinkingLevelChange(effectiveLevel);\n\t\t\tif (this.supportsThinking() || effectiveLevel !== \"off\") {\n\t\t\t\tthis.settingsManager.setDefaultThinkingLevel(effectiveLevel);\n\t\t\t}\n\t\t\tthis._emit({ type: \"thinking_level_changed\", level: effectiveLevel });\n\t\t\tvoid this._extensionRunner.emit({\n\t\t\t\ttype: \"thinking_level_select\",\n\t\t\t\tlevel: effectiveLevel,\n\t\t\t\tpreviousLevel,\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * Cycle to next thinking level.\n\t * @returns New level, or undefined if model doesn't support thinking\n\t */\n\tcycleThinkingLevel(): ThinkingLevel | undefined {\n\t\tif (!this.supportsThinking()) return undefined;\n\n\t\tconst levels = this.getAvailableThinkingLevels();\n\t\tconst currentIndex = levels.indexOf(this.thinkingLevel);\n\t\tconst nextIndex = (currentIndex + 1) % levels.length;\n\t\tconst nextLevel = levels[nextIndex];\n\n\t\tthis.setThinkingLevel(nextLevel);\n\t\treturn nextLevel;\n\t}\n\n\t/**\n\t * Get available thinking levels for current model.\n\t * The provider will clamp to what the specific model supports internally.\n\t */\n\tgetAvailableThinkingLevels(): ThinkingLevel[] {\n\t\tif (!this.model) return THINKING_LEVELS;\n\t\treturn getSupportedThinkingLevels(this.model) as ThinkingLevel[];\n\t}\n\n\t/**\n\t * Check if current model supports thinking/reasoning.\n\t */\n\tsupportsThinking(): boolean {\n\t\treturn !!this.model?.reasoning;\n\t}\n\n\tprivate _getThinkingLevelForModelSwitch(explicitLevel?: ThinkingLevel): ThinkingLevel {\n\t\tif (explicitLevel !== undefined) {\n\t\t\treturn explicitLevel;\n\t\t}\n\t\tif (!this.supportsThinking()) {\n\t\t\treturn this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;\n\t\t}\n\t\treturn this.thinkingLevel;\n\t}\n\n\tprivate _clampThinkingLevel(level: ThinkingLevel, _availableLevels: ThinkingLevel[]): ThinkingLevel {\n\t\treturn this.model ? (clampThinkingLevel(this.model, level) as ThinkingLevel) : \"off\";\n\t}\n\n\t// =========================================================================\n\t// Queue Mode Management\n\t// =========================================================================\n\n\t/**\n\t * Set steering message mode.\n\t * Saves to settings.\n\t */\n\tsetSteeringMode(mode: \"all\" | \"one-at-a-time\"): void {\n\t\tthis.agent.steeringMode = mode;\n\t\tthis.settingsManager.setSteeringMode(mode);\n\t}\n\n\t/**\n\t * Set follow-up message mode.\n\t * Saves to settings.\n\t */\n\tsetFollowUpMode(mode: \"all\" | \"one-at-a-time\"): void {\n\t\tthis.agent.followUpMode = mode;\n\t\tthis.settingsManager.setFollowUpMode(mode);\n\t}\n\n\t// =========================================================================\n\t// Compaction\n\t// =========================================================================\n\n\t/**\n\t * Manually compact the session context.\n\t * Aborts current agent operation first.\n\t * @param customInstructions Optional instructions for the compaction summary\n\t */\n\tasync compact(customInstructions?: string): Promise<CompactionResult> {\n\t\tthis._disconnectFromAgent();\n\t\tawait this.abort();\n\t\tthis._compactionAbortController = new AbortController();\n\t\tthis._emit({ type: \"compaction_start\", reason: \"manual\" });\n\n\t\ttry {\n\t\t\tif (!this.model) {\n\t\t\t\tthrow new Error(formatNoModelSelectedMessage());\n\t\t\t}\n\n\t\t\tconst { apiKey, headers } = await this._getCompactionRequestAuth(this.model);\n\n\t\t\tconst pathEntries = this.sessionManager.getBranch();\n\t\t\tconst settings = this.settingsManager.getCompactionSettings();\n\n\t\t\tconst preparation = prepareCompaction(pathEntries, settings);\n\t\t\tif (!preparation) {\n\t\t\t\t// Check why we can't compact\n\t\t\t\tconst lastEntry = pathEntries[pathEntries.length - 1];\n\t\t\t\tif (lastEntry?.type === \"compaction\") {\n\t\t\t\t\tthrow new Error(\"Already compacted\");\n\t\t\t\t}\n\t\t\t\tthrow new Error(\"Nothing to compact (session too small)\");\n\t\t\t}\n\n\t\t\tlet extensionCompaction: CompactionResult | undefined;\n\t\t\tlet fromExtension = false;\n\n\t\t\tif (this._extensionRunner.hasHandlers(\"session_before_compact\")) {\n\t\t\t\tconst result = (await this._extensionRunner.emit({\n\t\t\t\t\ttype: \"session_before_compact\",\n\t\t\t\t\tpreparation,\n\t\t\t\t\tbranchEntries: pathEntries,\n\t\t\t\t\tcustomInstructions,\n\t\t\t\t\tsignal: this._compactionAbortController.signal,\n\t\t\t\t})) as SessionBeforeCompactResult | undefined;\n\n\t\t\t\tif (result?.cancel) {\n\t\t\t\t\tthrow new Error(\"Compaction cancelled\");\n\t\t\t\t}\n\n\t\t\t\tif (result?.compaction) {\n\t\t\t\t\textensionCompaction = result.compaction;\n\t\t\t\t\tfromExtension = true;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlet summary: string;\n\t\t\tlet firstKeptEntryId: string;\n\t\t\tlet tokensBefore: number;\n\t\t\tlet details: unknown;\n\n\t\t\tif (extensionCompaction) {\n\t\t\t\t// Extension provided compaction content\n\t\t\t\tsummary = extensionCompaction.summary;\n\t\t\t\tfirstKeptEntryId = extensionCompaction.firstKeptEntryId;\n\t\t\t\ttokensBefore = extensionCompaction.tokensBefore;\n\t\t\t\tdetails = extensionCompaction.details;\n\t\t\t} else {\n\t\t\t\t// Generate compaction result\n\t\t\t\tconst result = await compact(\n\t\t\t\t\tpreparation,\n\t\t\t\t\tthis.model,\n\t\t\t\t\tapiKey,\n\t\t\t\t\theaders,\n\t\t\t\t\tcustomInstructions,\n\t\t\t\t\tthis._compactionAbortController.signal,\n\t\t\t\t\tthis.thinkingLevel,\n\t\t\t\t\tthis.agent.streamFn,\n\t\t\t\t);\n\t\t\t\tsummary = result.summary;\n\t\t\t\tfirstKeptEntryId = result.firstKeptEntryId;\n\t\t\t\ttokensBefore = result.tokensBefore;\n\t\t\t\tdetails = result.details;\n\t\t\t}\n\n\t\t\tif (this._compactionAbortController.signal.aborted) {\n\t\t\t\tthrow new Error(\"Compaction cancelled\");\n\t\t\t}\n\n\t\t\tthis.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);\n\t\t\tconst newEntries = this.sessionManager.getEntries();\n\t\t\tconst sessionContext = this.sessionManager.buildSessionContext();\n\t\t\tthis.agent.state.messages = sessionContext.messages;\n\n\t\t\t// Get the saved compaction entry for the extension event\n\t\t\tconst savedCompactionEntry = newEntries.find((e) => e.type === \"compaction\" && e.summary === summary) as\n\t\t\t\t| CompactionEntry\n\t\t\t\t| undefined;\n\n\t\t\tif (this._extensionRunner && savedCompactionEntry) {\n\t\t\t\tawait this._extensionRunner.emit({\n\t\t\t\t\ttype: \"session_compact\",\n\t\t\t\t\tcompactionEntry: savedCompactionEntry,\n\t\t\t\t\tfromExtension,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst compactionResult = {\n\t\t\t\tsummary,\n\t\t\t\tfirstKeptEntryId,\n\t\t\t\ttokensBefore,\n\t\t\t\tdetails,\n\t\t\t};\n\t\t\tthis._emit({\n\t\t\t\ttype: \"compaction_end\",\n\t\t\t\treason: \"manual\",\n\t\t\t\tresult: compactionResult,\n\t\t\t\taborted: false,\n\t\t\t\twillRetry: false,\n\t\t\t});\n\t\t\treturn compactionResult;\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tconst aborted = message === \"Compaction cancelled\" || (error instanceof Error && error.name === \"AbortError\");\n\t\t\tthis._emit({\n\t\t\t\ttype: \"compaction_end\",\n\t\t\t\treason: \"manual\",\n\t\t\t\tresult: undefined,\n\t\t\t\taborted,\n\t\t\t\twillRetry: false,\n\t\t\t\terrorMessage: aborted ? undefined : `Compaction failed: ${message}`,\n\t\t\t});\n\t\t\tthrow error;\n\t\t} finally {\n\t\t\tthis._compactionAbortController = undefined;\n\t\t\tthis._reconnectToAgent();\n\t\t}\n\t}\n\n\t/**\n\t * Cancel in-progress compaction (manual or auto).\n\t */\n\tabortCompaction(): void {\n\t\tthis._compactionAbortController?.abort();\n\t\tthis._autoCompactionAbortController?.abort();\n\t}\n\n\t/**\n\t * Cancel in-progress branch summarization.\n\t */\n\tabortBranchSummary(): void {\n\t\tthis._branchSummaryAbortController?.abort();\n\t}\n\n\t/**\n\t * Check if compaction is needed and run it.\n\t * Called after agent_end and before prompt submission.\n\t *\n\t * Two cases:\n\t * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry\n\t * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)\n\t *\n\t * @param assistantMessage The assistant message to check\n\t * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true\n\t */\n\tprivate async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean> {\n\t\tconst settings = this.settingsManager.getCompactionSettings();\n\t\tif (!settings.enabled) return false;\n\n\t\t// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false\n\t\tif (skipAbortedCheck && assistantMessage.stopReason === \"aborted\") return false;\n\n\t\tconst contextWindow = this.model?.contextWindow ?? 0;\n\n\t\t// Skip overflow check if the message came from a different model.\n\t\t// This handles the case where user switched from a smaller-context model (e.g. opus)\n\t\t// to a larger-context model (e.g. codex) - the overflow error from the old model\n\t\t// shouldn't trigger compaction for the new model.\n\t\tconst sameModel =\n\t\t\tthis.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;\n\n\t\t// Skip compaction checks if this assistant message is older than the latest\n\t\t// compaction boundary. This prevents a stale pre-compaction usage/error\n\t\t// from retriggering compaction on the first prompt after compaction.\n\t\tconst compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());\n\t\tconst assistantIsFromBeforeCompaction =\n\t\t\tcompactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();\n\t\tif (assistantIsFromBeforeCompaction) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// Case 1: Overflow - LLM returned context overflow error\n\t\tif (sameModel && isContextOverflow(assistantMessage, contextWindow)) {\n\t\t\tif (this._overflowRecoveryAttempted) {\n\t\t\t\tthis._emit({\n\t\t\t\t\ttype: \"compaction_end\",\n\t\t\t\t\treason: \"overflow\",\n\t\t\t\t\tresult: undefined,\n\t\t\t\t\taborted: false,\n\t\t\t\t\twillRetry: false,\n\t\t\t\t\terrorMessage:\n\t\t\t\t\t\t\"Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.\",\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tthis._overflowRecoveryAttempted = true;\n\t\t\t// Remove the error message from agent state (it IS saved to session for history,\n\t\t\t// but we don't want it in context for the retry)\n\t\t\tconst messages = this.agent.state.messages;\n\t\t\tif (messages.length > 0 && messages[messages.length - 1].role === \"assistant\") {\n\t\t\t\tthis.agent.state.messages = messages.slice(0, -1);\n\t\t\t}\n\t\t\treturn await this._runAutoCompaction(\"overflow\", true);\n\t\t}\n\n\t\t// Case 2: Threshold - context is getting large\n\t\t// For error messages (no usage data), estimate from last successful response.\n\t\t// This ensures sessions that hit persistent API errors (e.g. 529) can still compact.\n\t\tlet contextTokens: number;\n\t\tif (assistantMessage.stopReason === \"error\") {\n\t\t\tconst messages = this.agent.state.messages;\n\t\t\tconst estimate = estimateContextTokens(messages);\n\t\t\tif (estimate.lastUsageIndex === null) return false; // No usage data at all\n\t\t\t// Verify the usage source is post-compaction. Kept pre-compaction messages\n\t\t\t// have stale usage reflecting the old (larger) context and would falsely\n\t\t\t// trigger compaction right after one just finished.\n\t\t\tconst usageMsg = messages[estimate.lastUsageIndex];\n\t\t\tif (\n\t\t\t\tcompactionEntry &&\n\t\t\t\tusageMsg.role === \"assistant\" &&\n\t\t\t\t(usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime()\n\t\t\t) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tcontextTokens = estimate.tokens;\n\t\t} else {\n\t\t\tcontextTokens = calculateContextTokens(assistantMessage.usage);\n\t\t}\n\t\tif (shouldCompact(contextTokens, contextWindow, settings)) {\n\t\t\treturn await this._runAutoCompaction(\"threshold\", false);\n\t\t}\n\t\treturn false;\n\t}\n\n\t/**\n\t * Internal: Run auto-compaction with events.\n\t */\n\tprivate async _runAutoCompaction(reason: \"overflow\" | \"threshold\", willRetry: boolean): Promise<boolean> {\n\t\tconst settings = this.settingsManager.getCompactionSettings();\n\n\t\tthis._emit({ type: \"compaction_start\", reason });\n\t\tthis._autoCompactionAbortController = new AbortController();\n\n\t\ttry {\n\t\t\tif (!this.model) {\n\t\t\t\tthis._emit({\n\t\t\t\t\ttype: \"compaction_end\",\n\t\t\t\t\treason,\n\t\t\t\t\tresult: undefined,\n\t\t\t\t\taborted: false,\n\t\t\t\t\twillRetry: false,\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tlet apiKey: string | undefined;\n\t\t\tlet headers: Record<string, string> | undefined;\n\t\t\tif (this.agent.streamFn === streamSimple) {\n\t\t\t\tconst authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);\n\t\t\t\tif (!authResult.ok || !authResult.apiKey) {\n\t\t\t\t\tthis._emit({\n\t\t\t\t\t\ttype: \"compaction_end\",\n\t\t\t\t\t\treason,\n\t\t\t\t\t\tresult: undefined,\n\t\t\t\t\t\taborted: false,\n\t\t\t\t\t\twillRetry: false,\n\t\t\t\t\t});\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\tapiKey = authResult.apiKey;\n\t\t\t\theaders = authResult.headers;\n\t\t\t} else {\n\t\t\t\t({ apiKey, headers } = await this._getCompactionRequestAuth(this.model));\n\t\t\t}\n\n\t\t\tconst pathEntries = this.sessionManager.getBranch();\n\n\t\t\tconst preparation = prepareCompaction(pathEntries, settings);\n\t\t\tif (!preparation) {\n\t\t\t\tthis._emit({\n\t\t\t\t\ttype: \"compaction_end\",\n\t\t\t\t\treason,\n\t\t\t\t\tresult: undefined,\n\t\t\t\t\taborted: false,\n\t\t\t\t\twillRetry: false,\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tlet extensionCompaction: CompactionResult | undefined;\n\t\t\tlet fromExtension = false;\n\n\t\t\tif (this._extensionRunner.hasHandlers(\"session_before_compact\")) {\n\t\t\t\tconst extensionResult = (await this._extensionRunner.emit({\n\t\t\t\t\ttype: \"session_before_compact\",\n\t\t\t\t\tpreparation,\n\t\t\t\t\tbranchEntries: pathEntries,\n\t\t\t\t\tcustomInstructions: undefined,\n\t\t\t\t\tsignal: this._autoCompactionAbortController.signal,\n\t\t\t\t})) as SessionBeforeCompactResult | undefined;\n\n\t\t\t\tif (extensionResult?.cancel) {\n\t\t\t\t\tthis._emit({\n\t\t\t\t\t\ttype: \"compaction_end\",\n\t\t\t\t\t\treason,\n\t\t\t\t\t\tresult: undefined,\n\t\t\t\t\t\taborted: true,\n\t\t\t\t\t\twillRetry: false,\n\t\t\t\t\t});\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\n\t\t\t\tif (extensionResult?.compaction) {\n\t\t\t\t\textensionCompaction = extensionResult.compaction;\n\t\t\t\t\tfromExtension = true;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlet summary: string;\n\t\t\tlet firstKeptEntryId: string;\n\t\t\tlet tokensBefore: number;\n\t\t\tlet details: unknown;\n\n\t\t\tif (extensionCompaction) {\n\t\t\t\t// Extension provided compaction content\n\t\t\t\tsummary = extensionCompaction.summary;\n\t\t\t\tfirstKeptEntryId = extensionCompaction.firstKeptEntryId;\n\t\t\t\ttokensBefore = extensionCompaction.tokensBefore;\n\t\t\t\tdetails = extensionCompaction.details;\n\t\t\t} else {\n\t\t\t\t// Generate compaction result\n\t\t\t\tconst compactResult = await compact(\n\t\t\t\t\tpreparation,\n\t\t\t\t\tthis.model,\n\t\t\t\t\tapiKey,\n\t\t\t\t\theaders,\n\t\t\t\t\tundefined,\n\t\t\t\t\tthis._autoCompactionAbortController.signal,\n\t\t\t\t\tthis.thinkingLevel,\n\t\t\t\t\tthis.agent.streamFn,\n\t\t\t\t);\n\t\t\t\tsummary = compactResult.summary;\n\t\t\t\tfirstKeptEntryId = compactResult.firstKeptEntryId;\n\t\t\t\ttokensBefore = compactResult.tokensBefore;\n\t\t\t\tdetails = compactResult.details;\n\t\t\t}\n\n\t\t\tif (this._autoCompactionAbortController.signal.aborted) {\n\t\t\t\tthis._emit({\n\t\t\t\t\ttype: \"compaction_end\",\n\t\t\t\t\treason,\n\t\t\t\t\tresult: undefined,\n\t\t\t\t\taborted: true,\n\t\t\t\t\twillRetry: false,\n\t\t\t\t});\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tthis.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);\n\t\t\tconst newEntries = this.sessionManager.getEntries();\n\t\t\tconst sessionContext = this.sessionManager.buildSessionContext();\n\t\t\tthis.agent.state.messages = sessionContext.messages;\n\n\t\t\t// Get the saved compaction entry for the extension event\n\t\t\tconst savedCompactionEntry = newEntries.find((e) => e.type === \"compaction\" && e.summary === summary) as\n\t\t\t\t| CompactionEntry\n\t\t\t\t| undefined;\n\n\t\t\tif (this._extensionRunner && savedCompactionEntry) {\n\t\t\t\tawait this._extensionRunner.emit({\n\t\t\t\t\ttype: \"session_compact\",\n\t\t\t\t\tcompactionEntry: savedCompactionEntry,\n\t\t\t\t\tfromExtension,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst result: CompactionResult = {\n\t\t\t\tsummary,\n\t\t\t\tfirstKeptEntryId,\n\t\t\t\ttokensBefore,\n\t\t\t\tdetails,\n\t\t\t};\n\t\t\tthis._emit({ type: \"compaction_end\", reason, result, aborted: false, willRetry });\n\n\t\t\tif (willRetry) {\n\t\t\t\tconst messages = this.agent.state.messages;\n\t\t\t\tconst lastMsg = messages[messages.length - 1];\n\t\t\t\tif (lastMsg?.role === \"assistant\" && (lastMsg as AssistantMessage).stopReason === \"error\") {\n\t\t\t\t\tthis.agent.state.messages = messages.slice(0, -1);\n\t\t\t\t}\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\t// Auto-compaction can complete while follow-up/steering/custom messages are waiting.\n\t\t\t// Continue once so queued messages are delivered.\n\t\t\treturn this.agent.hasQueuedMessages();\n\t\t} catch (error) {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"compaction failed\";\n\t\t\tthis._emit({\n\t\t\t\ttype: \"compaction_end\",\n\t\t\t\treason,\n\t\t\t\tresult: undefined,\n\t\t\t\taborted: false,\n\t\t\t\twillRetry: false,\n\t\t\t\terrorMessage:\n\t\t\t\t\treason === \"overflow\"\n\t\t\t\t\t\t? `Context overflow recovery failed: ${errorMessage}`\n\t\t\t\t\t\t: `Auto-compaction failed: ${errorMessage}`,\n\t\t\t});\n\t\t\treturn false;\n\t\t} finally {\n\t\t\tthis._autoCompactionAbortController = undefined;\n\t\t}\n\t}\n\n\t/**\n\t * Toggle auto-compaction setting.\n\t */\n\tsetAutoCompactionEnabled(enabled: boolean): void {\n\t\tthis.settingsManager.setCompactionEnabled(enabled);\n\t}\n\n\t/** Whether auto-compaction is enabled */\n\tget autoCompactionEnabled(): boolean {\n\t\treturn this.settingsManager.getCompactionEnabled();\n\t}\n\n\tasync bindExtensions(bindings: ExtensionBindings): Promise<void> {\n\t\tif (bindings.uiContext !== undefined) {\n\t\t\tthis._extensionUIContext = bindings.uiContext;\n\t\t}\n\t\tif (bindings.commandContextActions !== undefined) {\n\t\t\tthis._extensionCommandContextActions = bindings.commandContextActions;\n\t\t}\n\t\tif (bindings.abortHandler !== undefined) {\n\t\t\tthis._extensionAbortHandler = bindings.abortHandler;\n\t\t}\n\t\tif (bindings.shutdownHandler !== undefined) {\n\t\t\tthis._extensionShutdownHandler = bindings.shutdownHandler;\n\t\t}\n\t\tif (bindings.onError !== undefined) {\n\t\t\tthis._extensionErrorListener = bindings.onError;\n\t\t}\n\n\t\tthis._applyExtensionBindings(this._extensionRunner);\n\t\tawait this._extensionRunner.emit(this._sessionStartEvent);\n\t\tawait this.extendResourcesFromExtensions(this._sessionStartEvent.reason === \"reload\" ? \"reload\" : \"startup\");\n\t}\n\n\tprivate async extendResourcesFromExtensions(reason: \"startup\" | \"reload\"): Promise<void> {\n\t\tif (!this._extensionRunner.hasHandlers(\"resources_discover\")) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst { skillPaths, promptPaths, themePaths } = await this._extensionRunner.emitResourcesDiscover(\n\t\t\tthis._cwd,\n\t\t\treason,\n\t\t);\n\n\t\tif (skillPaths.length === 0 && promptPaths.length === 0 && themePaths.length === 0) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst extensionPaths: ResourceExtensionPaths = {\n\t\t\tskillPaths: this.buildExtensionResourcePaths(skillPaths),\n\t\t\tpromptPaths: this.buildExtensionResourcePaths(promptPaths),\n\t\t\tthemePaths: this.buildExtensionResourcePaths(themePaths),\n\t\t};\n\n\t\tthis._resourceLoader.extendResources(extensionPaths);\n\t\tthis._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames());\n\t\tthis.agent.state.systemPrompt = this._baseSystemPrompt;\n\t}\n\n\tprivate buildExtensionResourcePaths(entries: Array<{ path: string; extensionPath: string }>): Array<{\n\t\tpath: string;\n\t\tmetadata: { source: string; scope: \"temporary\"; origin: \"top-level\"; baseDir?: string };\n\t}> {\n\t\treturn entries.map((entry) => {\n\t\t\tconst source = this.getExtensionSourceLabel(entry.extensionPath);\n\t\t\tconst baseDir = entry.extensionPath.startsWith(\"<\") ? undefined : dirname(entry.extensionPath);\n\t\t\treturn {\n\t\t\t\tpath: entry.path,\n\t\t\t\tmetadata: {\n\t\t\t\t\tsource,\n\t\t\t\t\tscope: \"temporary\",\n\t\t\t\t\torigin: \"top-level\",\n\t\t\t\t\tbaseDir,\n\t\t\t\t},\n\t\t\t};\n\t\t});\n\t}\n\n\tprivate getExtensionSourceLabel(extensionPath: string): string {\n\t\tif (extensionPath.startsWith(\"<\")) {\n\t\t\treturn `extension:${extensionPath.replace(/[<>]/g, \"\")}`;\n\t\t}\n\t\tconst base = basename(extensionPath);\n\t\tconst name = base.replace(/\\.(ts|js)$/, \"\");\n\t\treturn `extension:${name}`;\n\t}\n\n\tprivate _applyExtensionBindings(runner: ExtensionRunner): void {\n\t\trunner.setUIContext(this._extensionUIContext);\n\t\trunner.bindCommandContext(this._extensionCommandContextActions);\n\n\t\tthis._extensionErrorUnsubscriber?.();\n\t\tthis._extensionErrorUnsubscriber = this._extensionErrorListener\n\t\t\t? runner.onError(this._extensionErrorListener)\n\t\t\t: undefined;\n\t}\n\n\tprivate _refreshCurrentModelFromRegistry(): void {\n\t\tconst currentModel = this.model;\n\t\tif (!currentModel) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst refreshedModel = this._modelRegistry.find(currentModel.provider, currentModel.id);\n\t\tif (!refreshedModel || refreshedModel === currentModel) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.agent.state.model = refreshedModel;\n\t}\n\n\tprivate _bindExtensionCore(runner: ExtensionRunner): void {\n\t\tconst getCommands = (): SlashCommandInfo[] => {\n\t\t\tconst extensionCommands: SlashCommandInfo[] = runner.getRegisteredCommands().map((command) => ({\n\t\t\t\tname: command.invocationName,\n\t\t\t\tdescription: command.description,\n\t\t\t\tsource: \"extension\",\n\t\t\t\tsourceInfo: command.sourceInfo,\n\t\t\t}));\n\n\t\t\tconst templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({\n\t\t\t\tname: template.name,\n\t\t\t\tdescription: template.description,\n\t\t\t\tsource: \"prompt\",\n\t\t\t\tsourceInfo: template.sourceInfo,\n\t\t\t}));\n\n\t\t\tconst skills: SlashCommandInfo[] = this._resourceLoader.getSkills().skills.map((skill) => ({\n\t\t\t\tname: `skill:${skill.name}`,\n\t\t\t\tdescription: skill.description,\n\t\t\t\tsource: \"skill\",\n\t\t\t\tsourceInfo: skill.sourceInfo,\n\t\t\t}));\n\n\t\t\treturn [...extensionCommands, ...templates, ...skills];\n\t\t};\n\n\t\trunner.bindCore(\n\t\t\t{\n\t\t\t\tsendMessage: (message, options) => {\n\t\t\t\t\tthis.sendCustomMessage(message, options).catch((err) => {\n\t\t\t\t\t\trunner.emitError({\n\t\t\t\t\t\t\textensionPath: \"<runtime>\",\n\t\t\t\t\t\t\tevent: \"send_message\",\n\t\t\t\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t\t\t\t});\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t\tsendUserMessage: (content, options) => {\n\t\t\t\t\tthis.sendUserMessage(content, options).catch((err) => {\n\t\t\t\t\t\trunner.emitError({\n\t\t\t\t\t\t\textensionPath: \"<runtime>\",\n\t\t\t\t\t\t\tevent: \"send_user_message\",\n\t\t\t\t\t\t\terror: err instanceof Error ? err.message : String(err),\n\t\t\t\t\t\t});\n\t\t\t\t\t});\n\t\t\t\t},\n\t\t\t\tappendEntry: (customType, data) => {\n\t\t\t\t\tthis.sessionManager.appendCustomEntry(customType, data);\n\t\t\t\t},\n\t\t\t\tsetSessionName: (name) => {\n\t\t\t\t\tthis.setSessionName(name);\n\t\t\t\t},\n\t\t\t\tgetSessionName: () => {\n\t\t\t\t\treturn this.sessionManager.getSessionName();\n\t\t\t\t},\n\t\t\t\tsetLabel: (entryId, label) => {\n\t\t\t\t\tthis.sessionManager.appendLabelChange(entryId, label);\n\t\t\t\t},\n\t\t\t\tgetActiveTools: () => this.getActiveToolNames(),\n\t\t\t\tgetAllTools: () => this.getAllTools(),\n\t\t\t\tsetActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),\n\t\t\t\trefreshTools: () => this._refreshToolRegistry(),\n\t\t\t\tgetCommands,\n\t\t\t\tsetModel: async (model) => {\n\t\t\t\t\tif (!this.modelRegistry.hasConfiguredAuth(model)) return false;\n\t\t\t\t\tawait this.setModel(model);\n\t\t\t\t\treturn true;\n\t\t\t\t},\n\t\t\t\tgetThinkingLevel: () => this.thinkingLevel,\n\t\t\t\tsetThinkingLevel: (level) => this.setThinkingLevel(level),\n\t\t\t},\n\t\t\t{\n\t\t\t\tgetModel: () => this.model,\n\t\t\t\tisIdle: () => !this.isStreaming,\n\t\t\t\tgetSignal: () => this.agent.signal,\n\t\t\t\tabort: () => {\n\t\t\t\t\tif (this._extensionAbortHandler) {\n\t\t\t\t\t\tthis._extensionAbortHandler();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tvoid this.abort();\n\t\t\t\t},\n\t\t\t\thasPendingMessages: () => this.pendingMessageCount > 0,\n\t\t\t\tshutdown: () => {\n\t\t\t\t\tthis._extensionShutdownHandler?.();\n\t\t\t\t},\n\t\t\t\tgetContextUsage: () => this.getContextUsage(),\n\t\t\t\tcompact: (options) => {\n\t\t\t\t\tvoid (async () => {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst result = await this.compact(options?.customInstructions);\n\t\t\t\t\t\t\toptions?.onComplete?.(result);\n\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\tconst err = error instanceof Error ? error : new Error(String(error));\n\t\t\t\t\t\t\toptions?.onError?.(err);\n\t\t\t\t\t\t}\n\t\t\t\t\t})();\n\t\t\t\t},\n\t\t\t\tgetSystemPrompt: () => this.systemPrompt,\n\t\t\t},\n\t\t\t{\n\t\t\t\tregisterProvider: (name, config) => {\n\t\t\t\t\tthis._modelRegistry.registerProvider(name, config);\n\t\t\t\t\tthis._refreshCurrentModelFromRegistry();\n\t\t\t\t},\n\t\t\t\tunregisterProvider: (name) => {\n\t\t\t\t\tthis._modelRegistry.unregisterProvider(name);\n\t\t\t\t\tthis._refreshCurrentModelFromRegistry();\n\t\t\t\t},\n\t\t\t},\n\t\t);\n\t}\n\n\tprivate _refreshToolRegistry(options?: { activeToolNames?: string[]; includeAllExtensionTools?: boolean }): void {\n\t\tconst previousRegistryNames = new Set(this._toolRegistry.keys());\n\t\tconst previousActiveToolNames = this.getActiveToolNames();\n\t\tconst allowedToolNames = this._allowedToolNames;\n\t\tconst isAllowedTool = (name: string): boolean => !allowedToolNames || allowedToolNames.has(name);\n\n\t\tconst registeredTools = this._extensionRunner.getAllRegisteredTools();\n\t\tconst allCustomTools = [\n\t\t\t...registeredTools,\n\t\t\t...this._customTools.map((definition) => ({\n\t\t\t\tdefinition,\n\t\t\t\tsourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: \"sdk\" }),\n\t\t\t})),\n\t\t].filter((tool) => isAllowedTool(tool.definition.name));\n\t\tconst definitionRegistry = new Map<string, ToolDefinitionEntry>(\n\t\t\tArray.from(this._baseToolDefinitions.entries())\n\t\t\t\t.filter(([name]) => isAllowedTool(name))\n\t\t\t\t.map(([name, definition]) => [\n\t\t\t\t\tname,\n\t\t\t\t\t{\n\t\t\t\t\t\tdefinition,\n\t\t\t\t\t\tsourceInfo: createSyntheticSourceInfo(`<builtin:${name}>`, { source: \"builtin\" }),\n\t\t\t\t\t},\n\t\t\t\t]),\n\t\t);\n\t\tfor (const tool of allCustomTools) {\n\t\t\tdefinitionRegistry.set(tool.definition.name, {\n\t\t\t\tdefinition: tool.definition,\n\t\t\t\tsourceInfo: tool.sourceInfo,\n\t\t\t});\n\t\t}\n\t\tthis._toolDefinitions = definitionRegistry;\n\t\tthis._toolPromptSnippets = new Map(\n\t\t\tArray.from(definitionRegistry.values())\n\t\t\t\t.map(({ definition }) => {\n\t\t\t\t\tconst snippet = this._normalizePromptSnippet(definition.promptSnippet);\n\t\t\t\t\treturn snippet ? ([definition.name, snippet] as const) : undefined;\n\t\t\t\t})\n\t\t\t\t.filter((entry): entry is readonly [string, string] => entry !== undefined),\n\t\t);\n\t\tthis._toolPromptGuidelines = new Map(\n\t\t\tArray.from(definitionRegistry.values())\n\t\t\t\t.map(({ definition }) => {\n\t\t\t\t\tconst guidelines = this._normalizePromptGuidelines(definition.promptGuidelines);\n\t\t\t\t\treturn guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined;\n\t\t\t\t})\n\t\t\t\t.filter((entry): entry is readonly [string, string[]] => entry !== undefined),\n\t\t);\n\t\tconst runner = this._extensionRunner;\n\t\tconst wrappedExtensionTools = wrapRegisteredTools(allCustomTools, runner);\n\t\tconst wrappedBuiltInTools = wrapRegisteredTools(\n\t\t\tArray.from(this._baseToolDefinitions.values())\n\t\t\t\t.filter((definition) => isAllowedTool(definition.name))\n\t\t\t\t.map((definition) => ({\n\t\t\t\t\tdefinition,\n\t\t\t\t\tsourceInfo: createSyntheticSourceInfo(`<builtin:${definition.name}>`, { source: \"builtin\" }),\n\t\t\t\t})),\n\t\t\trunner,\n\t\t);\n\n\t\tconst toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool]));\n\t\tfor (const tool of wrappedExtensionTools as AgentTool[]) {\n\t\t\ttoolRegistry.set(tool.name, tool);\n\t\t}\n\t\tthis._toolRegistry = toolRegistry;\n\n\t\tconst nextActiveToolNames = (\n\t\t\toptions?.activeToolNames ? [...options.activeToolNames] : [...previousActiveToolNames]\n\t\t).filter((name) => isAllowedTool(name));\n\n\t\tif (allowedToolNames) {\n\t\t\tfor (const toolName of this._toolRegistry.keys()) {\n\t\t\t\tif (allowedToolNames.has(toolName)) {\n\t\t\t\t\tnextActiveToolNames.push(toolName);\n\t\t\t\t}\n\t\t\t}\n\t\t} else if (options?.includeAllExtensionTools) {\n\t\t\tfor (const tool of wrappedExtensionTools) {\n\t\t\t\tnextActiveToolNames.push(tool.name);\n\t\t\t}\n\t\t} else if (!options?.activeToolNames) {\n\t\t\tfor (const toolName of this._toolRegistry.keys()) {\n\t\t\t\tif (!previousRegistryNames.has(toolName)) {\n\t\t\t\t\tnextActiveToolNames.push(toolName);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.setActiveToolsByName([...new Set(nextActiveToolNames)]);\n\t}\n\n\tprivate _buildRuntime(options: {\n\t\tactiveToolNames?: string[];\n\t\tflagValues?: Map<string, boolean | string>;\n\t\tincludeAllExtensionTools?: boolean;\n\t}): void {\n\t\tconst autoResizeImages = this.settingsManager.getImageAutoResize();\n\t\tconst shellCommandPrefix = this.settingsManager.getShellCommandPrefix();\n\t\tconst shellPath = this.settingsManager.getShellPath();\n\t\tconst baseToolDefinitions = this._baseToolsOverride\n\t\t\t? Object.fromEntries(\n\t\t\t\t\tObject.entries(this._baseToolsOverride).map(([name, tool]) => [\n\t\t\t\t\t\tname,\n\t\t\t\t\t\tcreateToolDefinitionFromAgentTool(tool),\n\t\t\t\t\t]),\n\t\t\t\t)\n\t\t\t: createAllToolDefinitions(this._cwd, {\n\t\t\t\t\tread: { autoResizeImages },\n\t\t\t\t\tbash: { commandPrefix: shellCommandPrefix, shellPath },\n\t\t\t\t});\n\n\t\tthis._baseToolDefinitions = new Map(\n\t\t\tObject.entries(baseToolDefinitions).map(([name, tool]) => [name, tool as ToolDefinition]),\n\t\t);\n\n\t\tconst extensionsResult = this._resourceLoader.getExtensions();\n\t\tif (options.flagValues) {\n\t\t\tfor (const [name, value] of options.flagValues) {\n\t\t\t\textensionsResult.runtime.flagValues.set(name, value);\n\t\t\t}\n\t\t}\n\n\t\tthis._extensionRunner = new ExtensionRunner(\n\t\t\textensionsResult.extensions,\n\t\t\textensionsResult.runtime,\n\t\t\tthis._cwd,\n\t\t\tthis.sessionManager,\n\t\t\tthis._modelRegistry,\n\t\t);\n\t\tif (this._extensionRunnerRef) {\n\t\t\tthis._extensionRunnerRef.current = this._extensionRunner;\n\t\t}\n\t\tthis._bindExtensionCore(this._extensionRunner);\n\t\tthis._applyExtensionBindings(this._extensionRunner);\n\n\t\tconst defaultActiveToolNames = this._baseToolsOverride\n\t\t\t? Object.keys(this._baseToolsOverride)\n\t\t\t: [\"read\", \"bash\", \"edit\", \"write\"];\n\t\tconst baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;\n\t\tthis._refreshToolRegistry({\n\t\t\tactiveToolNames: baseActiveToolNames,\n\t\t\tincludeAllExtensionTools: options.includeAllExtensionTools,\n\t\t});\n\t}\n\n\tasync reload(): Promise<void> {\n\t\tconst previousFlagValues = this._extensionRunner.getFlagValues();\n\t\tawait emitSessionShutdownEvent(this._extensionRunner, { type: \"session_shutdown\", reason: \"reload\" });\n\t\tawait this.settingsManager.reload();\n\t\tresetApiProviders();\n\t\tawait this._resourceLoader.reload();\n\t\tthis._buildRuntime({\n\t\t\tactiveToolNames: this.getActiveToolNames(),\n\t\t\tflagValues: previousFlagValues,\n\t\t\tincludeAllExtensionTools: true,\n\t\t});\n\n\t\tconst hasBindings =\n\t\t\tthis._extensionUIContext ||\n\t\t\tthis._extensionCommandContextActions ||\n\t\t\tthis._extensionShutdownHandler ||\n\t\t\tthis._extensionErrorListener;\n\t\tif (hasBindings) {\n\t\t\tawait this._extensionRunner.emit({ type: \"session_start\", reason: \"reload\" });\n\t\t\tawait this.extendResourcesFromExtensions(\"reload\");\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Auto-Retry\n\t// =========================================================================\n\n\t/**\n\t * Check if an error is retryable (overloaded, rate limit, server errors).\n\t * Context overflow errors are NOT retryable (handled by compaction instead).\n\t */\n\tprivate _isRetryableError(message: AssistantMessage): boolean {\n\t\tif (message.stopReason !== \"error\" || !message.errorMessage) return false;\n\n\t\t// Context overflow is handled by compaction, not retry\n\t\tconst contextWindow = this.model?.contextWindow ?? 0;\n\t\tif (isContextOverflow(message, contextWindow)) return false;\n\n\t\tconst err = message.errorMessage;\n\t\t// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, premature stream endings, HTTP/2 closed before response, terminated, retry delay exceeded\n\t\treturn /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test(\n\t\t\terr,\n\t\t);\n\t}\n\n\t/**\n\t * Prepare a retryable error for continuation with exponential backoff.\n\t * @returns true if the caller should continue the agent, false otherwise\n\t */\n\tprivate async _prepareRetry(message: AssistantMessage): Promise<boolean> {\n\t\tconst settings = this.settingsManager.getRetrySettings();\n\t\tif (!settings.enabled) {\n\t\t\treturn false;\n\t\t}\n\n\t\tthis._retryAttempt++;\n\n\t\tif (this._retryAttempt > settings.maxRetries) {\n\t\t\t// Preserve the completed attempt count so post-run handling can emit the final failure.\n\t\t\tthis._retryAttempt--;\n\t\t\treturn false;\n\t\t}\n\n\t\tconst delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);\n\n\t\tthis._emit({\n\t\t\ttype: \"auto_retry_start\",\n\t\t\tattempt: this._retryAttempt,\n\t\t\tmaxAttempts: settings.maxRetries,\n\t\t\tdelayMs,\n\t\t\terrorMessage: message.errorMessage || \"Unknown error\",\n\t\t});\n\n\t\t// Remove error message from agent state (keep in session for history)\n\t\tconst messages = this.agent.state.messages;\n\t\tif (messages.length > 0 && messages[messages.length - 1].role === \"assistant\") {\n\t\t\tthis.agent.state.messages = messages.slice(0, -1);\n\t\t}\n\n\t\t// Wait with exponential backoff (abortable)\n\t\tthis._retryAbortController = new AbortController();\n\t\ttry {\n\t\t\tawait sleep(delayMs, this._retryAbortController.signal);\n\t\t} catch {\n\t\t\t// Aborted during sleep - emit end event so UI can clean up\n\t\t\tconst attempt = this._retryAttempt;\n\t\t\tthis._retryAttempt = 0;\n\t\t\tthis._emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: false,\n\t\t\t\tattempt,\n\t\t\t\tfinalError: \"Retry cancelled\",\n\t\t\t});\n\t\t\treturn false;\n\t\t} finally {\n\t\t\tthis._retryAbortController = undefined;\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Cancel in-progress retry.\n\t */\n\tabortRetry(): void {\n\t\tthis._retryAbortController?.abort();\n\t}\n\n\t/** Whether auto-retry is currently in progress */\n\tget isRetrying(): boolean {\n\t\treturn this._retryAbortController !== undefined;\n\t}\n\n\t/** Whether auto-retry is enabled */\n\tget autoRetryEnabled(): boolean {\n\t\treturn this.settingsManager.getRetryEnabled();\n\t}\n\n\t/**\n\t * Toggle auto-retry setting.\n\t */\n\tsetAutoRetryEnabled(enabled: boolean): void {\n\t\tthis.settingsManager.setRetryEnabled(enabled);\n\t}\n\n\t// =========================================================================\n\t// Bash Execution\n\t// =========================================================================\n\n\t/**\n\t * Execute a bash command.\n\t * Adds result to agent context and session.\n\t * @param command The bash command to execute\n\t * @param onChunk Optional streaming callback for output\n\t * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)\n\t * @param options.operations Custom BashOperations for remote execution\n\t */\n\tasync executeBash(\n\t\tcommand: string,\n\t\tonChunk?: (chunk: string) => void,\n\t\toptions?: { excludeFromContext?: boolean; operations?: BashOperations },\n\t): Promise<BashResult> {\n\t\tthis._bashAbortController = new AbortController();\n\n\t\t// Apply command prefix if configured (e.g., \"shopt -s expand_aliases\" for alias support)\n\t\tconst prefix = this.settingsManager.getShellCommandPrefix();\n\t\tconst shellPath = this.settingsManager.getShellPath();\n\t\tconst resolvedCommand = prefix ? `${prefix}\\n${command}` : command;\n\n\t\ttry {\n\t\t\tconst result = await executeBashWithOperations(\n\t\t\t\tresolvedCommand,\n\t\t\t\tthis.sessionManager.getCwd(),\n\t\t\t\toptions?.operations ?? createLocalBashOperations({ shellPath }),\n\t\t\t\t{\n\t\t\t\t\tonChunk,\n\t\t\t\t\tsignal: this._bashAbortController.signal,\n\t\t\t\t},\n\t\t\t);\n\n\t\t\tthis.recordBashResult(command, result, options);\n\t\t\treturn result;\n\t\t} finally {\n\t\t\tthis._bashAbortController = undefined;\n\t\t}\n\t}\n\n\t/**\n\t * Record a bash execution result in session history.\n\t * Used by executeBash and by extensions that handle bash execution themselves.\n\t */\n\trecordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {\n\t\tconst bashMessage: BashExecutionMessage = {\n\t\t\trole: \"bashExecution\",\n\t\t\tcommand,\n\t\t\toutput: result.output,\n\t\t\texitCode: result.exitCode,\n\t\t\tcancelled: result.cancelled,\n\t\t\ttruncated: result.truncated,\n\t\t\tfullOutputPath: result.fullOutputPath,\n\t\t\ttimestamp: Date.now(),\n\t\t\texcludeFromContext: options?.excludeFromContext,\n\t\t};\n\n\t\t// If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering\n\t\tif (this.isStreaming) {\n\t\t\t// Queue for later - will be flushed on agent_end\n\t\t\tthis._pendingBashMessages.push(bashMessage);\n\t\t} else {\n\t\t\t// Add to agent state immediately\n\t\t\tthis.agent.state.messages.push(bashMessage);\n\n\t\t\t// Save to session\n\t\t\tthis.sessionManager.appendMessage(bashMessage);\n\t\t}\n\t}\n\n\t/**\n\t * Cancel running bash command.\n\t */\n\tabortBash(): void {\n\t\tthis._bashAbortController?.abort();\n\t}\n\n\t/** Whether a bash command is currently running */\n\tget isBashRunning(): boolean {\n\t\treturn this._bashAbortController !== undefined;\n\t}\n\n\t/** Whether there are pending bash messages waiting to be flushed */\n\tget hasPendingBashMessages(): boolean {\n\t\treturn this._pendingBashMessages.length > 0;\n\t}\n\n\t/**\n\t * Flush pending bash messages to agent state and session.\n\t * Called after agent turn completes to maintain proper message ordering.\n\t */\n\tprivate _flushPendingBashMessages(): void {\n\t\tif (this._pendingBashMessages.length === 0) return;\n\n\t\tfor (const bashMessage of this._pendingBashMessages) {\n\t\t\t// Add to agent state\n\t\t\tthis.agent.state.messages.push(bashMessage);\n\n\t\t\t// Save to session\n\t\t\tthis.sessionManager.appendMessage(bashMessage);\n\t\t}\n\n\t\tthis._pendingBashMessages = [];\n\t}\n\n\t// =========================================================================\n\t// Session Management\n\t// =========================================================================\n\n\t/**\n\t * Set a display name for the current session.\n\t */\n\tsetSessionName(name: string): void {\n\t\tthis.sessionManager.appendSessionInfo(name);\n\t\tthis._emit({ type: \"session_info_changed\", name: this.sessionManager.getSessionName() });\n\t}\n\n\t// =========================================================================\n\t// Tree Navigation\n\t// =========================================================================\n\n\t/**\n\t * Navigate to a different node in the session tree.\n\t * Unlike fork() which creates a new session file, this stays in the same file.\n\t *\n\t * @param targetId The entry ID to navigate to\n\t * @param options.summarize Whether user wants to summarize abandoned branch\n\t * @param options.customInstructions Custom instructions for summarizer\n\t * @param options.replaceInstructions If true, customInstructions replaces the default prompt\n\t * @param options.label Label to attach to the branch summary entry\n\t * @returns Result with editorText (if user message) and cancelled status\n\t */\n\tasync navigateTree(\n\t\ttargetId: string,\n\t\toptions: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string } = {},\n\t): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> {\n\t\tconst oldLeafId = this.sessionManager.getLeafId();\n\n\t\t// No-op if already at target\n\t\tif (targetId === oldLeafId) {\n\t\t\treturn { cancelled: false };\n\t\t}\n\n\t\t// Model required for summarization\n\t\tif (options.summarize && !this.model) {\n\t\t\tthrow new Error(\"No model available for summarization\");\n\t\t}\n\n\t\tconst targetEntry = this.sessionManager.getEntry(targetId);\n\t\tif (!targetEntry) {\n\t\t\tthrow new Error(`Entry ${targetId} not found`);\n\t\t}\n\n\t\t// Collect entries to summarize (from old leaf to common ancestor)\n\t\tconst { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(\n\t\t\tthis.sessionManager,\n\t\t\toldLeafId,\n\t\t\ttargetId,\n\t\t);\n\n\t\t// Prepare event data - mutable so extensions can override\n\t\tlet customInstructions = options.customInstructions;\n\t\tlet replaceInstructions = options.replaceInstructions;\n\t\tlet label = options.label;\n\n\t\tconst preparation: TreePreparation = {\n\t\t\ttargetId,\n\t\t\toldLeafId,\n\t\t\tcommonAncestorId,\n\t\t\tentriesToSummarize,\n\t\t\tuserWantsSummary: options.summarize ?? false,\n\t\t\tcustomInstructions,\n\t\t\treplaceInstructions,\n\t\t\tlabel,\n\t\t};\n\n\t\t// Set up abort controller for summarization\n\t\tthis._branchSummaryAbortController = new AbortController();\n\n\t\ttry {\n\t\t\tlet extensionSummary: { summary: string; details?: unknown } | undefined;\n\t\t\tlet fromExtension = false;\n\n\t\t\t// Emit session_before_tree event\n\t\t\tif (this._extensionRunner.hasHandlers(\"session_before_tree\")) {\n\t\t\t\tconst result = (await this._extensionRunner.emit({\n\t\t\t\t\ttype: \"session_before_tree\",\n\t\t\t\t\tpreparation,\n\t\t\t\t\tsignal: this._branchSummaryAbortController.signal,\n\t\t\t\t})) as SessionBeforeTreeResult | undefined;\n\n\t\t\t\tif (result?.cancel) {\n\t\t\t\t\treturn { cancelled: true };\n\t\t\t\t}\n\n\t\t\t\tif (result?.summary && options.summarize) {\n\t\t\t\t\textensionSummary = result.summary;\n\t\t\t\t\tfromExtension = true;\n\t\t\t\t}\n\n\t\t\t\t// Allow extensions to override instructions and label\n\t\t\t\tif (result?.customInstructions !== undefined) {\n\t\t\t\t\tcustomInstructions = result.customInstructions;\n\t\t\t\t}\n\t\t\t\tif (result?.replaceInstructions !== undefined) {\n\t\t\t\t\treplaceInstructions = result.replaceInstructions;\n\t\t\t\t}\n\t\t\t\tif (result?.label !== undefined) {\n\t\t\t\t\tlabel = result.label;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Run default summarizer if needed\n\t\t\tlet summaryText: string | undefined;\n\t\t\tlet summaryDetails: unknown;\n\t\t\tif (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {\n\t\t\t\tconst model = this.model!;\n\t\t\t\tconst { apiKey, headers } = await this._getRequiredRequestAuth(model);\n\t\t\t\tconst branchSummarySettings = this.settingsManager.getBranchSummarySettings();\n\t\t\t\tconst result = await generateBranchSummary(entriesToSummarize, {\n\t\t\t\t\tmodel,\n\t\t\t\t\tapiKey,\n\t\t\t\t\theaders,\n\t\t\t\t\tsignal: this._branchSummaryAbortController.signal,\n\t\t\t\t\tcustomInstructions,\n\t\t\t\t\treplaceInstructions,\n\t\t\t\t\treserveTokens: branchSummarySettings.reserveTokens,\n\t\t\t\t});\n\t\t\t\tif (result.aborted) {\n\t\t\t\t\treturn { cancelled: true, aborted: true };\n\t\t\t\t}\n\t\t\t\tif (result.error) {\n\t\t\t\t\tthrow new Error(result.error);\n\t\t\t\t}\n\t\t\t\tsummaryText = result.summary;\n\t\t\t\tsummaryDetails = {\n\t\t\t\t\treadFiles: result.readFiles || [],\n\t\t\t\t\tmodifiedFiles: result.modifiedFiles || [],\n\t\t\t\t};\n\t\t\t} else if (extensionSummary) {\n\t\t\t\tsummaryText = extensionSummary.summary;\n\t\t\t\tsummaryDetails = extensionSummary.details;\n\t\t\t}\n\n\t\t\t// Determine the new leaf position based on target type\n\t\t\tlet newLeafId: string | null;\n\t\t\tlet editorText: string | undefined;\n\n\t\t\tif (targetEntry.type === \"message\" && targetEntry.message.role === \"user\") {\n\t\t\t\t// User message: leaf = parent (null if root), text goes to editor\n\t\t\t\tnewLeafId = targetEntry.parentId;\n\t\t\t\teditorText = this._extractUserMessageText(targetEntry.message.content);\n\t\t\t} else if (targetEntry.type === \"custom_message\") {\n\t\t\t\t// Custom message: leaf = parent (null if root), text goes to editor\n\t\t\t\tnewLeafId = targetEntry.parentId;\n\t\t\t\teditorText =\n\t\t\t\t\ttypeof targetEntry.content === \"string\"\n\t\t\t\t\t\t? targetEntry.content\n\t\t\t\t\t\t: targetEntry.content\n\t\t\t\t\t\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t\t\t\t\t\t.map((c) => c.text)\n\t\t\t\t\t\t\t\t.join(\"\");\n\t\t\t} else {\n\t\t\t\t// Non-user message: leaf = selected node\n\t\t\t\tnewLeafId = targetId;\n\t\t\t}\n\n\t\t\t// Switch leaf (with or without summary)\n\t\t\t// Summary is attached at the navigation target position (newLeafId), not the old branch\n\t\t\tlet summaryEntry: BranchSummaryEntry | undefined;\n\t\t\tif (summaryText) {\n\t\t\t\t// Create summary at target position (can be null for root)\n\t\t\t\tconst summaryId = this.sessionManager.branchWithSummary(\n\t\t\t\t\tnewLeafId,\n\t\t\t\t\tsummaryText,\n\t\t\t\t\tsummaryDetails,\n\t\t\t\t\tfromExtension,\n\t\t\t\t);\n\t\t\t\tsummaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;\n\n\t\t\t\t// Attach label to the summary entry\n\t\t\t\tif (label) {\n\t\t\t\t\tthis.sessionManager.appendLabelChange(summaryId, label);\n\t\t\t\t}\n\t\t\t} else if (newLeafId === null) {\n\t\t\t\t// No summary, navigating to root - reset leaf\n\t\t\t\tthis.sessionManager.resetLeaf();\n\t\t\t} else {\n\t\t\t\t// No summary, navigating to non-root\n\t\t\t\tthis.sessionManager.branch(newLeafId);\n\t\t\t}\n\n\t\t\t// Attach label to target entry when not summarizing (no summary entry to label)\n\t\t\tif (label && !summaryText) {\n\t\t\t\tthis.sessionManager.appendLabelChange(targetId, label);\n\t\t\t}\n\n\t\t\t// Update agent state\n\t\t\tconst sessionContext = this.sessionManager.buildSessionContext();\n\t\t\tthis.agent.state.messages = sessionContext.messages;\n\n\t\t\t// Emit session_tree event\n\t\t\tawait this._extensionRunner.emit({\n\t\t\t\ttype: \"session_tree\",\n\t\t\t\tnewLeafId: this.sessionManager.getLeafId(),\n\t\t\t\toldLeafId,\n\t\t\t\tsummaryEntry,\n\t\t\t\tfromExtension: summaryText ? fromExtension : undefined,\n\t\t\t});\n\n\t\t\t// Emit to custom tools\n\n\t\t\treturn { editorText, cancelled: false, summaryEntry };\n\t\t} finally {\n\t\t\tthis._branchSummaryAbortController = undefined;\n\t\t}\n\t}\n\n\t/**\n\t * Get all user messages from session for fork selector.\n\t */\n\tgetUserMessagesForForking(): Array<{ entryId: string; text: string }> {\n\t\tconst entries = this.sessionManager.getEntries();\n\t\tconst result: Array<{ entryId: string; text: string }> = [];\n\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tif (entry.message.role !== \"user\") continue;\n\n\t\t\tconst text = this._extractUserMessageText(entry.message.content);\n\t\t\tif (text) {\n\t\t\t\tresult.push({ entryId: entry.id, text });\n\t\t\t}\n\t\t}\n\n\t\treturn result;\n\t}\n\n\tprivate _extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {\n\t\tif (typeof content === \"string\") return content;\n\t\tif (Array.isArray(content)) {\n\t\t\treturn content\n\t\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t\t.map((c) => c.text)\n\t\t\t\t.join(\"\");\n\t\t}\n\t\treturn \"\";\n\t}\n\n\t/**\n\t * Get session statistics.\n\t */\n\tgetSessionStats(): SessionStats {\n\t\tconst state = this.state;\n\t\tconst userMessages = state.messages.filter((m) => m.role === \"user\").length;\n\t\tconst assistantMessages = state.messages.filter((m) => m.role === \"assistant\").length;\n\t\tconst toolResults = state.messages.filter((m) => m.role === \"toolResult\").length;\n\n\t\tlet toolCalls = 0;\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const message of state.messages) {\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\ttoolCalls += assistantMsg.content.filter((c) => c.type === \"toolCall\").length;\n\t\t\t\ttotalInput += assistantMsg.usage.input;\n\t\t\t\ttotalOutput += assistantMsg.usage.output;\n\t\t\t\ttotalCacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\ttotalCost += assistantMsg.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\treturn {\n\t\t\tsessionFile: this.sessionFile,\n\t\t\tsessionId: this.sessionId,\n\t\t\tuserMessages,\n\t\t\tassistantMessages,\n\t\t\ttoolCalls,\n\t\t\ttoolResults,\n\t\t\ttotalMessages: state.messages.length,\n\t\t\ttokens: {\n\t\t\t\tinput: totalInput,\n\t\t\t\toutput: totalOutput,\n\t\t\t\tcacheRead: totalCacheRead,\n\t\t\t\tcacheWrite: totalCacheWrite,\n\t\t\t\ttotal: totalInput + totalOutput + totalCacheRead + totalCacheWrite,\n\t\t\t},\n\t\t\tcost: totalCost,\n\t\t\tcontextUsage: this.getContextUsage(),\n\t\t};\n\t}\n\n\tgetContextUsage(): ContextUsage | undefined {\n\t\tconst model = this.model;\n\t\tif (!model) return undefined;\n\n\t\tconst contextWindow = model.contextWindow ?? 0;\n\t\tif (contextWindow <= 0) return undefined;\n\n\t\t// After compaction, the last assistant usage reflects pre-compaction context size.\n\t\t// We can only trust usage from an assistant that responded after the latest compaction.\n\t\t// If no such assistant exists, context token count is unknown until the next LLM response.\n\t\tconst branchEntries = this.sessionManager.getBranch();\n\t\tconst latestCompaction = getLatestCompactionEntry(branchEntries);\n\n\t\tif (latestCompaction) {\n\t\t\t// Check if there's a valid assistant usage after the compaction boundary\n\t\t\tconst compactionIndex = branchEntries.lastIndexOf(latestCompaction);\n\t\t\tlet hasPostCompactionUsage = false;\n\t\t\tfor (let i = branchEntries.length - 1; i > compactionIndex; i--) {\n\t\t\t\tconst entry = branchEntries[i];\n\t\t\t\tif (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\t\tconst assistant = entry.message;\n\t\t\t\t\tif (assistant.stopReason !== \"aborted\" && assistant.stopReason !== \"error\") {\n\t\t\t\t\t\tconst contextTokens = calculateContextTokens(assistant.usage);\n\t\t\t\t\t\tif (contextTokens > 0) {\n\t\t\t\t\t\t\thasPostCompactionUsage = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!hasPostCompactionUsage) {\n\t\t\t\treturn { tokens: null, contextWindow, percent: null };\n\t\t\t}\n\t\t}\n\n\t\tconst estimate = estimateContextTokens(this.messages);\n\t\tconst percent = (estimate.tokens / contextWindow) * 100;\n\n\t\treturn {\n\t\t\ttokens: estimate.tokens,\n\t\t\tcontextWindow,\n\t\t\tpercent,\n\t\t};\n\t}\n\n\t/**\n\t * Export session to HTML.\n\t * @param outputPath Optional output path (defaults to session directory)\n\t * @returns Path to exported file\n\t */\n\tasync exportToHtml(outputPath?: string): Promise<string> {\n\t\tconst themeName = this.settingsManager.getTheme();\n\n\t\t// Create tool renderer if we have an extension runner (for custom tool HTML rendering)\n\t\tconst toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({\n\t\t\tgetToolDefinition: (name) => this.getToolDefinition(name),\n\t\t\ttheme,\n\t\t\tcwd: this.sessionManager.getCwd(),\n\t\t});\n\n\t\treturn await exportSessionToHtml(this.sessionManager, this.state, {\n\t\t\toutputPath,\n\t\t\tthemeName,\n\t\t\ttoolRenderer,\n\t\t});\n\t}\n\n\t/**\n\t * Export the current session branch to a JSONL file.\n\t * Writes the session header followed by all entries on the current branch path.\n\n[98 more lines in file. Use offset=3001 to continue.]"}],"isError":false,"timestamp":1781615140039}}
|
||
{"type":"message","id":"a8720253","parentId":"fafffde3","timestamp":"2026-06-16T13:05:51.061Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 resource loader 是如何加载上下文的,以及 prompt templates 是如何工作的。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_25a2286b8ab14b588eba52d2","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw/packages -type f -name \"*.ts\" | grep -v node_modules | grep -E \"(resource|prompt)\" | grep -v test | grep -v dist | grep -v examples | head -10"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":11786,"output":99,"cacheRead":87040,"cacheWrite":0,"totalTokens":98925,"cost":{"input":0.0047144000000000005,"output":0.000198,"cacheRead":0.0069632,"cacheWrite":0,"total":0.0118756}},"stopReason":"toolUse","timestamp":1781615140044,"responseId":"813a1338e0794019bc1af62de8586b34"}}
|
||
{"type":"message","id":"ddae5d41","parentId":"a8720253","timestamp":"2026-06-16T13:05:51.084Z","message":{"role":"toolResult","toolCallId":"call_25a2286b8ab14b588eba52d2","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/system-prompt.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/prompt-templates.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/resource-loader.ts\n/shumengya/project/agent/sproutclaw/packages/agent/src/harness/system-prompt.ts\n/shumengya/project/agent/sproutclaw/packages/agent/src/harness/prompt-templates.ts\n/shumengya/project/agent/sproutclaw/packages/ai/src/providers/openai-prompt-cache.ts\n/shumengya/project/agent/sproutclaw/packages/ai/src/session-resources.ts\n"}],"isError":false,"timestamp":1781615151084}}
|
||
{"type":"message","id":"279fa532","parentId":"ddae5d41","timestamp":"2026-06-16T13:05:53.822Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 resource-loader.ts,了解上下文是如何加载的。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_3c3416eaece8403cb234ef9c","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/resource-loader.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":280,"output":55,"cacheRead":98816,"cacheWrite":0,"totalTokens":99151,"cost":{"input":0.00011200000000000001,"output":0.00010999999999999999,"cacheRead":0.00790528,"cacheWrite":0,"total":0.00812728}},"stopReason":"toolUse","timestamp":1781615151085,"responseId":"df96e82bba89409ab8dca0638c69bdaf"}}
|
||
{"type":"message","id":"86a33b97","parentId":"279fa532","timestamp":"2026-06-16T13:05:53.824Z","message":{"role":"toolResult","toolCallId":"call_3c3416eaece8403cb234ef9c","toolName":"read","content":[{"type":"text","text":"import { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join, resolve, sep } from \"node:path\";\nimport chalk from \"chalk\";\nimport { CONFIG_DIR_NAME } from \"../config.ts\";\nimport { loadThemeFromPath, type Theme } from \"../modes/interactive/theme/theme.ts\";\nimport type { ResourceDiagnostic } from \"./diagnostics.ts\";\n\nexport type { ResourceCollision, ResourceDiagnostic } from \"./diagnostics.ts\";\n\nimport { canonicalizePath, isLocalPath, resolvePath } from \"../utils/paths.ts\";\nimport { createEventBus, type EventBus } from \"./event-bus.ts\";\nimport { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from \"./extensions/loader.ts\";\nimport type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from \"./extensions/types.ts\";\nimport { DefaultPackageManager, type PathMetadata } from \"./package-manager.ts\";\nimport type { PromptTemplate } from \"./prompt-templates.ts\";\nimport { loadPromptTemplates } from \"./prompt-templates.ts\";\nimport { SettingsManager } from \"./settings-manager.ts\";\nimport type { Skill } from \"./skills.ts\";\nimport { loadSkills } from \"./skills.ts\";\nimport { createSourceInfo, type SourceInfo } from \"./source-info.ts\";\n\nexport interface ResourceExtensionPaths {\n\tskillPaths?: Array<{ path: string; metadata: PathMetadata }>;\n\tpromptPaths?: Array<{ path: string; metadata: PathMetadata }>;\n\tthemePaths?: Array<{ path: string; metadata: PathMetadata }>;\n}\n\nexport interface ResourceLoader {\n\tgetExtensions(): LoadExtensionsResult;\n\tgetSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };\n\tgetPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };\n\tgetThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] };\n\tgetAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> };\n\tgetSystemPrompt(): string | undefined;\n\tgetAppendSystemPrompt(): string[];\n\textendResources(paths: ResourceExtensionPaths): void;\n\treload(): Promise<void>;\n}\n\nfunction resolvePromptInput(input: string | undefined, description: string): string | undefined {\n\tif (!input) {\n\t\treturn undefined;\n\t}\n\n\tif (existsSync(input)) {\n\t\ttry {\n\t\t\treturn readFileSync(input, \"utf-8\");\n\t\t} catch (error) {\n\t\t\tconsole.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));\n\t\t\treturn input;\n\t\t}\n\t}\n\n\treturn input;\n}\n\nfunction loadContextFileFromDir(dir: string): { path: string; content: string } | null {\n\tconst candidates = [\"AGENTS.md\", \"AGENTS.MD\", \"CLAUDE.md\", \"CLAUDE.MD\"];\n\tfor (const filename of candidates) {\n\t\tconst filePath = join(dir, filename);\n\t\tif (existsSync(filePath)) {\n\t\t\ttry {\n\t\t\t\treturn {\n\t\t\t\t\tpath: filePath,\n\t\t\t\t\tcontent: readFileSync(filePath, \"utf-8\"),\n\t\t\t\t};\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));\n\t\t\t}\n\t\t}\n\t}\n\treturn null;\n}\n\nexport function loadProjectContextFiles(options: {\n\tcwd: string;\n\tagentDir: string;\n}): Array<{ path: string; content: string }> {\n\tconst resolvedCwd = resolvePath(options.cwd);\n\tconst resolvedAgentDir = resolvePath(options.agentDir);\n\n\tconst contextFiles: Array<{ path: string; content: string }> = [];\n\tconst seenPaths = new Set<string>();\n\n\tconst globalContext = loadContextFileFromDir(resolvedAgentDir);\n\tif (globalContext) {\n\t\tcontextFiles.push(globalContext);\n\t\tseenPaths.add(globalContext.path);\n\t}\n\n\tconst ancestorContextFiles: Array<{ path: string; content: string }> = [];\n\n\tlet currentDir = resolvedCwd;\n\tconst root = resolve(\"/\");\n\n\twhile (true) {\n\t\tconst contextFile = loadContextFileFromDir(currentDir);\n\t\tif (contextFile && !seenPaths.has(contextFile.path)) {\n\t\t\tancestorContextFiles.unshift(contextFile);\n\t\t\tseenPaths.add(contextFile.path);\n\t\t}\n\n\t\tif (currentDir === root) break;\n\n\t\tconst parentDir = resolve(currentDir, \"..\");\n\t\tif (parentDir === currentDir) break;\n\t\tcurrentDir = parentDir;\n\t}\n\n\tcontextFiles.push(...ancestorContextFiles);\n\n\treturn contextFiles;\n}\n\nexport interface DefaultResourceLoaderOptions {\n\tcwd: string;\n\tagentDir: string;\n\tsettingsManager?: SettingsManager;\n\teventBus?: EventBus;\n\tadditionalExtensionPaths?: string[];\n\tadditionalSkillPaths?: string[];\n\tadditionalPromptTemplatePaths?: string[];\n\tadditionalThemePaths?: string[];\n\textensionFactories?: ExtensionFactory[];\n\tnoExtensions?: boolean;\n\tnoSkills?: boolean;\n\tnoPromptTemplates?: boolean;\n\tnoThemes?: boolean;\n\tnoContextFiles?: boolean;\n\tsystemPrompt?: string;\n\tappendSystemPrompt?: string[];\n\textensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;\n\tskillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {\n\t\tskills: Skill[];\n\t\tdiagnostics: ResourceDiagnostic[];\n\t};\n\tpromptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => {\n\t\tprompts: PromptTemplate[];\n\t\tdiagnostics: ResourceDiagnostic[];\n\t};\n\tthemesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => {\n\t\tthemes: Theme[];\n\t\tdiagnostics: ResourceDiagnostic[];\n\t};\n\tagentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {\n\t\tagentsFiles: Array<{ path: string; content: string }>;\n\t};\n\tsystemPromptOverride?: (base: string | undefined) => string | undefined;\n\tappendSystemPromptOverride?: (base: string[]) => string[];\n}\n\nexport class DefaultResourceLoader implements ResourceLoader {\n\tprivate cwd: string;\n\tprivate agentDir: string;\n\tprivate settingsManager: SettingsManager;\n\tprivate eventBus: EventBus;\n\tprivate packageManager: DefaultPackageManager;\n\tprivate additionalExtensionPaths: string[];\n\tprivate additionalSkillPaths: string[];\n\tprivate additionalPromptTemplatePaths: string[];\n\tprivate additionalThemePaths: string[];\n\tprivate extensionFactories: ExtensionFactory[];\n\tprivate noExtensions: boolean;\n\tprivate noSkills: boolean;\n\tprivate noPromptTemplates: boolean;\n\tprivate noThemes: boolean;\n\tprivate noContextFiles: boolean;\n\tprivate systemPromptSource?: string;\n\tprivate appendSystemPromptSource?: string[];\n\tprivate extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;\n\tprivate skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {\n\t\tskills: Skill[];\n\t\tdiagnostics: ResourceDiagnostic[];\n\t};\n\tprivate promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => {\n\t\tprompts: PromptTemplate[];\n\t\tdiagnostics: ResourceDiagnostic[];\n\t};\n\tprivate themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => {\n\t\tthemes: Theme[];\n\t\tdiagnostics: ResourceDiagnostic[];\n\t};\n\tprivate agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {\n\t\tagentsFiles: Array<{ path: string; content: string }>;\n\t};\n\tprivate systemPromptOverride?: (base: string | undefined) => string | undefined;\n\tprivate appendSystemPromptOverride?: (base: string[]) => string[];\n\n\tprivate extensionsResult: LoadExtensionsResult;\n\tprivate skills: Skill[];\n\tprivate skillDiagnostics: ResourceDiagnostic[];\n\tprivate prompts: PromptTemplate[];\n\tprivate promptDiagnostics: ResourceDiagnostic[];\n\tprivate themes: Theme[];\n\tprivate themeDiagnostics: ResourceDiagnostic[];\n\tprivate agentsFiles: Array<{ path: string; content: string }>;\n\tprivate systemPrompt?: string;\n\tprivate appendSystemPrompt: string[];\n\tprivate lastSkillPaths: string[];\n\tprivate extensionSkillSourceInfos: Map<string, SourceInfo>;\n\tprivate extensionPromptSourceInfos: Map<string, SourceInfo>;\n\tprivate extensionThemeSourceInfos: Map<string, SourceInfo>;\n\tprivate lastPromptPaths: string[];\n\tprivate lastThemePaths: string[];\n\n\tconstructor(options: DefaultResourceLoaderOptions) {\n\t\tthis.cwd = resolvePath(options.cwd);\n\t\tthis.agentDir = resolvePath(options.agentDir);\n\t\tthis.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);\n\t\tthis.eventBus = options.eventBus ?? createEventBus();\n\t\tthis.packageManager = new DefaultPackageManager({\n\t\t\tcwd: this.cwd,\n\t\t\tagentDir: this.agentDir,\n\t\t\tsettingsManager: this.settingsManager,\n\t\t});\n\t\tthis.additionalExtensionPaths = options.additionalExtensionPaths ?? [];\n\t\tthis.additionalSkillPaths = options.additionalSkillPaths ?? [];\n\t\tthis.additionalPromptTemplatePaths = options.additionalPromptTemplatePaths ?? [];\n\t\tthis.additionalThemePaths = options.additionalThemePaths ?? [];\n\t\tthis.extensionFactories = options.extensionFactories ?? [];\n\t\tthis.noExtensions = options.noExtensions ?? false;\n\t\tthis.noSkills = options.noSkills ?? false;\n\t\tthis.noPromptTemplates = options.noPromptTemplates ?? false;\n\t\tthis.noThemes = options.noThemes ?? false;\n\t\tthis.noContextFiles = options.noContextFiles ?? false;\n\t\tthis.systemPromptSource = options.systemPrompt;\n\t\tthis.appendSystemPromptSource = options.appendSystemPrompt;\n\t\tthis.extensionsOverride = options.extensionsOverride;\n\t\tthis.skillsOverride = options.skillsOverride;\n\t\tthis.promptsOverride = options.promptsOverride;\n\t\tthis.themesOverride = options.themesOverride;\n\t\tthis.agentsFilesOverride = options.agentsFilesOverride;\n\t\tthis.systemPromptOverride = options.systemPromptOverride;\n\t\tthis.appendSystemPromptOverride = options.appendSystemPromptOverride;\n\n\t\tthis.extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };\n\t\tthis.skills = [];\n\t\tthis.skillDiagnostics = [];\n\t\tthis.prompts = [];\n\t\tthis.promptDiagnostics = [];\n\t\tthis.themes = [];\n\t\tthis.themeDiagnostics = [];\n\t\tthis.agentsFiles = [];\n\t\tthis.appendSystemPrompt = [];\n\t\tthis.lastSkillPaths = [];\n\t\tthis.extensionSkillSourceInfos = new Map();\n\t\tthis.extensionPromptSourceInfos = new Map();\n\t\tthis.extensionThemeSourceInfos = new Map();\n\t\tthis.lastPromptPaths = [];\n\t\tthis.lastThemePaths = [];\n\t}\n\n\tgetExtensions(): LoadExtensionsResult {\n\t\treturn this.extensionsResult;\n\t}\n\n\tgetSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } {\n\t\treturn { skills: this.skills, diagnostics: this.skillDiagnostics };\n\t}\n\n\tgetPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } {\n\t\treturn { prompts: this.prompts, diagnostics: this.promptDiagnostics };\n\t}\n\n\tgetThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {\n\t\treturn { themes: this.themes, diagnostics: this.themeDiagnostics };\n\t}\n\n\tgetAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> } {\n\t\treturn { agentsFiles: this.agentsFiles };\n\t}\n\n\tgetSystemPrompt(): string | undefined {\n\t\treturn this.systemPrompt;\n\t}\n\n\tgetAppendSystemPrompt(): string[] {\n\t\treturn this.appendSystemPrompt;\n\t}\n\n\textendResources(paths: ResourceExtensionPaths): void {\n\t\tconst skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []);\n\t\tconst promptPaths = this.normalizeExtensionPaths(paths.promptPaths ?? []);\n\t\tconst themePaths = this.normalizeExtensionPaths(paths.themePaths ?? []);\n\n\t\tfor (const entry of skillPaths) {\n\t\t\tthis.extensionSkillSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata));\n\t\t}\n\t\tfor (const entry of promptPaths) {\n\t\t\tthis.extensionPromptSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata));\n\t\t}\n\t\tfor (const entry of themePaths) {\n\t\t\tthis.extensionThemeSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata));\n\t\t}\n\n\t\tif (skillPaths.length > 0) {\n\t\t\tthis.lastSkillPaths = this.mergePaths(\n\t\t\t\tthis.lastSkillPaths,\n\t\t\t\tskillPaths.map((entry) => entry.path),\n\t\t\t);\n\t\t\tthis.updateSkillsFromPaths(this.lastSkillPaths);\n\t\t}\n\n\t\tif (promptPaths.length > 0) {\n\t\t\tthis.lastPromptPaths = this.mergePaths(\n\t\t\t\tthis.lastPromptPaths,\n\t\t\t\tpromptPaths.map((entry) => entry.path),\n\t\t\t);\n\t\t\tthis.updatePromptsFromPaths(this.lastPromptPaths);\n\t\t}\n\n\t\tif (themePaths.length > 0) {\n\t\t\tthis.lastThemePaths = this.mergePaths(\n\t\t\t\tthis.lastThemePaths,\n\t\t\t\tthemePaths.map((entry) => entry.path),\n\t\t\t);\n\t\t\tthis.updateThemesFromPaths(this.lastThemePaths);\n\t\t}\n\t}\n\n\tasync reload(): Promise<void> {\n\t\tawait this.settingsManager.reload();\n\t\tconst resolvedPaths = await this.packageManager.resolve();\n\t\tconst cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {\n\t\t\ttemporary: true,\n\t\t});\n\t\tconst metadataByPath = new Map<string, PathMetadata>();\n\n\t\tthis.extensionSkillSourceInfos = new Map();\n\t\tthis.extensionPromptSourceInfos = new Map();\n\t\tthis.extensionThemeSourceInfos = new Map();\n\n\t\t// Helper to extract enabled paths and store metadata\n\t\tconst getEnabledResources = (\n\t\t\tresources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,\n\t\t): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => {\n\t\t\tfor (const r of resources) {\n\t\t\t\tif (!metadataByPath.has(r.path)) {\n\t\t\t\t\tmetadataByPath.set(r.path, r.metadata);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn resources.filter((r) => r.enabled);\n\t\t};\n\n\t\tconst getEnabledPaths = (\n\t\t\tresources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,\n\t\t): string[] => getEnabledResources(resources).map((r) => r.path);\n\t\tconst enabledExtensions = getEnabledPaths(resolvedPaths.extensions);\n\t\tconst enabledSkillResources = getEnabledResources(resolvedPaths.skills);\n\t\tconst enabledPrompts = getEnabledPaths(resolvedPaths.prompts);\n\t\tconst enabledThemes = getEnabledPaths(resolvedPaths.themes);\n\n\t\tconst mapSkillPath = (resource: { path: string; metadata: PathMetadata }): string => {\n\t\t\tif (resource.metadata.source !== \"auto\" && resource.metadata.origin !== \"package\") {\n\t\t\t\treturn resource.path;\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst stats = statSync(resource.path);\n\t\t\t\tif (!stats.isDirectory()) {\n\t\t\t\t\treturn resource.path;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\treturn resource.path;\n\t\t\t}\n\t\t\tconst skillFile = join(resource.path, \"SKILL.md\");\n\t\t\tif (existsSync(skillFile)) {\n\t\t\t\tif (!metadataByPath.has(skillFile)) {\n\t\t\t\t\tmetadataByPath.set(skillFile, resource.metadata);\n\t\t\t\t}\n\t\t\t\treturn skillFile;\n\t\t\t}\n\t\t\treturn resource.path;\n\t\t};\n\n\t\tconst enabledSkills = enabledSkillResources.map(mapSkillPath);\n\n\t\t// Add CLI paths metadata\n\t\tfor (const r of cliExtensionPaths.extensions) {\n\t\t\tif (!metadataByPath.has(r.path)) {\n\t\t\t\tmetadataByPath.set(r.path, { source: \"cli\", scope: \"temporary\", origin: \"top-level\" });\n\t\t\t}\n\t\t}\n\t\tfor (const r of cliExtensionPaths.skills) {\n\t\t\tif (!metadataByPath.has(r.path)) {\n\t\t\t\tmetadataByPath.set(r.path, { source: \"cli\", scope: \"temporary\", origin: \"top-level\" });\n\t\t\t}\n\t\t}\n\n\t\tconst cliEnabledExtensions = getEnabledPaths(cliExtensionPaths.extensions);\n\t\tconst cliEnabledSkills = getEnabledPaths(cliExtensionPaths.skills);\n\t\tconst cliEnabledPrompts = getEnabledPaths(cliExtensionPaths.prompts);\n\t\tconst cliEnabledThemes = getEnabledPaths(cliExtensionPaths.themes);\n\n\t\tconst extensionPaths = this.noExtensions\n\t\t\t? cliEnabledExtensions\n\t\t\t: this.mergePaths(cliEnabledExtensions, enabledExtensions);\n\n\t\tconst extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);\n\t\tconst inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);\n\t\textensionsResult.extensions.push(...inlineExtensions.extensions);\n\t\textensionsResult.errors.push(...inlineExtensions.errors);\n\n\t\t// Detect extension conflicts (tools, commands, flags with same names from different extensions)\n\t\t// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.\n\t\tconst conflicts = this.detectExtensionConflicts(extensionsResult.extensions);\n\t\tfor (const conflict of conflicts) {\n\t\t\textensionsResult.errors.push({ path: conflict.path, error: conflict.message });\n\t\t}\n\n\t\tfor (const p of this.additionalExtensionPaths) {\n\t\t\tif (isLocalPath(p)) {\n\t\t\t\tconst resolved = this.resolveResourcePath(p);\n\t\t\t\tif (!existsSync(resolved)) {\n\t\t\t\t\textensionsResult.errors.push({ path: resolved, error: `Extension path does not exist: ${resolved}` });\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tthis.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;\n\t\tthis.applyExtensionSourceInfo(this.extensionsResult.extensions, metadataByPath);\n\n\t\tconst skillPaths = this.noSkills\n\t\t\t? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths)\n\t\t\t: this.mergePaths([...cliEnabledSkills, ...enabledSkills], this.additionalSkillPaths);\n\n\t\tthis.lastSkillPaths = skillPaths;\n\t\tthis.updateSkillsFromPaths(skillPaths, metadataByPath);\n\t\tfor (const p of this.additionalSkillPaths) {\n\t\t\tif (isLocalPath(p)) {\n\t\t\t\tconst resolved = this.resolveResourcePath(p);\n\t\t\t\tif (!existsSync(resolved) && !this.skillDiagnostics.some((d) => d.path === resolved)) {\n\t\t\t\t\tthis.skillDiagnostics.push({ type: \"error\", message: \"Skill path does not exist\", path: resolved });\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst promptPaths = this.noPromptTemplates\n\t\t\t? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths)\n\t\t\t: this.mergePaths([...cliEnabledPrompts, ...enabledPrompts], this.additionalPromptTemplatePaths);\n\n\t\tthis.lastPromptPaths = promptPaths;\n\t\tthis.updatePromptsFromPaths(promptPaths, metadataByPath);\n\t\tfor (const p of this.additionalPromptTemplatePaths) {\n\t\t\tif (isLocalPath(p)) {\n\t\t\t\tconst resolved = this.resolveResourcePath(p);\n\t\t\t\tif (!existsSync(resolved) && !this.promptDiagnostics.some((d) => d.path === resolved)) {\n\t\t\t\t\tthis.promptDiagnostics.push({\n\t\t\t\t\t\ttype: \"error\",\n\t\t\t\t\t\tmessage: \"Prompt template path does not exist\",\n\t\t\t\t\t\tpath: resolved,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst themePaths = this.noThemes\n\t\t\t? this.mergePaths(cliEnabledThemes, this.additionalThemePaths)\n\t\t\t: this.mergePaths([...cliEnabledThemes, ...enabledThemes], this.additionalThemePaths);\n\n\t\tthis.lastThemePaths = themePaths;\n\t\tthis.updateThemesFromPaths(themePaths, metadataByPath);\n\t\tfor (const p of this.additionalThemePaths) {\n\t\t\tconst resolved = this.resolveResourcePath(p);\n\t\t\tif (!existsSync(resolved) && !this.themeDiagnostics.some((d) => d.path === resolved)) {\n\t\t\t\tthis.themeDiagnostics.push({ type: \"error\", message: \"Theme path does not exist\", path: resolved });\n\t\t\t}\n\t\t}\n\n\t\tconst agentsFiles = {\n\t\t\tagentsFiles: this.noContextFiles ? [] : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }),\n\t\t};\n\t\tconst resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;\n\t\tthis.agentsFiles = resolvedAgentsFiles.agentsFiles;\n\n\t\tconst baseSystemPrompt = resolvePromptInput(\n\t\t\tthis.systemPromptSource ?? this.discoverSystemPromptFile(),\n\t\t\t\"system prompt\",\n\t\t);\n\t\tthis.systemPrompt = this.systemPromptOverride ? this.systemPromptOverride(baseSystemPrompt) : baseSystemPrompt;\n\n\t\tconst appendSources =\n\t\t\tthis.appendSystemPromptSource ??\n\t\t\t(this.discoverAppendSystemPromptFile() ? [this.discoverAppendSystemPromptFile()!] : []);\n\t\tconst baseAppend = appendSources\n\t\t\t.map((s) => resolvePromptInput(s, \"append system prompt\"))\n\t\t\t.filter((s): s is string => s !== undefined);\n\t\tthis.appendSystemPrompt = this.appendSystemPromptOverride\n\t\t\t? this.appendSystemPromptOverride(baseAppend)\n\t\t\t: baseAppend;\n\t}\n\n\tprivate normalizeExtensionPaths(\n\t\tentries: Array<{ path: string; metadata: PathMetadata }>,\n\t): Array<{ path: string; metadata: PathMetadata }> {\n\t\treturn entries.map((entry) => {\n\t\t\tconst metadata = entry.metadata.baseDir\n\t\t\t\t? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) }\n\t\t\t\t: entry.metadata;\n\t\t\treturn {\n\t\t\t\tpath: this.resolveResourcePath(entry.path),\n\t\t\t\tmetadata,\n\t\t\t};\n\t\t});\n\t}\n\n\tprivate updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map<string, PathMetadata>): void {\n\t\tlet skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] };\n\t\tif (this.noSkills && skillPaths.length === 0) {\n\t\t\tskillsResult = { skills: [], diagnostics: [] };\n\t\t} else {\n\t\t\tskillsResult = loadSkills({\n\t\t\t\tcwd: this.cwd,\n\t\t\t\tagentDir: this.agentDir,\n\t\t\t\tskillPaths,\n\t\t\t\tincludeDefaults: false,\n\t\t\t});\n\t\t}\n\t\tconst resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult;\n\t\tthis.skills = resolvedSkills.skills.map((skill) => ({\n\t\t\t...skill,\n\t\t\tsourceInfo:\n\t\t\t\tthis.findSourceInfoForPath(skill.filePath, this.extensionSkillSourceInfos, metadataByPath) ??\n\t\t\t\tskill.sourceInfo ??\n\t\t\t\tthis.getDefaultSourceInfoForPath(skill.filePath),\n\t\t}));\n\t\tthis.skillDiagnostics = resolvedSkills.diagnostics;\n\t}\n\n\tprivate updatePromptsFromPaths(promptPaths: string[], metadataByPath?: Map<string, PathMetadata>): void {\n\t\tlet promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };\n\t\tif (this.noPromptTemplates && promptPaths.length === 0) {\n\t\t\tpromptsResult = { prompts: [], diagnostics: [] };\n\t\t} else {\n\t\t\tconst allPrompts = loadPromptTemplates({\n\t\t\t\tcwd: this.cwd,\n\t\t\t\tagentDir: this.agentDir,\n\t\t\t\tpromptPaths,\n\t\t\t\tincludeDefaults: false,\n\t\t\t});\n\t\t\tpromptsResult = this.dedupePrompts(allPrompts);\n\t\t}\n\t\tconst resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult;\n\t\tthis.prompts = resolvedPrompts.prompts.map((prompt) => ({\n\t\t\t...prompt,\n\t\t\tsourceInfo:\n\t\t\t\tthis.findSourceInfoForPath(prompt.filePath, this.extensionPromptSourceInfos, metadataByPath) ??\n\t\t\t\tprompt.sourceInfo ??\n\t\t\t\tthis.getDefaultSourceInfoForPath(prompt.filePath),\n\t\t}));\n\t\tthis.promptDiagnostics = resolvedPrompts.diagnostics;\n\t}\n\n\tprivate updateThemesFromPaths(themePaths: string[], metadataByPath?: Map<string, PathMetadata>): void {\n\t\tlet themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] };\n\t\tif (this.noThemes && themePaths.length === 0) {\n\t\t\tthemesResult = { themes: [], diagnostics: [] };\n\t\t} else {\n\t\t\tconst loaded = this.loadThemes(themePaths, false);\n\t\t\tconst deduped = this.dedupeThemes(loaded.themes);\n\t\t\tthemesResult = { themes: deduped.themes, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics] };\n\t\t}\n\t\tconst resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult;\n\t\tthis.themes = resolvedThemes.themes.map((theme) => {\n\t\t\tconst sourcePath = theme.sourcePath;\n\t\t\ttheme.sourceInfo = sourcePath\n\t\t\t\t? (this.findSourceInfoForPath(sourcePath, this.extensionThemeSourceInfos, metadataByPath) ??\n\t\t\t\t\ttheme.sourceInfo ??\n\t\t\t\t\tthis.getDefaultSourceInfoForPath(sourcePath))\n\t\t\t\t: theme.sourceInfo;\n\t\t\treturn theme;\n\t\t});\n\t\tthis.themeDiagnostics = resolvedThemes.diagnostics;\n\t}\n\n\tprivate applyExtensionSourceInfo(extensions: Extension[], metadataByPath: Map<string, PathMetadata>): void {\n\t\tfor (const extension of extensions) {\n\t\t\textension.sourceInfo =\n\t\t\t\tthis.findSourceInfoForPath(extension.path, undefined, metadataByPath) ??\n\t\t\t\tthis.getDefaultSourceInfoForPath(extension.path);\n\t\t\tfor (const command of extension.commands.values()) {\n\t\t\t\tcommand.sourceInfo = extension.sourceInfo;\n\t\t\t}\n\t\t\tfor (const tool of extension.tools.values()) {\n\t\t\t\ttool.sourceInfo = extension.sourceInfo;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate findSourceInfoForPath(\n\t\tresourcePath: string,\n\t\textraSourceInfos?: Map<string, SourceInfo>,\n\t\tmetadataByPath?: Map<string, PathMetadata>,\n\t): SourceInfo | undefined {\n\t\tif (!resourcePath) {\n\t\t\treturn undefined;\n\t\t}\n\n\t\tif (resourcePath.startsWith(\"<\")) {\n\t\t\treturn this.getDefaultSourceInfoForPath(resourcePath);\n\t\t}\n\n\t\tconst normalizedResourcePath = resolve(resourcePath);\n\t\tif (extraSourceInfos) {\n\t\t\tfor (const [sourcePath, sourceInfo] of extraSourceInfos.entries()) {\n\t\t\t\tconst normalizedSourcePath = resolve(sourcePath);\n\t\t\t\tif (\n\t\t\t\t\tnormalizedResourcePath === normalizedSourcePath ||\n\t\t\t\t\tnormalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`)\n\t\t\t\t) {\n\t\t\t\t\treturn { ...sourceInfo, path: resourcePath };\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (metadataByPath) {\n\t\t\tconst exact = metadataByPath.get(normalizedResourcePath) ?? metadataByPath.get(resourcePath);\n\t\t\tif (exact) {\n\t\t\t\treturn createSourceInfo(resourcePath, exact);\n\t\t\t}\n\n\t\t\tfor (const [sourcePath, metadata] of metadataByPath.entries()) {\n\t\t\t\tconst normalizedSourcePath = resolve(sourcePath);\n\t\t\t\tif (\n\t\t\t\t\tnormalizedResourcePath === normalizedSourcePath ||\n\t\t\t\t\tnormalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`)\n\t\t\t\t) {\n\t\t\t\t\treturn createSourceInfo(resourcePath, metadata);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn undefined;\n\t}\n\n\tprivate getDefaultSourceInfoForPath(filePath: string): SourceInfo {\n\t\tif (filePath.startsWith(\"<\") && filePath.endsWith(\">\")) {\n\t\t\treturn {\n\t\t\t\tpath: filePath,\n\t\t\t\tsource: filePath.slice(1, -1).split(\":\")[0] || \"temporary\",\n\t\t\t\tscope: \"temporary\",\n\t\t\t\torigin: \"top-level\",\n\t\t\t};\n\t\t}\n\n\t\tconst normalizedPath = resolve(filePath);\n\t\tconst agentRoots = [\n\t\t\tjoin(this.agentDir, \"skills\"),\n\t\t\tjoin(this.agentDir, \"prompts\"),\n\t\t\tjoin(this.agentDir, \"themes\"),\n\t\t\tjoin(this.agentDir, \"extensions\"),\n\t\t];\n\t\tconst projectRoots = [\n\t\t\tjoin(this.cwd, CONFIG_DIR_NAME, \"skills\"),\n\t\t\tjoin(this.cwd, CONFIG_DIR_NAME, \"prompts\"),\n\t\t\tjoin(this.cwd, CONFIG_DIR_NAME, \"themes\"),\n\t\t\tjoin(this.cwd, CONFIG_DIR_NAME, \"extensions\"),\n\t\t];\n\n\t\tfor (const root of agentRoots) {\n\t\t\tif (this.isUnderPath(normalizedPath, root)) {\n\t\t\t\treturn { path: filePath, source: \"local\", scope: \"user\", origin: \"top-level\", baseDir: root };\n\t\t\t}\n\t\t}\n\n\t\tfor (const root of projectRoots) {\n\t\t\tif (this.isUnderPath(normalizedPath, root)) {\n\t\t\t\treturn { path: filePath, source: \"local\", scope: \"project\", origin: \"top-level\", baseDir: root };\n\t\t\t}\n\t\t}\n\n\t\treturn {\n\t\t\tpath: filePath,\n\t\t\tsource: \"local\",\n\t\t\tscope: \"temporary\",\n\t\t\torigin: \"top-level\",\n\t\t\tbaseDir: statSync(normalizedPath).isDirectory() ? normalizedPath : resolve(normalizedPath, \"..\"),\n\t\t};\n\t}\n\n\tprivate mergePaths(primary: string[], additional: string[]): string[] {\n\t\tconst merged: string[] = [];\n\t\tconst seen = new Set<string>();\n\n\t\tfor (const p of [...primary, ...additional]) {\n\t\t\tconst resolved = this.resolveResourcePath(p);\n\t\t\tconst canonicalPath = canonicalizePath(resolved);\n\t\t\tif (seen.has(canonicalPath)) continue;\n\t\t\tseen.add(canonicalPath);\n\t\t\tmerged.push(resolved);\n\t\t}\n\n\t\treturn merged;\n\t}\n\n\tprivate resolveResourcePath(p: string): string {\n\t\treturn resolvePath(p, this.cwd, { trim: true });\n\t}\n\n\tprivate loadThemes(\n\t\tpaths: string[],\n\t\tincludeDefaults: boolean = true,\n\t): {\n\t\tthemes: Theme[];\n\t\tdiagnostics: ResourceDiagnostic[];\n\t} {\n\t\tconst themes: Theme[] = [];\n\t\tconst diagnostics: ResourceDiagnostic[] = [];\n\t\tif (includeDefaults) {\n\t\t\tconst defaultDirs = [join(this.agentDir, \"themes\"), join(this.cwd, CONFIG_DIR_NAME, \"themes\")];\n\n\t\t\tfor (const dir of defaultDirs) {\n\t\t\t\tthis.loadThemesFromDir(dir, themes, diagnostics);\n\t\t\t}\n\t\t}\n\n\t\tfor (const p of paths) {\n\t\t\tconst resolved = this.resolveResourcePath(p);\n\t\t\tif (!existsSync(resolved)) {\n\t\t\t\tdiagnostics.push({ type: \"warning\", message: \"theme path does not exist\", path: resolved });\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tconst stats = statSync(resolved);\n\t\t\t\tif (stats.isDirectory()) {\n\t\t\t\t\tthis.loadThemesFromDir(resolved, themes, diagnostics);\n\t\t\t\t} else if (stats.isFile() && resolved.endsWith(\".json\")) {\n\t\t\t\t\tthis.loadThemeFromFile(resolved, themes, diagnostics);\n\t\t\t\t} else {\n\t\t\t\t\tdiagnostics.push({ type: \"warning\", message: \"theme path is not a json file\", path: resolved });\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tconst message = error instanceof Error ? error.message : \"failed to read theme path\";\n\t\t\t\tdiagnostics.push({ type: \"warning\", message, path: resolved });\n\t\t\t}\n\t\t}\n\n\t\treturn { themes, diagnostics };\n\t}\n\n\tprivate loadThemesFromDir(dir: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void {\n\t\tif (!existsSync(dir)) {\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tconst entries = readdirSync(dir, { withFileTypes: true });\n\t\t\tfor (const entry of entries) {\n\t\t\t\tlet isFile = entry.isFile();\n\t\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tisFile = statSync(join(dir, entry.name)).isFile();\n\t\t\t\t\t} catch {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (!isFile) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (!entry.name.endsWith(\".json\")) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tthis.loadThemeFromFile(join(dir, entry.name), themes, diagnostics);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : \"failed to read theme directory\";\n\t\t\tdiagnostics.push({ type: \"warning\", message, path: dir });\n\t\t}\n\t}\n\n\tprivate loadThemeFromFile(filePath: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void {\n\t\ttry {\n\t\t\tthemes.push(loadThemeFromPath(filePath));\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : \"failed to load theme\";\n\t\t\tdiagnostics.push({ type: \"warning\", message, path: filePath });\n\t\t}\n\t}\n\n\tprivate async loadExtensionFactories(runtime: ExtensionRuntime): Promise<{\n\t\textensions: Extension[];\n\t\terrors: Array<{ path: string; error: string }>;\n\t}> {\n\t\tconst extensions: Extension[] = [];\n\t\tconst errors: Array<{ path: string; error: string }> = [];\n\n\t\tfor (const [index, factory] of this.extensionFactories.entries()) {\n\t\t\tconst extensionPath = `<inline:${index + 1}>`;\n\t\t\ttry {\n\t\t\t\tconst extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath);\n\t\t\t\textensions.push(extension);\n\t\t\t} catch (error) {\n\t\t\t\tconst message = error instanceof Error ? error.message : \"failed to load extension\";\n\t\t\t\terrors.push({ path: extensionPath, error: message });\n\t\t\t}\n\t\t}\n\n\t\treturn { extensions, errors };\n\t}\n\n\tprivate dedupePrompts(prompts: PromptTemplate[]): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } {\n\t\tconst seen = new Map<string, PromptTemplate>();\n\t\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\t\tfor (const prompt of prompts) {\n\t\t\tconst existing = seen.get(prompt.name);\n\t\t\tif (existing) {\n\t\t\t\tdiagnostics.push({\n\t\t\t\t\ttype: \"collision\",\n\t\t\t\t\tmessage: `name \"/${prompt.name}\" collision`,\n\t\t\t\t\tpath: prompt.filePath,\n\t\t\t\t\tcollision: {\n\t\t\t\t\t\tresourceType: \"prompt\",\n\t\t\t\t\t\tname: prompt.name,\n\t\t\t\t\t\twinnerPath: existing.filePath,\n\t\t\t\t\t\tloserPath: prompt.filePath,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tseen.set(prompt.name, prompt);\n\t\t\t}\n\t\t}\n\n\t\treturn { prompts: Array.from(seen.values()), diagnostics };\n\t}\n\n\tprivate dedupeThemes(themes: Theme[]): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {\n\t\tconst seen = new Map<string, Theme>();\n\t\tconst diagnostics: ResourceDiagnostic[] = [];\n\n\t\tfor (const t of themes) {\n\t\t\tconst name = t.name ?? \"unnamed\";\n\t\t\tconst existing = seen.get(name);\n\t\t\tif (existing) {\n\t\t\t\tdiagnostics.push({\n\t\t\t\t\ttype: \"collision\",\n\t\t\t\t\tmessage: `name \"${name}\" collision`,\n\t\t\t\t\tpath: t.sourcePath,\n\t\t\t\t\tcollision: {\n\t\t\t\t\t\tresourceType: \"theme\",\n\t\t\t\t\t\tname,\n\t\t\t\t\t\twinnerPath: existing.sourcePath ?? \"<builtin>\",\n\t\t\t\t\t\tloserPath: t.sourcePath ?? \"<builtin>\",\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tseen.set(name, t);\n\t\t\t}\n\t\t}\n\n\t\treturn { themes: Array.from(seen.values()), diagnostics };\n\t}\n\n\tprivate discoverSystemPromptFile(): string | undefined {\n\t\tconst projectPath = join(this.cwd, CONFIG_DIR_NAME, \"SYSTEM.md\");\n\t\tif (existsSync(projectPath)) {\n\t\t\treturn projectPath;\n\t\t}\n\n\t\tconst globalPath = join(this.agentDir, \"SYSTEM.md\");\n\t\tif (existsSync(globalPath)) {\n\t\t\treturn globalPath;\n\t\t}\n\n\t\treturn undefined;\n\t}\n\n\tprivate discoverAppendSystemPromptFile(): string | undefined {\n\t\tconst projectPath = join(this.cwd, CONFIG_DIR_NAME, \"APPEND_SYSTEM.md\");\n\t\tif (existsSync(projectPath)) {\n\t\t\treturn projectPath;\n\t\t}\n\n\t\tconst globalPath = join(this.agentDir, \"APPEND_SYSTEM.md\");\n\t\tif (existsSync(globalPath)) {\n\t\t\treturn globalPath;\n\t\t}\n\n\t\treturn undefined;\n\t}\n\n\tprivate isUnderPath(target: string, root: string): boolean {\n\t\tconst normalizedRoot = resolve(root);\n\t\tif (target === normalizedRoot) {\n\t\t\treturn true;\n\t\t}\n\t\tconst prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;\n\t\treturn target.startsWith(prefix);\n\t}\n\n\tprivate detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> {\n\t\tconst conflicts: Array<{ path: string; message: string }> = [];\n\n\t\t// Track which extension registered each tool and flag\n\t\tconst toolOwners = new Map<string, string>();\n\t\tconst flagOwners = new Map<string, string>();\n\n\t\tfor (const ext of extensions) {\n\t\t\t// Check tools\n\t\t\tfor (const toolName of ext.tools.keys()) {\n\t\t\t\tconst existingOwner = toolOwners.get(toolName);\n\t\t\t\tif (existingOwner && existingOwner !== ext.path) {\n\t\t\t\t\tconflicts.push({\n\t\t\t\t\t\tpath: ext.path,\n\t\t\t\t\t\tmessage: `Tool \"${toolName}\" conflicts with ${existingOwner}`,\n\t\t\t\t\t});\n\t\t\t\t} else {\n\t\t\t\t\ttoolOwners.set(toolName, ext.path);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check flags\n\t\t\tfor (const flagName of ext.flags.keys()) {\n\t\t\t\tconst existingOwner = flagOwners.get(flagName);\n\t\t\t\tif (existingOwner && existingOwner !== ext.path) {\n\t\t\t\t\tconflicts.push({\n\t\t\t\t\t\tpath: ext.path,\n\t\t\t\t\t\tmessage: `Flag \"--${flagName}\" conflicts with ${existingOwner}`,\n\t\t\t\t\t});\n\t\t\t\t} else {\n\t\t\t\t\tflagOwners.set(flagName, ext.path);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn conflicts;\n\t}\n}\n"}],"isError":false,"timestamp":1781615153824}}
|
||
{"type":"message","id":"c0142727","parentId":"86a33b97","timestamp":"2026-06-16T13:05:56.827Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 prompt-templates.ts,了解模板是如何工作的。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_1f089bff5b2a40d693cbd904","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/src/core/prompt-templates.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":7726,"output":56,"cacheRead":99072,"cacheWrite":0,"totalTokens":106854,"cost":{"input":0.0030904,"output":0.000112,"cacheRead":0.00792576,"cacheWrite":0,"total":0.011128160000000002}},"stopReason":"toolUse","timestamp":1781615153828,"responseId":"da5e115d4b6349aa8ff9c8fbb815db08"}}
|
||
{"type":"message","id":"122de8d7","parentId":"c0142727","timestamp":"2026-06-16T13:05:56.830Z","message":{"role":"toolResult","toolCallId":"call_1f089bff5b2a40d693cbd904","toolName":"read","content":[{"type":"text","text":"import { existsSync, readdirSync, readFileSync, statSync } from \"fs\";\nimport { basename, dirname, join, resolve, sep } from \"path\";\nimport { CONFIG_DIR_NAME } from \"../config.ts\";\nimport { parseFrontmatter } from \"../utils/frontmatter.ts\";\nimport { resolvePath } from \"../utils/paths.ts\";\nimport { createSyntheticSourceInfo, type SourceInfo } from \"./source-info.ts\";\n\n/**\n * Represents a prompt template loaded from a markdown file\n */\nexport interface PromptTemplate {\n\tname: string;\n\tdescription: string;\n\targumentHint?: string;\n\tcontent: string;\n\tsourceInfo: SourceInfo;\n\tfilePath: string; // Absolute path to the template file\n}\n\n/**\n * Parse command arguments respecting quoted strings (bash-style)\n * Returns array of arguments\n */\nexport function parseCommandArgs(argsString: string): string[] {\n\tconst args: string[] = [];\n\tlet current = \"\";\n\tlet inQuote: string | null = null;\n\n\tfor (let i = 0; i < argsString.length; i++) {\n\t\tconst char = argsString[i];\n\n\t\tif (inQuote) {\n\t\t\tif (char === inQuote) {\n\t\t\t\tinQuote = null;\n\t\t\t} else {\n\t\t\t\tcurrent += char;\n\t\t\t}\n\t\t} else if (char === '\"' || char === \"'\") {\n\t\t\tinQuote = char;\n\t\t} else if (/\\s/.test(char)) {\n\t\t\tif (current) {\n\t\t\t\targs.push(current);\n\t\t\t\tcurrent = \"\";\n\t\t\t}\n\t\t} else {\n\t\t\tcurrent += char;\n\t\t}\n\t}\n\n\tif (current) {\n\t\targs.push(current);\n\t}\n\n\treturn args;\n}\n\n/**\n * Substitute argument placeholders in template content\n * Supports:\n * - $1, $2, ... for positional args\n * - $@ and $ARGUMENTS for all args\n * - ${@:N} for args from Nth onwards (bash-style slicing)\n * - ${@:N:L} for L args starting from Nth\n *\n * Note: Replacement happens on the template string only. Argument values\n * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted.\n */\nexport function substituteArgs(content: string, args: string[]): string {\n\tlet result = content;\n\n\t// Replace $1, $2, etc. with positional args FIRST (before wildcards)\n\t// This prevents wildcard replacement values containing $<digit> patterns from being re-substituted\n\tresult = result.replace(/\\$(\\d+)/g, (_, num) => {\n\t\tconst index = parseInt(num, 10) - 1;\n\t\treturn args[index] ?? \"\";\n\t});\n\n\t// Replace ${@:start} or ${@:start:length} with sliced args (bash-style)\n\t// Process BEFORE simple $@ to avoid conflicts\n\tresult = result.replace(/\\$\\{@:(\\d+)(?::(\\d+))?\\}/g, (_, startStr, lengthStr) => {\n\t\tlet start = parseInt(startStr, 10) - 1; // Convert to 0-indexed (user provides 1-indexed)\n\t\t// Treat 0 as 1 (bash convention: args start at 1)\n\t\tif (start < 0) start = 0;\n\n\t\tif (lengthStr) {\n\t\t\tconst length = parseInt(lengthStr, 10);\n\t\t\treturn args.slice(start, start + length).join(\" \");\n\t\t}\n\t\treturn args.slice(start).join(\" \");\n\t});\n\n\t// Pre-compute all args joined (optimization)\n\tconst allArgs = args.join(\" \");\n\n\t// Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode)\n\tresult = result.replace(/\\$ARGUMENTS/g, allArgs);\n\n\t// Replace $@ with all args joined (existing syntax)\n\tresult = result.replace(/\\$@/g, allArgs);\n\n\treturn result;\n}\n\nfunction loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptTemplate | null {\n\ttry {\n\t\tconst rawContent = readFileSync(filePath, \"utf-8\");\n\t\tconst { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent);\n\n\t\tconst name = basename(filePath).replace(/\\.md$/, \"\");\n\n\t\t// Get description from frontmatter or first non-empty line\n\t\tlet description = frontmatter.description || \"\";\n\t\tif (!description) {\n\t\t\tconst firstLine = body.split(\"\\n\").find((line) => line.trim());\n\t\t\tif (firstLine) {\n\t\t\t\t// Truncate if too long\n\t\t\t\tdescription = firstLine.slice(0, 60);\n\t\t\t\tif (firstLine.length > 60) description += \"...\";\n\t\t\t}\n\t\t}\n\n\t\treturn {\n\t\t\tname,\n\t\t\tdescription,\n\t\t\t...(frontmatter[\"argument-hint\"] && { argumentHint: frontmatter[\"argument-hint\"] }),\n\t\t\tcontent: body,\n\t\t\tsourceInfo,\n\t\t\tfilePath,\n\t\t};\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Scan a directory for .md files (non-recursive) and load them as prompt templates.\n */\nfunction loadTemplatesFromDir(dir: string, getSourceInfo: (filePath: string) => SourceInfo): PromptTemplate[] {\n\tconst templates: PromptTemplate[] = [];\n\n\tif (!existsSync(dir)) {\n\t\treturn templates;\n\t}\n\n\ttry {\n\t\tconst entries = readdirSync(dir, { withFileTypes: true });\n\n\t\tfor (const entry of entries) {\n\t\t\tconst fullPath = join(dir, entry.name);\n\n\t\t\t// For symlinks, check if they point to a file\n\t\t\tlet isFile = entry.isFile();\n\t\t\tif (entry.isSymbolicLink()) {\n\t\t\t\ttry {\n\t\t\t\t\tconst stats = statSync(fullPath);\n\t\t\t\t\tisFile = stats.isFile();\n\t\t\t\t} catch {\n\t\t\t\t\t// Broken symlink, skip it\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (isFile && entry.name.endsWith(\".md\")) {\n\t\t\t\tconst template = loadTemplateFromFile(fullPath, getSourceInfo(fullPath));\n\t\t\t\tif (template) {\n\t\t\t\t\ttemplates.push(template);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} catch {\n\t\treturn templates;\n\t}\n\n\treturn templates;\n}\n\nexport interface LoadPromptTemplatesOptions {\n\t/** Working directory for project-local templates. */\n\tcwd: string;\n\t/** Agent config directory for global templates. */\n\tagentDir: string;\n\t/** Explicit prompt template paths (files or directories). */\n\tpromptPaths: string[];\n\t/** Include default prompt directories. */\n\tincludeDefaults: boolean;\n}\n\n/**\n * Load all prompt templates from:\n * 1. Global: agentDir/prompts/\n * 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/\n * 3. Explicit prompt paths\n */\nexport function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] {\n\tconst resolvedCwd = resolvePath(options.cwd);\n\tconst resolvedAgentDir = resolvePath(options.agentDir);\n\tconst promptPaths = options.promptPaths;\n\tconst includeDefaults = options.includeDefaults;\n\n\tconst templates: PromptTemplate[] = [];\n\n\tconst globalPromptsDir = join(resolvedAgentDir, \"prompts\");\n\tconst projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, \"prompts\");\n\n\tconst isUnderPath = (target: string, root: string): boolean => {\n\t\tconst normalizedRoot = resolve(root);\n\t\tif (target === normalizedRoot) {\n\t\t\treturn true;\n\t\t}\n\t\tconst prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;\n\t\treturn target.startsWith(prefix);\n\t};\n\n\tconst getSourceInfo = (resolvedPath: string): SourceInfo => {\n\t\tif (isUnderPath(resolvedPath, globalPromptsDir)) {\n\t\t\treturn createSyntheticSourceInfo(resolvedPath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"user\",\n\t\t\t\tbaseDir: globalPromptsDir,\n\t\t\t});\n\t\t}\n\t\tif (isUnderPath(resolvedPath, projectPromptsDir)) {\n\t\t\treturn createSyntheticSourceInfo(resolvedPath, {\n\t\t\t\tsource: \"local\",\n\t\t\t\tscope: \"project\",\n\t\t\t\tbaseDir: projectPromptsDir,\n\t\t\t});\n\t\t}\n\t\treturn createSyntheticSourceInfo(resolvedPath, {\n\t\t\tsource: \"local\",\n\t\t\tbaseDir: statSync(resolvedPath).isDirectory() ? resolvedPath : dirname(resolvedPath),\n\t\t});\n\t};\n\n\tif (includeDefaults) {\n\t\ttemplates.push(...loadTemplatesFromDir(globalPromptsDir, getSourceInfo));\n\t\ttemplates.push(...loadTemplatesFromDir(projectPromptsDir, getSourceInfo));\n\t}\n\n\t// 3. Load explicit prompt paths\n\tfor (const rawPath of promptPaths) {\n\t\tconst resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true });\n\t\tif (!existsSync(resolvedPath)) {\n\t\t\tcontinue;\n\t\t}\n\n\t\ttry {\n\t\t\tconst stats = statSync(resolvedPath);\n\t\t\tif (stats.isDirectory()) {\n\t\t\t\ttemplates.push(...loadTemplatesFromDir(resolvedPath, getSourceInfo));\n\t\t\t} else if (stats.isFile() && resolvedPath.endsWith(\".md\")) {\n\t\t\t\tconst template = loadTemplateFromFile(resolvedPath, getSourceInfo(resolvedPath));\n\t\t\t\tif (template) {\n\t\t\t\t\ttemplates.push(template);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// Ignore read failures\n\t\t}\n\t}\n\n\treturn templates;\n}\n\n/**\n * Expand a prompt template if it matches a template name.\n * Returns the expanded content or the original text if not a template.\n */\nexport function expandPromptTemplate(text: string, templates: PromptTemplate[]): string {\n\tif (!text.startsWith(\"/\")) return text;\n\n\tconst match = text.match(/^\\/([^\\s]+)(?:\\s+([\\s\\S]*))?$/);\n\tif (!match) return text;\n\n\tconst templateName = match[1];\n\tconst argsString = match[2] ?? \"\";\n\n\tconst template = templates.find((t) => t.name === templateName);\n\tif (template) {\n\t\tconst args = parseCommandArgs(argsString);\n\t\treturn substituteArgs(template.content, args);\n\t}\n\n\treturn text;\n}\n"}],"isError":false,"timestamp":1781615156829}}
|
||
{"type":"message","id":"30e41394","parentId":"122de8d7","timestamp":"2026-06-16T13:06:00.497Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在让我查看一下系统提示词构建的文档,了解完整的上下文构建流程。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_0ae6aa3a6b704c69820bc8e3","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/docs/prompt-templates.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":2191,"output":60,"cacheRead":106752,"cacheWrite":0,"totalTokens":109003,"cost":{"input":0.0008764,"output":0.00011999999999999999,"cacheRead":0.00854016,"cacheWrite":0,"total":0.00953656}},"stopReason":"toolUse","timestamp":1781615156832,"responseId":"7f1d2da60af647c790d951fd9f322563"}}
|
||
{"type":"message","id":"2c11f7a1","parentId":"30e41394","timestamp":"2026-06-16T13:06:00.499Z","message":{"role":"toolResult","toolCallId":"call_0ae6aa3a6b704c69820bc8e3","toolName":"read","content":[{"type":"text","text":"> pi can create prompt templates. Ask it to build one for your workflow.\n\n# Prompt Templates\n\nPrompt templates are Markdown snippets that expand into full prompts. Type `/name` in the editor to invoke a template, where `name` is the filename without `.md`.\n\n## Locations\n\nPi loads prompt templates from:\n\n- Global: `~/.pi/agent/prompts/*.md`\n- Project: `.pi/prompts/*.md`\n- Packages: `prompts/` directories or `pi.prompts` entries in `package.json`\n- Settings: `prompts` array with files or directories\n- CLI: `--prompt-template <path>` (repeatable)\n\nDisable discovery with `--no-prompt-templates`.\n\n## Format\n\n```markdown\n---\ndescription: Review staged git changes\n---\nReview the staged changes (`git diff --cached`). Focus on:\n- Bugs and logic errors\n- Security issues\n- Error handling gaps\n```\n\n- The filename becomes the command name. `review.md` becomes `/review`.\n- `description` is optional. If missing, the first non-empty line is used.\n- `argument-hint` is optional. When set, the hint is displayed before the description in the autocomplete dropdown.\n\n### Argument Hints\n\nUse `argument-hint` in frontmatter to show expected arguments in autocomplete. Use `<angle brackets>` for required arguments and `[square brackets]` for optional ones:\n\n```markdown\n---\ndescription: Review PRs from URLs with structured issue and code analysis\nargument-hint: \"<PR-URL>\"\n---\n```\n\nThis renders in the autocomplete dropdown as:\n\n```\n→ pr <PR-URL> — Review PRs from URLs with structured issue and code analysis\n is <issue> — Analyze GitHub issues (bugs or feature requests)\n wr [instructions] — Finish the current task end-to-end\n cl — Audit changelog entries before release\n```\n\n## Usage\n\nType `/` followed by the template name in the editor. Autocomplete shows available templates with descriptions.\n\n```\n/review # Expands review.md\n/component Button # Expands with argument\n/component Button \"click handler\" # Multiple arguments\n```\n\n## Arguments\n\nTemplates support positional arguments and simple slicing:\n\n- `$1`, `$2`, ... positional args\n- `$@` or `$ARGUMENTS` for all args joined\n- `${@:N}` for args from the Nth position (1-indexed)\n- `${@:N:L}` for `L` args starting at N\n\nExample:\n\n```markdown\n---\ndescription: Create a component\n---\nCreate a React component named $1 with features: $@\n```\n\nUsage: `/component Button \"onClick handler\" \"disabled support\"`\n\n## Loading Rules\n\n- Template discovery in `prompts/` is non-recursive.\n- If you want templates in subdirectories, add them explicitly via `prompts` settings or a package manifest.\n"}],"isError":false,"timestamp":1781615160499}}
|
||
{"type":"message","id":"aa667fbe","parentId":"2c11f7a1","timestamp":"2026-06-16T13:06:05.737Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 skills 的文档,了解技能是如何加载的。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_84f93209f4ef46a69f6cf43d","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/docs/skills.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":689,"output":53,"cacheRead":108928,"cacheWrite":0,"totalTokens":109670,"cost":{"input":0.00027560000000000003,"output":0.000106,"cacheRead":0.00871424,"cacheWrite":0,"total":0.00909584}},"stopReason":"toolUse","timestamp":1781615160503,"responseId":"8093b62ef1b74dfc98225ac2f9ce4d3e"}}
|
||
{"type":"message","id":"910ae884","parentId":"aa667fbe","timestamp":"2026-06-16T13:06:05.739Z","message":{"role":"toolResult","toolCallId":"call_84f93209f4ef46a69f6cf43d","toolName":"read","content":[{"type":"text","text":"> pi can create skills. Ask it to build one for your use case.\n\n# Skills\n\nSkills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks.\n\nPi implements the [Agent Skills standard](https://agentskills.io/specification), warning about most violations but remaining lenient. Pi allows skill names to differ from their parent directory even though the standard disallows it; that rule is suboptimal for shared skill directories used across multiple agent harnesses.\n\n## Table of Contents\n\n- [Locations](#locations)\n- [How Skills Work](#how-skills-work)\n- [Skill Commands](#skill-commands)\n- [Skill Structure](#skill-structure)\n- [Frontmatter](#frontmatter)\n- [Validation](#validation)\n- [Example](#example)\n- [Skill Repositories](#skill-repositories)\n\n## Locations\n\n> **Security:** Skills can instruct the model to perform any action and may include executable code the model invokes. Review skill content before use.\n\nPi loads skills from:\n\n- Global:\n - `~/.pi/agent/skills/`\n - `~/.agents/skills/`\n- Project:\n - `.pi/skills/`\n - `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo)\n- Packages: `skills/` directories or `pi.skills` entries in `package.json`\n- Settings: `skills` array with files or directories\n- CLI: `--skill <path>` (repeatable, additive even with `--no-skills`)\n\nDiscovery rules:\n- In `~/.pi/agent/skills/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills\n- In all skill locations, directories containing `SKILL.md` are discovered recursively\n- In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored\n\nDisable discovery with `--no-skills` (explicit `--skill` paths still load).\n\n### Using Skills from Other Harnesses\n\nTo use skills from Claude Code or OpenAI Codex, add their directories to settings:\n\n```json\n{\n \"skills\": [\n \"~/.claude/skills\",\n \"~/.codex/skills\"\n ]\n}\n```\n\nFor project-level Claude Code skills, add to `.pi/settings.json`:\n\n```json\n{\n \"skills\": [\"../.claude/skills\"]\n}\n```\n\n## How Skills Work\n\n1. At startup, pi scans skill locations and extracts names and descriptions\n2. The system prompt includes available skills in XML format per the [specification](https://agentskills.io/integrate-skills)\n3. When a task matches, the agent uses `read` to load the full SKILL.md (models don't always do this; use prompting or `/skill:name` to force it)\n4. The agent follows the instructions, using relative paths to reference scripts and assets\n\nThis is progressive disclosure: only descriptions are always in context, full instructions load on-demand.\n\n## Skill Commands\n\nSkills register as `/skill:name` commands:\n\n```bash\n/skill:brave-search # Load and execute the skill\n/skill:pdf-tools extract # Load skill with arguments\n```\n\nArguments after the command are appended to the skill content as `User: <args>`.\n\nToggle skill commands via `/settings` in interactive mode or in `settings.json`:\n\n```json\n{\n \"enableSkillCommands\": true\n}\n```\n\n## Skill Structure\n\nA skill is a directory with a `SKILL.md` file. Everything else is freeform.\n\n```\nmy-skill/\n├── SKILL.md # Required: frontmatter + instructions\n├── scripts/ # Helper scripts\n│ └── process.sh\n├── references/ # Detailed docs loaded on-demand\n│ └── api-reference.md\n└── assets/\n └── template.json\n```\n\n### SKILL.md Format\n\n````markdown\n---\nname: my-skill\ndescription: What this skill does and when to use it. Be specific.\n---\n\n# My Skill\n\n## Setup\n\nRun once before first use:\n```bash\ncd /path/to/skill && npm install\n```\n\n## Usage\n\n```bash\n./scripts/process.sh <input>\n```\n````\n\nUse relative paths from the skill directory:\n\n```markdown\nSee [the reference guide](references/REFERENCE.md) for details.\n```\n\n## Frontmatter\n\nPer the [Agent Skills specification](https://agentskills.io/specification#frontmatter-required):\n\n| Field | Required | Description |\n|-------|----------|-------------|\n| `name` | Yes | Max 64 chars. Lowercase a-z, 0-9, hyphens. Unlike the standard, Pi does not require this to match the parent directory because that standard requirement is suboptimal for shared skill directories. |\n| `description` | Yes | Max 1024 chars. What the skill does and when to use it. |\n| `license` | No | License name or reference to bundled file. |\n| `compatibility` | No | Max 500 chars. Environment requirements. |\n| `metadata` | No | Arbitrary key-value mapping. |\n| `allowed-tools` | No | Space-delimited list of pre-approved tools (experimental). |\n| `disable-model-invocation` | No | When `true`, skill is hidden from system prompt. Users must use `/skill:name`. |\n\n### Name Rules\n\n- 1-64 characters\n- Lowercase letters, numbers, hyphens only\n- No leading/trailing hyphens\n- No consecutive hyphens\nPi does not require the name to match the parent directory. The Agent Skills standard does, but that requirement is suboptimal for shared skill directories used by multiple tools.\n\nValid: `pdf-processing`, `data-analysis`, `code-review`\nInvalid: `PDF-Processing`, `-pdf`, `pdf--processing`\n\n### Description Best Practices\n\nThe description determines when the agent loads the skill. Be specific.\n\nGood:\n```yaml\ndescription: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs. Use when working with PDF documents.\n```\n\nPoor:\n```yaml\ndescription: Helps with PDFs.\n```\n\n## Validation\n\nPi validates skills against the Agent Skills standard. Most issues produce warnings but still load the skill:\n\n- Name exceeds 64 characters or contains invalid characters\n- Name starts/ends with hyphen or has consecutive hyphens\n- Description exceeds 1024 characters\n\nUnknown frontmatter fields are ignored.\n\n**Exception:** Skills with missing description are not loaded.\n\nName collisions (same name from different locations) warn and keep the first skill found.\n\n## Example\n\n```\nbrave-search/\n├── SKILL.md\n├── search.js\n└── content.js\n```\n\n**SKILL.md:**\n````markdown\n---\nname: brave-search\ndescription: Web search and content extraction via Brave Search API. Use for searching documentation, facts, or any web content.\n---\n\n# Brave Search\n\n## Setup\n\n```bash\ncd /path/to/brave-search && npm install\n```\n\n## Search\n\n```bash\n./search.js \"query\" # Basic search\n./search.js \"query\" --content # Include page content\n```\n\n## Extract Page Content\n\n```bash\n./content.js https://example.com\n```\n````\n\n## Skill Repositories\n\n- [Anthropic Skills](https://github.com/anthropics/skills) - Document processing (docx, pdf, pptx, xlsx), web development\n- [Pi Skills](https://github.com/badlogic/pi-skills) - Web search, browser automation, Google APIs, transcription\n"}],"isError":false,"timestamp":1781615165739}}
|
||
{"type":"message","id":"068acfb9","parentId":"910ae884","timestamp":"2026-06-16T13:06:14.786Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我对项目中的上下文工程有了完整的了解。让我总结一下整个工作流程,包括系统提示词的构建和上下文的管理。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_5e27b0d8e33c46ad95cbbc91","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/docs/settings.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1755,"output":69,"cacheRead":109568,"cacheWrite":0,"totalTokens":111392,"cost":{"input":0.000702,"output":0.000138,"cacheRead":0.00876544,"cacheWrite":0,"total":0.00960544}},"stopReason":"toolUse","timestamp":1781615165742,"responseId":"0f0122b9b05a45d8ad0af971e51f5aff"}}
|
||
{"type":"message","id":"d96fb622","parentId":"068acfb9","timestamp":"2026-06-16T13:06:14.788Z","message":{"role":"toolResult","toolCallId":"call_5e27b0d8e33c46ad95cbbc91","toolName":"read","content":[{"type":"text","text":"# Settings\n\nPi uses JSON settings files with project settings overriding global settings.\n\n| Location | Scope |\n|----------|-------|\n| `~/.pi/agent/settings.json` | Global (all projects) |\n| `.pi/settings.json` | Project (current directory) |\n\nEdit directly or use `/settings` for common options.\n\n## All Settings\n\n### Model & Thinking\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `defaultProvider` | string | - | Default provider (e.g., `\"anthropic\"`, `\"openai\"`) |\n| `defaultModel` | string | - | Default model ID |\n| `defaultThinkingLevel` | string | - | `\"off\"`, `\"minimal\"`, `\"low\"`, `\"medium\"`, `\"high\"`, `\"xhigh\"` |\n| `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output |\n| `thinkingBudgets` | object | - | Custom token budgets per thinking level |\n\n#### thinkingBudgets\n\n```json\n{\n \"thinkingBudgets\": {\n \"minimal\": 1024,\n \"low\": 4096,\n \"medium\": 10240,\n \"high\": 32768\n }\n}\n```\n\n### UI & Display\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `theme` | string | `\"dark\"` | Theme name (`\"dark\"`, `\"light\"`, or custom) |\n| `quietStartup` | boolean | `false` | Hide startup header |\n| `collapseChangelog` | boolean | `false` | Show condensed changelog after updates |\n| `enableInstallTelemetry` | boolean | `true` | Send an anonymous install/update version ping after first install or changelog-detected updates. This does not control update checks |\n| `doubleEscapeAction` | string | `\"tree\"` | Action for double-escape: `\"tree\"`, `\"fork\"`, or `\"none\"` |\n| `treeFilterMode` | string | `\"default\"` | Default filter for `/tree`: `\"default\"`, `\"no-tools\"`, `\"user-only\"`, `\"labeled-only\"`, `\"all\"` |\n| `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) |\n| `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) |\n| `showHardwareCursor` | boolean | `false` | Show terminal cursor |\n\n### Telemetry and update checks\n\n`enableInstallTelemetry` only controls the anonymous install/update ping to `https://pi.dev/api/report-install`. Opting out of telemetry does not disable update checks; Pi can still fetch `https://pi.dev/api/latest-version` to look for the latest version.\n\nSet `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry.\n\n### Warnings\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `warnings.anthropicExtraUsage` | boolean | `true` | Show a warning when Anthropic subscription auth may use paid extra usage |\n\n```json\n{\n \"warnings\": {\n \"anthropicExtraUsage\": false\n }\n}\n```\n\n### Compaction\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `compaction.enabled` | boolean | `true` | Enable auto-compaction |\n| `compaction.reserveTokens` | number | `16384` | Tokens reserved for LLM response |\n| `compaction.keepRecentTokens` | number | `20000` | Recent tokens to keep (not summarized) |\n\n```json\n{\n \"compaction\": {\n \"enabled\": true,\n \"reserveTokens\": 16384,\n \"keepRecentTokens\": 20000\n }\n}\n```\n\n### Branch Summary\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `branchSummary.reserveTokens` | number | `16384` | Tokens reserved for branch summarization |\n| `branchSummary.skipPrompt` | boolean | `false` | Skip \"Summarize branch?\" prompt on `/tree` navigation (defaults to no summary) |\n\n### Retry\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `retry.enabled` | boolean | `true` | Enable automatic agent-level retry on transient errors |\n| `retry.maxRetries` | number | `3` | Maximum agent-level retry attempts |\n| `retry.baseDelayMs` | number | `2000` | Base delay for agent-level exponential backoff (2s, 4s, 8s) |\n| `retry.provider.timeoutMs` | number | SDK default | Provider/SDK request timeout in milliseconds |\n| `retry.provider.maxRetries` | number | SDK default | Provider/SDK retry attempts |\n| `retry.provider.maxRetryDelayMs` | number | `60000` | Max server-requested delay before failing (60s) |\n\nWhen a provider requests a retry delay longer than `retry.provider.maxRetryDelayMs` (e.g., Google's \"quota will reset after 5h\"), the request fails immediately with an informative error instead of waiting silently. Set to `0` to disable the cap.\n\n```json\n{\n \"retry\": {\n \"enabled\": true,\n \"maxRetries\": 3,\n \"baseDelayMs\": 2000,\n \"provider\": {\n \"timeoutMs\": 3600000,\n \"maxRetries\": 0,\n \"maxRetryDelayMs\": 60000\n }\n }\n}\n```\n\n### Message Delivery\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `steeringMode` | string | `\"one-at-a-time\"` | How steering messages are sent: `\"all\"` or `\"one-at-a-time\"` |\n| `followUpMode` | string | `\"one-at-a-time\"` | How follow-up messages are sent: `\"all\"` or `\"one-at-a-time\"` |\n| `transport` | string | `\"sse\"` | Preferred transport for providers that support multiple transports: `\"sse\"`, `\"websocket\"`, or `\"auto\"` |\n\n### Terminal & Images\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `terminal.showImages` | boolean | `true` | Show images in terminal (if supported) |\n| `terminal.imageWidthCells` | number | `60` | Preferred inline image width in terminal cells |\n| `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when content shrinks (can cause flicker) |\n| `images.autoResize` | boolean | `true` | Resize images to 2000x2000 max |\n| `images.blockImages` | boolean | `false` | Block all images from being sent to LLM |\n\n### Shell\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `shellPath` | string | - | Custom shell path (e.g., for Cygwin on Windows) |\n| `shellCommandPrefix` | string | - | Prefix for every bash command (e.g., `\"shopt -s expand_aliases\"`) |\n| `npmCommand` | string[] | - | Command argv used for npm package lookup/install operations (e.g., `[\"mise\", \"exec\", \"node@20\", \"--\", \"npm\"]`) |\n\n```json\n{\n \"npmCommand\": [\"mise\", \"exec\", \"node@20\", \"--\", \"npm\"]\n}\n```\n\n`npmCommand` is used for all npm package-manager operations, including installs, uninstalls, and dependency installs inside git packages. User-scoped npm packages install under `~/.pi/agent/npm/`; project-scoped npm packages install under `.pi/npm/`. Use argv-style entries exactly as the process should be launched. When `npmCommand` is configured, git package dependency installs use plain `install` to avoid npm-specific flags in wrappers or alternate package managers.\n\n### Sessions\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `sessionDir` | string | - | Directory where session files are stored. Accepts absolute or relative paths, plus `~`. |\n\n```json\n{ \"sessionDir\": \".pi/sessions\" }\n```\n\nWhen multiple sources specify a session directory, precedence is `--session-dir`, `PI_CODING_AGENT_SESSION_DIR`, then `sessionDir` in settings.json.\n\n### Model Cycling\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `enabledModels` | string[] | - | Model patterns for Ctrl+P cycling (same format as `--models` CLI flag) |\n\n```json\n{\n \"enabledModels\": [\"claude-*\", \"gpt-4o\", \"gemini-2*\"]\n}\n```\n\n### Markdown\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `markdown.codeBlockIndent` | string | `\" \"` | Indentation for code blocks |\n\n### Resources\n\nThese settings define where to load extensions, skills, prompts, and themes from.\n\nPaths in `~/.pi/agent/settings.json` resolve relative to `~/.pi/agent`. Paths in `.pi/settings.json` resolve relative to `.pi`. Absolute paths and `~` are supported.\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| `packages` | array | `[]` | npm/git packages to load resources from |\n| `extensions` | string[] | `[]` | Local extension file paths or directories |\n| `skills` | string[] | `[]` | Local skill file paths or directories |\n| `prompts` | string[] | `[]` | Local prompt template paths or directories |\n| `themes` | string[] | `[]` | Local theme file paths or directories |\n| `enableSkillCommands` | boolean | `true` | Register skills as `/skill:name` commands |\n\nArrays support glob patterns and exclusions. Use `!pattern` to exclude. Use `+path` to force-include an exact path and `-path` to force-exclude an exact path.\n\n#### packages\n\nString form loads all resources from a package:\n\n```json\n{\n \"packages\": [\"pi-skills\", \"@org/my-extension\"]\n}\n```\n\nObject form filters which resources to load:\n\n```json\n{\n \"packages\": [\n {\n \"source\": \"pi-skills\",\n \"skills\": [\"brave-search\", \"transcribe\"],\n \"extensions\": []\n }\n ]\n}\n```\n\nSee [packages.md](packages.md) for package management details.\n\n## Example\n\n```json\n{\n \"defaultProvider\": \"anthropic\",\n \"defaultModel\": \"claude-sonnet-4-20250514\",\n \"defaultThinkingLevel\": \"medium\",\n \"theme\": \"dark\",\n \"compaction\": {\n \"enabled\": true,\n \"reserveTokens\": 16384,\n \"keepRecentTokens\": 20000\n },\n \"retry\": {\n \"enabled\": true,\n \"maxRetries\": 3\n },\n \"enabledModels\": [\"claude-*\", \"gpt-4o\"],\n \"warnings\": {\n \"anthropicExtraUsage\": true\n },\n \"packages\": [\"pi-skills\"]\n}\n```\n\n## Project Overrides\n\nProject settings (`.pi/settings.json`) override global settings. Nested objects are merged:\n\n```json\n// ~/.pi/agent/settings.json (global)\n{\n \"theme\": \"dark\",\n \"compaction\": { \"enabled\": true, \"reserveTokens\": 16384 }\n}\n\n// .pi/settings.json (project)\n{\n \"compaction\": { \"reserveTokens\": 8192 }\n}\n\n// Result\n{\n \"theme\": \"dark\",\n \"compaction\": { \"enabled\": true, \"reserveTokens\": 8192 }\n}\n```\n"}],"isError":false,"timestamp":1781615174788}}
|
||
{"type":"message","id":"f58fffaa","parentId":"d96fb622","timestamp":"2026-06-16T13:06:30.173Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我有了足够的信息来解释 sproutclaw 的上下文工程实现。让我查看一下 docs/index.md 和 docs/usage.md 来了解更多关于上下文构建的细节。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_3dc76b32d4b84acaa600fcba","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/docs/index.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":2726,"output":77,"cacheRead":111296,"cacheWrite":0,"totalTokens":114099,"cost":{"input":0.0010904,"output":0.000154,"cacheRead":0.00890368,"cacheWrite":0,"total":0.01014808}},"stopReason":"toolUse","timestamp":1781615174791,"responseId":"13cc3c8fb8414efba2e00c4cbf0188c4"}}
|
||
{"type":"message","id":"0fe5a3fa","parentId":"f58fffaa","timestamp":"2026-06-16T13:06:30.175Z","message":{"role":"toolResult","toolCallId":"call_3dc76b32d4b84acaa600fcba","toolName":"read","content":[{"type":"text","text":"# Pi Documentation\n\nPi is a minimal terminal coding harness. It is designed to stay small at the core while being extended through TypeScript extensions, skills, prompt templates, themes, and pi packages.\n\n## Quick start\n\nInstall Pi with npm:\n\n```bash\nnpm install -g --ignore-scripts @earendil-works/pi-coding-agent\n```\n\n`--ignore-scripts` disables dependency lifecycle scripts during install. Pi does not require install scripts for normal npm installs.\n\nOn Linux or macOS, you can also use the installer:\n\n```bash\ncurl -fsSL https://pi.dev/install.sh | sh\n```\n\nTo uninstall pi itself, use npm for curl and npm installs:\n\n```bash\nnpm uninstall -g @earendil-works/pi-coding-agent\n```\n\nFor pnpm, Yarn, or Bun installs, use the matching global remove command: `pnpm remove -g @earendil-works/pi-coding-agent`, `yarn global remove @earendil-works/pi-coding-agent`, or `bun uninstall -g @earendil-works/pi-coding-agent`.\n\nThen run it in a project directory:\n\n```bash\npi\n```\n\nAuthenticate with `/login` for subscription providers, or set an API key such as `ANTHROPIC_API_KEY` before starting pi.\n\nFor the full first-run flow, see [Quickstart](quickstart.md).\n\n## Start here\n\n- [Quickstart](quickstart.md) - install, authenticate, and run a first session.\n- [Using Pi](usage.md) - interactive mode, slash commands, context files, and CLI reference.\n- [Providers](providers.md) - subscription and API-key setup for built-in providers.\n- [Settings](settings.md) - global and project settings.\n- [Keybindings](keybindings.md) - default shortcuts and custom keybindings.\n- [Sessions](sessions.md) - session management, branching, and tree navigation.\n- [Compaction](compaction.md) - context compaction and branch summarization.\n\n## Customization\n\n- [Extensions](extensions.md) - TypeScript modules for tools, commands, events, and custom UI.\n- [Skills](skills.md) - Agent Skills for reusable on-demand capabilities.\n- [Prompt templates](prompt-templates.md) - reusable prompts that expand from slash commands.\n- [Themes](themes.md) - built-in and custom terminal themes.\n- [Pi packages](packages.md) - bundle and share extensions, skills, prompts, and themes.\n- [Custom models](models.md) - add model entries for supported provider APIs.\n- [Custom providers](custom-provider.md) - implement custom APIs and OAuth flows.\n\n## Programmatic usage\n\n- [SDK](sdk.md) - embed pi in Node.js applications.\n- [RPC mode](rpc.md) - integrate over stdin/stdout JSONL.\n- [JSON event stream mode](json.md) - print mode with structured events.\n- [TUI components](tui.md) - build custom terminal UI for extensions.\n\n## Reference\n\n- [Session format](session-format.md) - JSONL session file format, entry types, and SessionManager API.\n\n## Platform setup\n\n- [Windows](windows.md)\n- [Termux on Android](termux.md)\n- [tmux](tmux.md)\n- [Terminal setup](terminal-setup.md)\n- [Shell aliases](shell-aliases.md)\n\n## Development\n\n- [Development](development.md) - local setup, project structure, and debugging.\n"}],"isError":false,"timestamp":1781615190175}}
|
||
{"type":"message","id":"0e2f62eb","parentId":"0fe5a3fa","timestamp":"2026-06-16T13:06:32.028Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 compaction.md 来了解上下文压缩的细节。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_68b617ab4fbb4afa8fca97d3","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/coding-agent/docs/compaction.md","limit":200}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":839,"output":67,"cacheRead":113984,"cacheWrite":0,"totalTokens":114890,"cost":{"input":0.0003356,"output":0.000134,"cacheRead":0.00911872,"cacheWrite":0,"total":0.009588320000000001}},"stopReason":"toolUse","timestamp":1781615190178,"responseId":"de5633dc2585427282da68e4c63410ce"}}
|
||
{"type":"message","id":"a11b656b","parentId":"0e2f62eb","timestamp":"2026-06-16T13:06:32.031Z","message":{"role":"toolResult","toolCallId":"call_68b617ab4fbb4afa8fca97d3","toolName":"read","content":[{"type":"text","text":"# Compaction & Branch Summarization\n\nLLMs have limited context windows. When conversations grow too long, pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization.\n\n**Source files** ([pi-mono](https://github.com/earendil-works/pi-mono)):\n- [`packages/coding-agent/src/core/compaction/compaction.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) - Auto-compaction logic\n- [`packages/coding-agent/src/core/compaction/branch-summarization.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/branch-summarization.ts) - Branch summarization\n- [`packages/coding-agent/src/core/compaction/utils.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/utils.ts) - Shared utilities (file tracking, serialization)\n- [`packages/coding-agent/src/core/session-manager.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/session-manager.ts) - Entry types (`CompactionEntry`, `BranchSummaryEntry`)\n- [`packages/coding-agent/src/core/extensions/types.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/extensions/types.ts) - Extension event types\n\nFor TypeScript definitions in your project, inspect `node_modules/@earendil-works/pi-coding-agent/dist/`.\n\n## Overview\n\nPi has two summarization mechanisms:\n\n| Mechanism | Trigger | Purpose |\n|-----------|---------|---------|\n| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |\n| Branch summarization | `/tree` navigation | Preserve context when switching branches |\n\nBoth use the same structured summary format and track file operations cumulatively.\n\n## Compaction\n\n### When It Triggers\n\nAuto-compaction triggers when:\n\n```\ncontextTokens > contextWindow - reserveTokens\n```\n\nBy default, `reserveTokens` is 16384 tokens (configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`). This leaves room for the LLM's response.\n\nYou can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary.\n\n### How It Works\n\n1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k, configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`) is reached\n2. **Extract messages**: Collect messages from the previous kept boundary (or session start) up to the cut point\n3. **Generate summary**: Call LLM to summarize with structured format, passing the previous summary as iterative context when present\n4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId`\n5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards\n\n```\nBefore compaction:\n\n entry: 0 1 2 3 4 5 6 7 8 9\n ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐\n │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│\n └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘\n └────────┬───────┘ └──────────────┬──────────────┘\n messagesToSummarize kept messages\n ↑\n firstKeptEntryId (entry 4)\n\nAfter compaction (new entry appended):\n\n entry: 0 1 2 3 4 5 6 7 8 9 10\n ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐\n │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │\n └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘\n └──────────┬──────┘ └──────────────────────┬───────────────────┘\n not sent to LLM sent to LLM\n ↑\n starts from firstKeptEntryId\n\nWhat the LLM sees:\n\n ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐\n │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │\n └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘\n ↑ ↑ └─────────────────┬────────────────┘\n prompt from cmp messages from firstKeptEntryId\n```\n\nOn repeated compactions, the summarized span starts at the previous compaction's kept boundary (`firstKeptEntryId`), not at the compaction entry itself, falling back to the entry after the previous compaction if that kept entry cannot be found in the path. This preserves messages that survived the earlier compaction by including them in the next summarization pass as well. Pi also recalculates `tokensBefore` from the rebuilt session context before writing the new `CompactionEntry`, so the token count reflects the actual pre-compaction context being replaced.\n\n### Split Turns\n\nA \"turn\" starts with a user message and includes all assistant responses and tool calls until the next user message. Normally, compaction cuts at turn boundaries.\n\nWhen a single turn exceeds `keepRecentTokens`, the cut point lands mid-turn at an assistant message. This is a \"split turn\":\n\n```\nSplit turn (one huge turn exceeds budget):\n\n entry: 0 1 2 3 4 5 6 7 8\n ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐\n │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │\n └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘\n ↑ ↑\n turnStartIndex = 1 firstKeptEntryId = 7\n │ │\n └──── turnPrefixMessages (1-6) ───────┘\n └── kept (7-8)\n\n isSplitTurn = true\n messagesToSummarize = [] (no complete turns before)\n turnPrefixMessages = [usr, ass, tool, ass, tool, tool]\n```\n\nFor split turns, pi generates two summaries and merges them:\n1. **History summary**: Previous context (if any)\n2. **Turn prefix summary**: The early part of the split turn\n\n### Cut Point Rules\n\nValid cut points are:\n- User messages\n- Assistant messages\n- BashExecution messages\n- Custom messages (custom_message, branch_summary)\n\nNever cut at tool results (they must stay with their tool call).\n\n### CompactionEntry Structure\n\nDefined in [`session-manager.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/session-manager.ts):\n\n```typescript\ninterface CompactionEntry<T = unknown> {\n type: \"compaction\";\n id: string;\n parentId: string;\n timestamp: number;\n summary: string;\n firstKeptEntryId: string;\n tokensBefore: number;\n fromHook?: boolean; // true if provided by extension (legacy field name)\n details?: T; // implementation-specific data\n}\n\n// Default compaction uses this for details (from compaction.ts):\ninterface CompactionDetails {\n readFiles: string[];\n modifiedFiles: string[];\n}\n```\n\nExtensions can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom extension implementations can use their own structure.\n\nSee [`prepareCompaction()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) and [`compact()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) for the implementation.\n\n## Branch Summarization\n\n### When It Triggers\n\nWhen you use `/tree` to navigate to a different branch, pi offers to summarize the work you're leaving. This injects context from the left branch into the new branch.\n\n### How It Works\n\n1. **Find common ancestor**: Deepest node shared by old and new positions\n2. **Collect entries**: Walk from old leaf back to common ancestor\n3. **Prepare with budget**: Include messages up to token budget (newest first)\n4. **Generate summary**: Call LLM with structured format\n5. **Append entry**: Save `BranchSummaryEntry` at navigation point\n\n```\nTree before navigation:\n\n ┌─ B ─ C ─ D (old leaf, being abandoned)\n A ───┤\n └─ E ─ F (target)\n\nCommon ancestor: A\nEntries to summarize: B, C, D\n\nAfter navigation with summary:\n\n ┌─ B ─ C ─ D ─ [summary of B,C,D]\n A ───┤\n └─ E ─ F (new leaf)\n```\n\n### Cumulative File Tracking\n\nBoth compaction and branch summarization track files cumulatively. When generating a summary, pi extracts file operations from:\n- Tool calls in the messages being summarized\n- Previous compaction or branch summary `details` (if any)\n\nThis means file tracking accumulates across multiple compactions or nested branch summaries, preserving the full history of read and modified files.\n\n### BranchSummaryEntry Structure\n\nDefined in [`session-manager.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/session-manager.ts):\n\n```typescript\ninterface BranchSummaryEntry<T = unknown> {\n type: \"branch_summary\";\n id: string;\n parentId: string;\n timestamp: number;\n summary: string;\n fromId: string; // Entry we navigated from\n fromHook?: boolean; // true if provided by extension (legacy field name)\n details?: T; // implementation-specific data\n}\n\n[195 more lines in file. Use offset=201 to continue.]"}],"isError":false,"timestamp":1781615192030}}
|
||
{"type":"message","id":"1234dae2","parentId":"a11b656b","timestamp":"2026-06-16T13:07:07.136Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我已经收集了足够的信息来为用户提供关于 sproutclaw 上下文工程的完整解释。让我总结一下:\n\n1. **上下文工程**(Context Engineering)是什么\n2. sproutclaw 项目中的上下文工程实现\n3. 每次发送给大模型的系统提示词会携带哪些信息\n4. 相关的配置和管理机制\n\n让我组织一个详细的回答。","thinkingSignature":"reasoning_content"},{"type":"text","text":"我已经深入分析了 sproutclaw 项目的源码。让我为你详细解释这个项目的上下文工程实现。\n\n---\n\n# SproutClaw 上下文工程详解\n\n## 一、什么是上下文工程\n\n**上下文工程**(Context Engineering)是 AI Agent 设计中的核心概念,指的是如何**构建和管理每次发送给大模型的完整上下文**(system prompt + 历史消息 + 工具定义 + 项目上下文),以最大化模型的任务完成能力。\n\n---\n\n## 二、SproutClaw 上下文工程的核心架构\n\n### 2.1 系统提示词构建流程\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ buildSystemPrompt() │\n├─────────────────────────────────────────────────────────────┤\n│ 1. 自定义系统提示词(来自 SYSTEM.md 或 SDK 配置) │\n│ 2. 工具列表 + 每个工具的单行摘要 │\n│ 3. 指导原则(guidelines) │\n│ 4. 项目上下文文件(AGENTS.md / CLAUDE.md) │\n│ 5. 技能列表(Skills XML 格式) │\n│ 6. 当前日期 + 工作目录 │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### 2.2 核心代码:system-prompt.ts\n\n```typescript\n// 核心构建函数\nexport function buildSystemPrompt(options: BuildSystemPromptOptions): string {\n // 1. 自定义系统提示词(来自 SYSTEM.md)\n if (customPrompt) {\n prompt = customPrompt;\n }\n \n // 2. 项目上下文文件(AGENTS.md / CLAUDE.md)\n // 从当前目录向上遍历,找到所有 AGENTS.md 文件\n for (const { path, content } of contextFiles) {\n prompt += `<project_instructions path=\"${path}\">${content}</project_instructions>`;\n }\n \n // 3. 技能列表(XML 格式)\n prompt += formatSkillsForPrompt(skills);\n \n // 4. 日期和工作目录\n prompt += `\\nCurrent date: ${date}`;\n prompt += `\\nCurrent working directory: ${cwd}`;\n \n return prompt;\n}\n```\n\n---\n\n## 三、每次发送给大模型的完整上下文\n\n### 3.1 System Prompt(系统提示词)\n\n系统提示词包含以下**6个核心部分**:\n\n#### Part 1:角色定义 + 工具列表\n```\nYou are an expert coding assistant operating inside pi, a coding agent harness.\n\nAvailable tools:\n- read: Read the contents of a file...\n- bash: Execute a bash command...\n- edit: Make precise file edits...\n- write: Write content to a file...\n```\n\n#### Part 2:指导原则(Guidelines)\n```\nGuidelines:\n- Be concise in your responses\n- Show file paths clearly when working with files\n- Use grep/find/ls tools over bash for file exploration\n- [扩展注册的额外 guidelines]\n```\n\n#### Part 3:Pi 文档指引(当用户询问 Pi 自身功能时)\n```\nPi documentation (read only when the user asks about pi itself):\n- Main documentation: /path/to/README.md\n- Additional docs: /path/to/docs\n- Examples: /path/to/examples\n```\n\n#### Part 4:项目上下文文件(Project Context)\n```xml\n<project_context>\n <project_instructions path=\"/path/to/AGENTS.md\">\n ## 开发规范\n - 代码风格要求...\n - Git 提交规范...\n - 测试要求...\n </project_instructions>\n \n <project_instructions path=\"/path/to/.pi/agent/AGENTS.md\">\n ## 项目特定配置\n - 部署环境...\n - 数据库配置...\n </project_instructions>\n</project_context>\n```\n\n#### Part 5:技能列表(Skills XML)\n```xml\n<available_skills>\n <skill>\n <name>frontend-design</name>\n <description>Guidance for distinctive visual design...</description>\n <location>/path/to/SKILL.md</location>\n </skill>\n <skill>\n <name>quark-sign-skill</name>\n <description>夸克网盘自动签到...</description>\n <location>/path/to/SKILL.md</location>\n </skill>\n</available_skills>\n```\n\n#### Part 6:时间戳 + 工作目录\n```\nCurrent date: 2026-06-16\nCurrent working directory: /shumengya/project/agent/sproutclaw\n```\n\n---\n\n### 3.2 会话历史消息(Session Context)\n\n会话历史是通过 **SessionManager** 管理的,存储在 `.jsonl` 文件中:\n\n```typescript\n// 消息类型\ntype SessionEntry = \n | SessionMessageEntry // 普通消息(user/assistant/toolResult)\n | ThinkingLevelChangeEntry // 思考级别变更\n | ModelChangeEntry // 模型切换\n | CompactionEntry // 上下文压缩摘要\n | BranchSummaryEntry // 分支摘要\n | CustomEntry // 扩展自定义数据(不参与 LLM)\n | CustomMessageEntry // 扩展消息(参与 LLM)\n | LabelEntry // 用户标签\n | SessionInfoEntry // 会话元数据\n```\n\n**构建发送给 LLM 的消息列表**:\n```typescript\nfunction buildSessionContext(entries, leafId, byId): SessionContext {\n // 1. 从叶子节点回溯到根节点,构建路径\n const path = walkFromLeafToRoot(leafId);\n \n // 2. 如果存在压缩摘要,先发送摘要\n if (compaction) {\n messages.push(createCompactionSummaryMessage(compaction.summary));\n }\n \n // 3. 添加保留的消息(从 firstKeptEntryId 开始)\n messages.push(...keptMessages);\n \n return { messages, thinkingLevel, model };\n}\n```\n\n---\n\n## 四、上下文管理的五大机制\n\n### 4.1 项目上下文发现(Context Discovery)\n\n```typescript\n// resource-loader.ts 中的 loadProjectContextFiles()\nfunction loadProjectContextFiles(options) {\n const contextFiles = [];\n \n // 1. 全局上下文:~/.pi/agent/AGENTS.md\n const globalContext = loadContextFileFromDir(agentDir);\n \n // 2. 项目上下文:从 cwd 向上遍历所有目录\n let currentDir = cwd;\n while (currentDir !== root) {\n const contextFile = loadContextFileFromDir(currentDir);\n if (contextFile) {\n ancestorContextFiles.unshift(contextFile); // 保持从根到当前的顺序\n }\n currentDir = parent(currentDir);\n }\n \n return contextFiles;\n}\n```\n\n**发现顺序**:\n1. `~/.pi/agent/AGENTS.md`(全局)\n2. `/project/AGENTS.md`(项目根)\n3. `/project/src/AGENTS.md`(子目录)\n4. `/project/src/components/AGENTS.md`(更深层)\n\n### 4.2 技能加载(Skills Loading)\n\n```typescript\n// skills.ts 中的加载逻辑\nfunction loadSkills(options) {\n // 1. 用户技能:~/.pi/agent/skills/\n // 2. 项目技能:.pi/skills/\n // 3. 显式路径:settings.json 中的 skills 数组\n // 4. 包技能:npm packages 的 skills/ 目录\n \n // 发现规则:\n // - 根目录的 .md 文件作为单独技能\n // - 包含 SKILL.md 的目录作为技能目录\n}\n```\n\n**技能在系统提示词中的呈现**:\n```xml\n<available_skills>\n <skill>\n <name>skill-name</name>\n <description>技能描述...</description>\n <location>/absolute/path/to/SKILL.md</location>\n </skill>\n</available_skills>\n```\n\n### 4.3 提示模板(Prompt Templates)\n\n```typescript\n// prompt-templates.ts\n// 加载位置\n// 1. ~/.pi/agent/prompts/*.md\n// 2. .pi/prompts/*.md\n// 3. settings.json 中的 prompts 数组\n\n// 模板格式\n---\ndescription: Review staged git changes\nargument-hint: \"<PR-URL>\"\n---\nReview the staged changes (`git diff --cached`). Focus on:\n- Bugs and logic errors\n- Security issues\n```\n\n**使用方式**:\n```\n/review # 展开模板\n/pr \"修复登录bug\" # 带参数展开\n```\n\n### 4.4 上下文压缩(Compaction)\n\n当上下文超过阈值时,自动压缩旧内容:\n\n```typescript\n// compaction.ts\nfunction shouldCompact(contextTokens, contextWindow, settings) {\n return contextTokens > contextWindow - settings.reserveTokens;\n}\n\n// 压缩流程:\n// 1. 从最新消息向前回溯\n// 2. 找到 keepRecentTokens(默认 20k tokens)的位置作为切点\n// 3. 调用 LLM 生成结构化摘要\n// 4. 保存 CompactionEntry,保留 firstKeptEntryId 之后的消息\n```\n\n**压缩后的上下文结构**:\n```\nSystem Prompt + Compaction Summary + Recent Messages\n```\n\n### 4.5 分支摘要(Branch Summarization)\n\n当在会话树中导航时,为被放弃的分支生成摘要:\n\n```typescript\n// 当用户使用 /tree 导航到不同分支\n// 1. 找到公共祖先\n// 2. 收集要摘要的条目\n// 3. 生成摘要\n// 4. 保存 BranchSummaryEntry\n```\n\n---\n\n## 五、上下文工程的核心设计原则\n\n### 5.1 渐进式披露(Progressive Disclosure)\n\n```\n始终在上下文中: 按需加载:\n├── 技能名称 + 描述 └── 完整 SKILL.md 内容\n├── 工具名称 + 单行摘要 └── 完整工具文档\n└── 项目规范摘要 └── 完整 AGENTS.md\n```\n\n### 5.2 分层上下文管理\n\n```\n┌─────────────────────────────────────────────┐\n│ Layer 1: System Prompt(始终不变) │\n│ - 角色定义 + 工具列表 + 指导原则 │\n├─────────────────────────────────────────────┤\n│ Layer 2: Project Context(项目级) │\n│ - AGENTS.md 内容 │\n│ - Skills XML 列表 │\n├─────────────────────────────────────────────┤\n│ Layer 3: Session Context(会话级) │\n│ - 历史消息(可能被压缩) │\n│ - 当前工具调用结果 │\n├─────────────────────────────────────────────┤\n│ Layer 4: Turn Context(轮次级) │\n│ - 当前用户输入 │\n│ - 扩展注入的临时上下文 │\n└─────────────────────────────────────────────┘\n```\n\n### 5.3 扩展点设计\n\n扩展可以通过以下方式注入上下文:\n\n```typescript\n// 1. 注册工具\nextension.registerTool({\n name: \"my_tool\",\n promptSnippet: \"单行描述\", // 出现在系统提示词\n promptGuidelines: [\"额外指导原则\"], // 出现在系统提示词\n});\n\n// 2. 注册事件处理器\nextension.on(\"before_agent_start\", async (event) => {\n // 修改系统提示词\n return { systemPrompt: modifiedPrompt };\n \n // 注入自定义消息\n return { messages: [{ role: \"custom\", content: \"...\" }] };\n});\n\n// 3. 注册资源发现\nextension.on(\"resources_discover\", async () => {\n return {\n skillPaths: [\"path/to/skills\"],\n promptPaths: [\"path/to/prompts\"],\n };\n});\n```\n\n---\n\n## 六、配置文件体系\n\n### 6.1 配置文件位置\n\n| 文件 | 作用域 | 优先级 |\n|------|--------|--------|\n| `~/.pi/agent/settings.json` | 全局 | 低 |\n| `.pi/settings.json` | 项目 | 高 |\n| `~/.pi/agent/AGENTS.md` | 全局上下文 | - |\n| `.pi/AGENTS.md` | 项目上下文 | - |\n| `AGENTS.md` | 目录上下文 | - |\n\n### 6.2 上下文相关配置\n\n```json\n{\n // 技能和模板\n \"skills\": [\"path/to/skills\"],\n \"prompts\": [\"path/to/prompts\"],\n \"packages\": [\"pi-skills\"],\n \n // 压缩配置\n \"compaction\": {\n \"enabled\": true,\n \"reserveTokens\": 16384,\n \"keepRecentTokens\": 20000\n },\n \n // 分支摘要配置\n \"branchSummary\": {\n \"reserveTokens\": 16384,\n \"skipPrompt\": false\n }\n}\n```\n\n---\n\n## 七、完整工作流程图\n\n```\n用户输入: \"帮我修复这个bug\"\n ↓\n┌──────────────────────────────────────────────────────────┐\n│ 1. 扩展 input 事件拦截 │\n│ └── 可能修改输入文本 │\n├──────────────────────────────────────────────────────────┤\n│ 2. 技能命令展开 (/skill:name) │\n│ └── 读取 SKILL.md 内容 │\n├──────────────────────────────────────────────────────────┤\n│ 3. 提示模板展开 (/template) │\n│ └── 替换参数占位符 │\n├──────────────────────────────────────────────────────────┤\n│ 4. before_agent_start 事件 │\n│ └── 扩展可以修改系统提示词 │\n├──────────────────────────────────────────────────────────┤\n│ 5. 构建完整上下文 │\n│ ├── System Prompt(6个部分) │\n│ ├── 历史消息(可能包含压缩摘要) │\n│ └── 当前用户消息 + 附件 │\n├──────────────────────────────────────────────────────────┤\n│ 6. 发送给 LLM │\n└──────────────────────────────────────────────────────────┘\n ↓\n┌──────────────────────────────────────────────────────────┐\n│ 7. LLM 返回响应 │\n│ ├── 文本回复 │\n│ └── 工具调用(read/bash/edit/write) │\n├──────────────────────────────────────────────────────────┤\n│ 8. 执行工具 + tool_result 事件 │\n│ └── 扩展可以拦截/修改结果 │\n├──────────────────────────────────────────────────────────┤\n│ 9. 检查是否需要压缩 │\n│ └── 上下文超阈值 → 自动生成摘要 │\n├──────────────────────────────────────────────────────────┤\n│ 10. 持久化到 Session │\n│ └── JSONL 文件追加 │\n└──────────────────────────────────────────────────────────┘\n```\n\n---\n\n## 八、总结\n\nSproutClaw 的上下文工程实现了一套**分层、可扩展、渐进式**的上下文管理系统:\n\n| 机制 | 作用 | 触发时机 |\n|------|------|---------|\n| **Project Context** | 加载 AGENTS.md 等项目规范 | 启动时 |\n| **Skills Discovery** | 发现并注册可用技能 | 启动时 + 重载时 |\n| **Prompt Templates** | 提供可复用的提示模板 | 用户输入 `/` 时 |\n| **Context Compaction** | 压缩过长的会话历史 | 上下文超阈值时 |\n| **Branch Summarization** | 为放弃的分支生成摘要 | `/tree` 导航时 |\n| **Extension Hooks** | 允许扩展注入/修改上下文 | 各个生命周期事件 |\n\n核心设计理念是:**始终在上下文中保持最少但足够的信息,让大模型能够高效地完成任务**。"}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":2417,"output":3738,"cacheRead":114816,"cacheWrite":0,"totalTokens":120971,"cost":{"input":0.0009668000000000001,"output":0.007476,"cacheRead":0.00918528,"cacheWrite":0,"total":0.01762808}},"stopReason":"stop","timestamp":1781615192035,"responseId":"592b32e6b1df4862af3760d02fc37323"}}
|
||
{"type":"message","id":"8948bb89","parentId":"1234dae2","timestamp":"2026-06-16T14:10:18.675Z","message":{"role":"user","content":[{"type":"text","text":"/model"}],"timestamp":1781619018674}}
|
||
{"type":"message","id":"9d149b51","parentId":"8948bb89","timestamp":"2026-06-16T14:10:21.544Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"aborted","timestamp":1781619018677,"errorMessage":"Request was aborted."}}
|
||
{"type":"message","id":"b7c8d143","parentId":"9d149b51","timestamp":"2026-06-17T01:42:36.539Z","message":{"role":"user","content":[{"type":"text","text":"sproutclaw 的工作流程是react吗 是否有plan-execute模式"}],"timestamp":1781660556539}}
|
||
{"type":"message","id":"a115d6ab","parentId":"b7c8d143","timestamp":"2026-06-17T01:42:45.995Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户问 sproutclaw 的工作流程是否是 ReAct 模式,以及是否有 plan-execute 模式。\n\n让我先理解这两个概念:\n\n1. **ReAct (Reasoning + Acting)**:一种交替进行推理和行动的模式。模型先思考(Reason),然后执行动作(Act),观察结果(Observe),再思考,如此循环。\n\n2. **Plan-Execute 模式**:先制定完整计划,然后逐步执行。通常是先规划出所有步骤,再按顺序执行。\n\n我需要查看 sproutclaw 的核心工作流程代码,特别是 agent 的执行逻辑。","thinkingSignature":"reasoning_content"},{"type":"text","text":"让我查看一下 sproutclaw 的核心执行逻辑,确认它的工作模式。"},{"type":"toolCall","id":"call_d646f3916666422a90b12244","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw/packages -type f -name \"*.ts\" | grep -v node_modules | grep -E \"(agent|core|loop|run)\" | grep -v test | grep -v dist | head -20"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":119975,"output":224,"cacheRead":1024,"cacheWrite":0,"totalTokens":121223,"cost":{"input":0.047990000000000005,"output":0.000448,"cacheRead":0.00008192,"cacheWrite":0,"total":0.04851992}},"stopReason":"toolUse","timestamp":1781660556541,"responseId":"ced969cf5e504152b1e982e3dfcba1f5"}}
|
||
{"type":"message","id":"f697315c","parentId":"a115d6ab","timestamp":"2026-06-17T01:42:46.018Z","message":{"role":"toolResult","toolCallId":"call_d646f3916666422a90b12244","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/packages/tui/src/tui.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/kill-ring.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/undo-stack.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/editor-component.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/stdin-buffer.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/autocomplete.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/fuzzy.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/terminal-image.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/terminal.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/utils.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/keybindings.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/keys.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/input.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/select-list.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/cancellable-loader.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/image.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/markdown.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/box.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/spacer.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/editor.ts\n"}],"isError":false,"timestamp":1781660566018}}
|
||
{"type":"message","id":"24180563","parentId":"f697315c","timestamp":"2026-06-17T01:42:48.641Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我搜索 agent 相关的核心文件,特别是 agent-core 或 agent 主循环相关的代码。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_3661264024544a008beefe30","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw/packages -type f -name \"*.ts\" | grep -v node_modules | grep -v test | grep -v dist | grep -v examples | xargs grep -l \"prompt\\|continue\\|loop\\|while.*streaming\\|agent.*start\\|agent.*end\" 2>/dev/null | head -15"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":683,"output":126,"cacheRead":120960,"cacheWrite":0,"totalTokens":121769,"cost":{"input":0.0002732,"output":0.000252,"cacheRead":0.009676800000000001,"cacheWrite":0,"total":0.010202000000000001}},"stopReason":"toolUse","timestamp":1781660566020,"responseId":"e5a719d822834a05b1ef0aa22aa0c825"}}
|
||
{"type":"message","id":"0c015191","parentId":"24180563","timestamp":"2026-06-17T01:42:48.669Z","message":{"role":"toolResult","toolCallId":"call_3661264024544a008beefe30","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/packages/tui/src/tui.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/autocomplete.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/terminal-image.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/utils.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/keybindings.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/keys.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/input.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/select-list.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/markdown.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/editor.ts\n/shumengya/project/agent/sproutclaw/packages/tui/src/components/settings-list.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/migrations.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/cli/args.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/cli/initial-message.ts\n/shumengya/project/agent/sproutclaw/packages/coding-agent/src/cli/file-processor.ts\n"}],"isError":false,"timestamp":1781660568669}}
|
||
{"type":"message","id":"fed29aba","parentId":"0c015191","timestamp":"2026-06-17T01:42:51.276Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 agent-core 包,这应该是核心的 agent 循环实现。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_785d4b689a814a79b91ecd01","name":"bash","arguments":{"command":"find /shumengya/project/agent/sproutclaw/packages -type d -name \"agent-core\" -o -name \"agent\" | grep -v node_modules"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":492,"output":77,"cacheRead":121600,"cacheWrite":0,"totalTokens":122169,"cost":{"input":0.0001968,"output":0.000154,"cacheRead":0.009728,"cacheWrite":0,"total":0.0100788}},"stopReason":"toolUse","timestamp":1781660568672,"responseId":"5e7686f26b75411e9f5b31a58fd1c121"}}
|
||
{"type":"message","id":"904d9d52","parentId":"fed29aba","timestamp":"2026-06-17T01:42:51.298Z","message":{"role":"toolResult","toolCallId":"call_785d4b689a814a79b91ecd01","toolName":"bash","content":[{"type":"text","text":"/shumengya/project/agent/sproutclaw/packages/agent\n"}],"isError":false,"timestamp":1781660571298}}
|
||
{"type":"message","id":"3532409e","parentId":"904d9d52","timestamp":"2026-06-17T01:42:52.858Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 agent 包的结构。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_0fc78d1fd5d843ef9d02697e","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/sproutclaw/packages/agent/src/"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":148,"output":52,"cacheRead":122048,"cacheWrite":0,"totalTokens":122248,"cost":{"input":0.0000592,"output":0.000104,"cacheRead":0.009763840000000001,"cacheWrite":0,"total":0.009927040000000002}},"stopReason":"toolUse","timestamp":1781660571300,"responseId":"91f2777942d54827a08e4e7e6adcf2ca"}}
|
||
{"type":"message","id":"b8f05140","parentId":"3532409e","timestamp":"2026-06-17T01:42:52.873Z","message":{"role":"toolResult","toolCallId":"call_0fc78d1fd5d843ef9d02697e","toolName":"bash","content":[{"type":"text","text":"总计 88\ndrwxr-xr-x 3 root root 4096 5月22日 20:05 .\ndrwxr-xr-x 7 root root 4096 5月22日 20:05 ..\n-rw-r--r-- 1 root root 19877 5月22日 20:05 agent-loop.ts\n-rw-r--r-- 1 root root 17134 5月22日 20:05 agent.ts\ndrwxr-xr-x 6 root root 4096 6月10日 15:12 harness\n-rw-r--r-- 1 root root 1298 5月22日 20:05 index.ts\n-rw-r--r-- 1 root root 88 5月22日 20:05 node.ts\n-rw-r--r-- 1 root root 10377 5月 8日 19:40 proxy.ts\n-rw-r--r-- 1 root root 16311 5月22日 20:05 types.ts\n"}],"isError":false,"timestamp":1781660572873}}
|
||
{"type":"message","id":"34ca5616","parentId":"b8f05140","timestamp":"2026-06-17T01:42:54.648Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 agent-loop.ts,这应该是核心的 agent 循环实现。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_9ea27de7a904415bba1d6c4c","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/agent/src/agent-loop.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":370,"output":57,"cacheRead":122176,"cacheWrite":0,"totalTokens":122603,"cost":{"input":0.00014800000000000002,"output":0.00011399999999999999,"cacheRead":0.009774080000000001,"cacheWrite":0,"total":0.010036080000000001}},"stopReason":"toolUse","timestamp":1781660572876,"responseId":"4cb4633d49624b5fb86c7fa9319d4a59"}}
|
||
{"type":"message","id":"a6d5bc47","parentId":"34ca5616","timestamp":"2026-06-17T01:42:54.651Z","message":{"role":"toolResult","toolCallId":"call_9ea27de7a904415bba1d6c4c","toolName":"read","content":[{"type":"text","text":"/**\n * Agent loop that works with AgentMessage throughout.\n * Transforms to Message[] only at the LLM call boundary.\n */\n\nimport {\n\ttype AssistantMessage,\n\ttype Context,\n\tEventStream,\n\tstreamSimple,\n\ttype ToolResultMessage,\n\tvalidateToolArguments,\n} from \"@earendil-works/pi-ai\";\nimport type {\n\tAgentContext,\n\tAgentEvent,\n\tAgentLoopConfig,\n\tAgentMessage,\n\tAgentTool,\n\tAgentToolCall,\n\tAgentToolResult,\n\tStreamFn,\n} from \"./types.ts\";\n\nexport type AgentEventSink = (event: AgentEvent) => Promise<void> | void;\n\n/**\n * Start an agent loop with a new prompt message.\n * The prompt is added to the context and events are emitted for it.\n */\nexport function agentLoop(\n\tprompts: AgentMessage[],\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): EventStream<AgentEvent, AgentMessage[]> {\n\tconst stream = createAgentStream();\n\n\tvoid runAgentLoop(\n\t\tprompts,\n\t\tcontext,\n\t\tconfig,\n\t\tasync (event) => {\n\t\t\tstream.push(event);\n\t\t},\n\t\tsignal,\n\t\tstreamFn,\n\t).then((messages) => {\n\t\tstream.end(messages);\n\t});\n\n\treturn stream;\n}\n\n/**\n * Continue an agent loop from the current context without adding a new message.\n * Used for retries - context already has user message or tool results.\n *\n * **Important:** The last message in context must convert to a `user` or `toolResult` message\n * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.\n * This cannot be validated here since `convertToLlm` is only called once per turn.\n */\nexport function agentLoopContinue(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): EventStream<AgentEvent, AgentMessage[]> {\n\tif (context.messages.length === 0) {\n\t\tthrow new Error(\"Cannot continue: no messages in context\");\n\t}\n\n\tif (context.messages[context.messages.length - 1].role === \"assistant\") {\n\t\tthrow new Error(\"Cannot continue from message role: assistant\");\n\t}\n\n\tconst stream = createAgentStream();\n\n\tvoid runAgentLoopContinue(\n\t\tcontext,\n\t\tconfig,\n\t\tasync (event) => {\n\t\t\tstream.push(event);\n\t\t},\n\t\tsignal,\n\t\tstreamFn,\n\t).then((messages) => {\n\t\tstream.end(messages);\n\t});\n\n\treturn stream;\n}\n\nexport async function runAgentLoop(\n\tprompts: AgentMessage[],\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\temit: AgentEventSink,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): Promise<AgentMessage[]> {\n\tconst newMessages: AgentMessage[] = [...prompts];\n\tconst currentContext: AgentContext = {\n\t\t...context,\n\t\tmessages: [...context.messages, ...prompts],\n\t};\n\n\tawait emit({ type: \"agent_start\" });\n\tawait emit({ type: \"turn_start\" });\n\tfor (const prompt of prompts) {\n\t\tawait emit({ type: \"message_start\", message: prompt });\n\t\tawait emit({ type: \"message_end\", message: prompt });\n\t}\n\n\tawait runLoop(currentContext, newMessages, config, signal, emit, streamFn);\n\treturn newMessages;\n}\n\nexport async function runAgentLoopContinue(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\temit: AgentEventSink,\n\tsignal?: AbortSignal,\n\tstreamFn?: StreamFn,\n): Promise<AgentMessage[]> {\n\tif (context.messages.length === 0) {\n\t\tthrow new Error(\"Cannot continue: no messages in context\");\n\t}\n\n\tif (context.messages[context.messages.length - 1].role === \"assistant\") {\n\t\tthrow new Error(\"Cannot continue from message role: assistant\");\n\t}\n\n\tconst newMessages: AgentMessage[] = [];\n\tconst currentContext: AgentContext = { ...context };\n\n\tawait emit({ type: \"agent_start\" });\n\tawait emit({ type: \"turn_start\" });\n\n\tawait runLoop(currentContext, newMessages, config, signal, emit, streamFn);\n\treturn newMessages;\n}\n\nfunction createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {\n\treturn new EventStream<AgentEvent, AgentMessage[]>(\n\t\t(event: AgentEvent) => event.type === \"agent_end\",\n\t\t(event: AgentEvent) => (event.type === \"agent_end\" ? event.messages : []),\n\t);\n}\n\n/**\n * Main loop logic shared by agentLoop and agentLoopContinue.\n */\nasync function runLoop(\n\tinitialContext: AgentContext,\n\tnewMessages: AgentMessage[],\n\tinitialConfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tstreamFn?: StreamFn,\n): Promise<void> {\n\tlet currentContext = initialContext;\n\tlet config = initialConfig;\n\tlet firstTurn = true;\n\t// Check for steering messages at start (user may have typed while waiting)\n\tlet pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];\n\n\t// Outer loop: continues when queued follow-up messages arrive after agent would stop\n\twhile (true) {\n\t\tlet hasMoreToolCalls = true;\n\n\t\t// Inner loop: process tool calls and steering messages\n\t\twhile (hasMoreToolCalls || pendingMessages.length > 0) {\n\t\t\tif (!firstTurn) {\n\t\t\t\tawait emit({ type: \"turn_start\" });\n\t\t\t} else {\n\t\t\t\tfirstTurn = false;\n\t\t\t}\n\n\t\t\t// Process pending messages (inject before next assistant response)\n\t\t\tif (pendingMessages.length > 0) {\n\t\t\t\tfor (const message of pendingMessages) {\n\t\t\t\t\tawait emit({ type: \"message_start\", message });\n\t\t\t\t\tawait emit({ type: \"message_end\", message });\n\t\t\t\t\tcurrentContext.messages.push(message);\n\t\t\t\t\tnewMessages.push(message);\n\t\t\t\t}\n\t\t\t\tpendingMessages = [];\n\t\t\t}\n\n\t\t\t// Stream assistant response\n\t\t\tconst message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);\n\t\t\tnewMessages.push(message);\n\n\t\t\tif (message.stopReason === \"error\" || message.stopReason === \"aborted\") {\n\t\t\t\tawait emit({ type: \"turn_end\", message, toolResults: [] });\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for tool calls\n\t\t\tconst toolCalls = message.content.filter((c) => c.type === \"toolCall\");\n\n\t\t\tconst toolResults: ToolResultMessage[] = [];\n\t\t\thasMoreToolCalls = false;\n\t\t\tif (toolCalls.length > 0) {\n\t\t\t\tconst executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);\n\t\t\t\ttoolResults.push(...executedToolBatch.messages);\n\t\t\t\thasMoreToolCalls = !executedToolBatch.terminate;\n\n\t\t\t\tfor (const result of toolResults) {\n\t\t\t\t\tcurrentContext.messages.push(result);\n\t\t\t\t\tnewMessages.push(result);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tawait emit({ type: \"turn_end\", message, toolResults });\n\n\t\t\tconst nextTurnContext = {\n\t\t\t\tmessage,\n\t\t\t\ttoolResults,\n\t\t\t\tcontext: currentContext,\n\t\t\t\tnewMessages,\n\t\t\t};\n\t\t\tconst nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);\n\t\t\tif (nextTurnSnapshot) {\n\t\t\t\tcurrentContext = nextTurnSnapshot.context ?? currentContext;\n\t\t\t\tconfig = {\n\t\t\t\t\t...config,\n\t\t\t\t\tmodel: nextTurnSnapshot.model ?? config.model,\n\t\t\t\t\treasoning:\n\t\t\t\t\t\tnextTurnSnapshot.thinkingLevel === undefined\n\t\t\t\t\t\t\t? config.reasoning\n\t\t\t\t\t\t\t: nextTurnSnapshot.thinkingLevel === \"off\"\n\t\t\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t\t\t: nextTurnSnapshot.thinkingLevel,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tawait config.shouldStopAfterTurn?.({\n\t\t\t\t\tmessage,\n\t\t\t\t\ttoolResults,\n\t\t\t\t\tcontext: currentContext,\n\t\t\t\t\tnewMessages,\n\t\t\t\t})\n\t\t\t) {\n\t\t\t\tawait emit({ type: \"agent_end\", messages: newMessages });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tpendingMessages = (await config.getSteeringMessages?.()) || [];\n\t\t}\n\n\t\t// Agent would stop here. Check for follow-up messages.\n\t\tconst followUpMessages = (await config.getFollowUpMessages?.()) || [];\n\t\tif (followUpMessages.length > 0) {\n\t\t\t// Set as pending so inner loop processes them\n\t\t\tpendingMessages = followUpMessages;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// No more messages, exit\n\t\tbreak;\n\t}\n\n\tawait emit({ type: \"agent_end\", messages: newMessages });\n}\n\n/**\n * Stream an assistant response from the LLM.\n * This is where AgentMessage[] gets transformed to Message[] for the LLM.\n */\nasync function streamAssistantResponse(\n\tcontext: AgentContext,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n\tstreamFn?: StreamFn,\n): Promise<AssistantMessage> {\n\t// Apply context transform if configured (AgentMessage[] → AgentMessage[])\n\tlet messages = context.messages;\n\tif (config.transformContext) {\n\t\tmessages = await config.transformContext(messages, signal);\n\t}\n\n\t// Convert to LLM-compatible messages (AgentMessage[] → Message[])\n\tconst llmMessages = await config.convertToLlm(messages);\n\n\t// Build LLM context\n\tconst llmContext: Context = {\n\t\tsystemPrompt: context.systemPrompt,\n\t\tmessages: llmMessages,\n\t\ttools: context.tools,\n\t};\n\n\tconst streamFunction = streamFn || streamSimple;\n\n\t// Resolve API key (important for expiring tokens)\n\tconst resolvedApiKey =\n\t\t(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;\n\n\tconst response = await streamFunction(config.model, llmContext, {\n\t\t...config,\n\t\tapiKey: resolvedApiKey,\n\t\tsignal,\n\t});\n\n\tlet partialMessage: AssistantMessage | null = null;\n\tlet addedPartial = false;\n\n\tfor await (const event of response) {\n\t\tswitch (event.type) {\n\t\t\tcase \"start\":\n\t\t\t\tpartialMessage = event.partial;\n\t\t\t\tcontext.messages.push(partialMessage);\n\t\t\t\taddedPartial = true;\n\t\t\t\tawait emit({ type: \"message_start\", message: { ...partialMessage } });\n\t\t\t\tbreak;\n\n\t\t\tcase \"text_start\":\n\t\t\tcase \"text_delta\":\n\t\t\tcase \"text_end\":\n\t\t\tcase \"thinking_start\":\n\t\t\tcase \"thinking_delta\":\n\t\t\tcase \"thinking_end\":\n\t\t\tcase \"toolcall_start\":\n\t\t\tcase \"toolcall_delta\":\n\t\t\tcase \"toolcall_end\":\n\t\t\t\tif (partialMessage) {\n\t\t\t\t\tpartialMessage = event.partial;\n\t\t\t\t\tcontext.messages[context.messages.length - 1] = partialMessage;\n\t\t\t\t\tawait emit({\n\t\t\t\t\t\ttype: \"message_update\",\n\t\t\t\t\t\tassistantMessageEvent: event,\n\t\t\t\t\t\tmessage: { ...partialMessage },\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"done\":\n\t\t\tcase \"error\": {\n\t\t\t\tconst finalMessage = await response.result();\n\t\t\t\tif (addedPartial) {\n\t\t\t\t\tcontext.messages[context.messages.length - 1] = finalMessage;\n\t\t\t\t} else {\n\t\t\t\t\tcontext.messages.push(finalMessage);\n\t\t\t\t}\n\t\t\t\tif (!addedPartial) {\n\t\t\t\t\tawait emit({ type: \"message_start\", message: { ...finalMessage } });\n\t\t\t\t}\n\t\t\t\tawait emit({ type: \"message_end\", message: finalMessage });\n\t\t\t\treturn finalMessage;\n\t\t\t}\n\t\t}\n\t}\n\n\tconst finalMessage = await response.result();\n\tif (addedPartial) {\n\t\tcontext.messages[context.messages.length - 1] = finalMessage;\n\t} else {\n\t\tcontext.messages.push(finalMessage);\n\t\tawait emit({ type: \"message_start\", message: { ...finalMessage } });\n\t}\n\tawait emit({ type: \"message_end\", message: finalMessage });\n\treturn finalMessage;\n}\n\n/**\n * Execute tool calls from an assistant message.\n */\nasync function executeToolCalls(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst toolCalls = assistantMessage.content.filter((c) => c.type === \"toolCall\");\n\tconst hasSequentialToolCall = toolCalls.some(\n\t\t(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === \"sequential\",\n\t);\n\tif (config.toolExecution === \"sequential\" || hasSequentialToolCall) {\n\t\treturn executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);\n\t}\n\treturn executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit);\n}\n\ntype ExecutedToolCallBatch = {\n\tmessages: ToolResultMessage[];\n\tterminate: boolean;\n};\n\nasync function executeToolCallsSequential(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallOutcome[] = [];\n\tconst messages: ToolResultMessage[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tawait emit({\n\t\t\ttype: \"tool_execution_start\",\n\t\t\ttoolCallId: toolCall.id,\n\t\t\ttoolName: toolCall.name,\n\t\t\targs: toolCall.arguments,\n\t\t});\n\n\t\tconst preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);\n\t\tlet finalized: FinalizedToolCallOutcome;\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\tfinalized = {\n\t\t\t\ttoolCall,\n\t\t\t\tresult: preparation.result,\n\t\t\t\tisError: preparation.isError,\n\t\t\t};\n\t\t} else {\n\t\t\tconst executed = await executePreparedToolCall(preparation, signal, emit);\n\t\t\tfinalized = await finalizeExecutedToolCall(\n\t\t\t\tcurrentContext,\n\t\t\t\tassistantMessage,\n\t\t\t\tpreparation,\n\t\t\t\texecuted,\n\t\t\t\tconfig,\n\t\t\t\tsignal,\n\t\t\t);\n\t\t}\n\n\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\tconst toolResultMessage = createToolResultMessage(finalized);\n\t\tawait emitToolResultMessage(toolResultMessage, emit);\n\t\tfinalizedCalls.push(finalized);\n\t\tmessages.push(toolResultMessage);\n\n\t\tif (signal?.aborted) {\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn {\n\t\tmessages,\n\t\tterminate: shouldTerminateToolBatch(finalizedCalls),\n\t};\n}\n\nasync function executeToolCallsParallel(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCalls: AgentToolCall[],\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallBatch> {\n\tconst finalizedCalls: FinalizedToolCallEntry[] = [];\n\n\tfor (const toolCall of toolCalls) {\n\t\tawait emit({\n\t\t\ttype: \"tool_execution_start\",\n\t\t\ttoolCallId: toolCall.id,\n\t\t\ttoolName: toolCall.name,\n\t\t\targs: toolCall.arguments,\n\t\t});\n\n\t\tconst preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal);\n\t\tif (preparation.kind === \"immediate\") {\n\t\t\tconst finalized = {\n\t\t\t\ttoolCall,\n\t\t\t\tresult: preparation.result,\n\t\t\t\tisError: preparation.isError,\n\t\t\t} satisfies FinalizedToolCallOutcome;\n\t\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\t\tfinalizedCalls.push(finalized);\n\t\t\tif (signal?.aborted) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tfinalizedCalls.push(async () => {\n\t\t\tconst executed = await executePreparedToolCall(preparation, signal, emit);\n\t\t\tconst finalized = await finalizeExecutedToolCall(\n\t\t\t\tcurrentContext,\n\t\t\t\tassistantMessage,\n\t\t\t\tpreparation,\n\t\t\t\texecuted,\n\t\t\t\tconfig,\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tawait emitToolExecutionEnd(finalized, emit);\n\t\t\treturn finalized;\n\t\t});\n\t\tif (signal?.aborted) {\n\t\t\tbreak;\n\t\t}\n\t}\n\n\tconst orderedFinalizedCalls = await Promise.all(\n\t\tfinalizedCalls.map((entry) => (typeof entry === \"function\" ? entry() : Promise.resolve(entry))),\n\t);\n\tconst messages: ToolResultMessage[] = [];\n\tfor (const finalized of orderedFinalizedCalls) {\n\t\tconst toolResultMessage = createToolResultMessage(finalized);\n\t\tawait emitToolResultMessage(toolResultMessage, emit);\n\t\tmessages.push(toolResultMessage);\n\t}\n\n\treturn {\n\t\tmessages,\n\t\tterminate: shouldTerminateToolBatch(orderedFinalizedCalls),\n\t};\n}\n\ntype PreparedToolCall = {\n\tkind: \"prepared\";\n\ttoolCall: AgentToolCall;\n\ttool: AgentTool<any>;\n\targs: unknown;\n};\n\ntype ImmediateToolCallOutcome = {\n\tkind: \"immediate\";\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n};\n\ntype ExecutedToolCallOutcome = {\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n};\n\ntype FinalizedToolCallOutcome = {\n\ttoolCall: AgentToolCall;\n\tresult: AgentToolResult<any>;\n\tisError: boolean;\n};\n\ntype FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise<FinalizedToolCallOutcome>);\n\nfunction shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {\n\treturn finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);\n}\n\nfunction prepareToolCallArguments(tool: AgentTool<any>, toolCall: AgentToolCall): AgentToolCall {\n\tif (!tool.prepareArguments) {\n\t\treturn toolCall;\n\t}\n\tconst preparedArguments = tool.prepareArguments(toolCall.arguments);\n\tif (preparedArguments === toolCall.arguments) {\n\t\treturn toolCall;\n\t}\n\treturn {\n\t\t...toolCall,\n\t\targuments: preparedArguments as Record<string, any>,\n\t};\n}\n\nasync function prepareToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\ttoolCall: AgentToolCall,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n): Promise<PreparedToolCall | ImmediateToolCallOutcome> {\n\tconst tool = currentContext.tools?.find((t) => t.name === toolCall.name);\n\tif (!tool) {\n\t\treturn {\n\t\t\tkind: \"immediate\",\n\t\t\tresult: createErrorToolResult(`Tool ${toolCall.name} not found`),\n\t\t\tisError: true,\n\t\t};\n\t}\n\n\ttry {\n\t\tconst preparedToolCall = prepareToolCallArguments(tool, toolCall);\n\t\tconst validatedArgs = validateToolArguments(tool, preparedToolCall);\n\t\tif (config.beforeToolCall) {\n\t\t\tconst beforeResult = await config.beforeToolCall(\n\t\t\t\t{\n\t\t\t\t\tassistantMessage,\n\t\t\t\t\ttoolCall,\n\t\t\t\t\targs: validatedArgs,\n\t\t\t\t\tcontext: currentContext,\n\t\t\t\t},\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tif (signal?.aborted) {\n\t\t\t\treturn {\n\t\t\t\t\tkind: \"immediate\",\n\t\t\t\t\tresult: createErrorToolResult(\"Operation aborted\"),\n\t\t\t\t\tisError: true,\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (beforeResult?.block) {\n\t\t\t\treturn {\n\t\t\t\t\tkind: \"immediate\",\n\t\t\t\t\tresult: createErrorToolResult(beforeResult.reason || \"Tool execution was blocked\"),\n\t\t\t\t\tisError: true,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\t\tif (signal?.aborted) {\n\t\t\treturn {\n\t\t\t\tkind: \"immediate\",\n\t\t\t\tresult: createErrorToolResult(\"Operation aborted\"),\n\t\t\t\tisError: true,\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tkind: \"prepared\",\n\t\t\ttoolCall,\n\t\t\ttool,\n\t\t\targs: validatedArgs,\n\t\t};\n\t} catch (error) {\n\t\treturn {\n\t\t\tkind: \"immediate\",\n\t\t\tresult: createErrorToolResult(error instanceof Error ? error.message : String(error)),\n\t\t\tisError: true,\n\t\t};\n\t}\n}\n\nasync function executePreparedToolCall(\n\tprepared: PreparedToolCall,\n\tsignal: AbortSignal | undefined,\n\temit: AgentEventSink,\n): Promise<ExecutedToolCallOutcome> {\n\tconst updateEvents: Promise<void>[] = [];\n\n\ttry {\n\t\tconst result = await prepared.tool.execute(\n\t\t\tprepared.toolCall.id,\n\t\t\tprepared.args as never,\n\t\t\tsignal,\n\t\t\t(partialResult) => {\n\t\t\t\tupdateEvents.push(\n\t\t\t\t\tPromise.resolve(\n\t\t\t\t\t\temit({\n\t\t\t\t\t\t\ttype: \"tool_execution_update\",\n\t\t\t\t\t\t\ttoolCallId: prepared.toolCall.id,\n\t\t\t\t\t\t\ttoolName: prepared.toolCall.name,\n\t\t\t\t\t\t\targs: prepared.toolCall.arguments,\n\t\t\t\t\t\t\tpartialResult,\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t},\n\t\t);\n\t\tawait Promise.all(updateEvents);\n\t\treturn { result, isError: false };\n\t} catch (error) {\n\t\tawait Promise.all(updateEvents);\n\t\treturn {\n\t\t\tresult: createErrorToolResult(error instanceof Error ? error.message : String(error)),\n\t\t\tisError: true,\n\t\t};\n\t}\n}\n\nasync function finalizeExecutedToolCall(\n\tcurrentContext: AgentContext,\n\tassistantMessage: AssistantMessage,\n\tprepared: PreparedToolCall,\n\texecuted: ExecutedToolCallOutcome,\n\tconfig: AgentLoopConfig,\n\tsignal: AbortSignal | undefined,\n): Promise<FinalizedToolCallOutcome> {\n\tlet result = executed.result;\n\tlet isError = executed.isError;\n\n\tif (config.afterToolCall) {\n\t\ttry {\n\t\t\tconst afterResult = await config.afterToolCall(\n\t\t\t\t{\n\t\t\t\t\tassistantMessage,\n\t\t\t\t\ttoolCall: prepared.toolCall,\n\t\t\t\t\targs: prepared.args,\n\t\t\t\t\tresult,\n\t\t\t\t\tisError,\n\t\t\t\t\tcontext: currentContext,\n\t\t\t\t},\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tif (afterResult) {\n\t\t\t\tresult = {\n\t\t\t\t\tcontent: afterResult.content ?? result.content,\n\t\t\t\t\tdetails: afterResult.details ?? result.details,\n\t\t\t\t\tterminate: afterResult.terminate ?? result.terminate,\n\t\t\t\t};\n\t\t\t\tisError = afterResult.isError ?? isError;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tresult = createErrorToolResult(error instanceof Error ? error.message : String(error));\n\t\t\tisError = true;\n\t\t}\n\t}\n\n\treturn {\n\t\ttoolCall: prepared.toolCall,\n\t\tresult,\n\t\tisError,\n\t};\n}\n\nfunction createErrorToolResult(message: string): AgentToolResult<any> {\n\treturn {\n\t\tcontent: [{ type: \"text\", text: message }],\n\t\tdetails: {},\n\t};\n}\n\nasync function emitToolExecutionEnd(finalized: FinalizedToolCallOutcome, emit: AgentEventSink): Promise<void> {\n\tawait emit({\n\t\ttype: \"tool_execution_end\",\n\t\ttoolCallId: finalized.toolCall.id,\n\t\ttoolName: finalized.toolCall.name,\n\t\tresult: finalized.result,\n\t\tisError: finalized.isError,\n\t});\n}\n\nfunction createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage {\n\treturn {\n\t\trole: \"toolResult\",\n\t\ttoolCallId: finalized.toolCall.id,\n\t\ttoolName: finalized.toolCall.name,\n\t\tcontent: finalized.result.content,\n\t\tdetails: finalized.result.details,\n\t\tisError: finalized.isError,\n\t\ttimestamp: Date.now(),\n\t};\n}\n\nasync function emitToolResultMessage(toolResultMessage: ToolResultMessage, emit: AgentEventSink): Promise<void> {\n\tawait emit({ type: \"message_start\", message: toolResultMessage });\n\tawait emit({ type: \"message_end\", message: toolResultMessage });\n}\n"}],"isError":false,"timestamp":1781660574651}}
|
||
{"type":"message","id":"e1b8bb89","parentId":"a6d5bc47","timestamp":"2026-06-17T01:43:00.672Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我看到了核心的 agent-loop.ts 代码。让我分析一下它的工作模式:\n\n1. `runLoop` 是核心函数\n2. 它有一个外层 while(true) 循环\n3. 内层 while 循环处理工具调用和 steering 消息\n4. 每次循环调用 `streamAssistantResponse` 获取 LLM 响应\n5. 如果响应包含工具调用,执行工具\n6. 检查是否有更多工具调用或 steering 消息\n7. 如果没有更多工具调用,检查是否有 follow-up 消息\n8. 如果没有 follow-up 消息,退出循环\n\n这是一个典型的 **ReAct 模式**:\n- Reason: LLM 生成响应(可能包含推理)\n- Act: 执行工具调用\n- Observe: 工具结果返回给 LLM\n- 循环直到没有更多工具调用\n\n让我再看看是否有 plan-execute 模式的实现。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_3c7455c1444a41cb9e1662a1","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/agent/src/types.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":4922,"output":247,"cacheRead":122496,"cacheWrite":0,"totalTokens":127665,"cost":{"input":0.0019688,"output":0.000494,"cacheRead":0.00979968,"cacheWrite":0,"total":0.01226248}},"stopReason":"toolUse","timestamp":1781660574654,"responseId":"0fb2752e342f4e72a4a51ba029a906f0"}}
|
||
{"type":"message","id":"b9a18250","parentId":"e1b8bb89","timestamp":"2026-06-17T01:43:00.675Z","message":{"role":"toolResult","toolCallId":"call_3c7455c1444a41cb9e1662a1","toolName":"read","content":[{"type":"text","text":"import type {\n\tAssistantMessage,\n\tAssistantMessageEvent,\n\tImageContent,\n\tMessage,\n\tModel,\n\tSimpleStreamOptions,\n\tstreamSimple,\n\tTextContent,\n\tTool,\n\tToolResultMessage,\n} from \"@earendil-works/pi-ai\";\nimport type { Static, TSchema } from \"typebox\";\n\n/**\n * Stream function used by the agent loop.\n *\n * Contract:\n * - Must not throw or return a rejected promise for request/model/runtime failures.\n * - Must return an AssistantMessageEventStream.\n * - Failures must be encoded in the returned stream via protocol events and a\n * final AssistantMessage with stopReason \"error\" or \"aborted\" and errorMessage.\n */\nexport type StreamFn = (\n\t...args: Parameters<typeof streamSimple>\n) => ReturnType<typeof streamSimple> | Promise<ReturnType<typeof streamSimple>>;\n\n/**\n * Configuration for how tool calls from a single assistant message are executed.\n *\n * - \"sequential\": each tool call is prepared, executed, and finalized before the next one starts.\n * - \"parallel\": tool calls are prepared sequentially, then allowed tools execute concurrently.\n * `tool_execution_end` is emitted in tool completion order after each tool is finalized,\n * while tool-result message artifacts are emitted later in assistant source order.\n */\nexport type ToolExecutionMode = \"sequential\" | \"parallel\";\n\n/**\n * Controls how many queued user messages are injected when the agent loop reaches a queue drain point.\n *\n * - \"all\": drain and inject every queued message at that point.\n * - \"one-at-a-time\": drain and inject only the oldest queued message, leaving the rest queued for later drain points.\n */\nexport type QueueMode = \"all\" | \"one-at-a-time\";\n\n/** A single tool call content block emitted by an assistant message. */\nexport type AgentToolCall = Extract<AssistantMessage[\"content\"][number], { type: \"toolCall\" }>;\n\n/**\n * Result returned from `beforeToolCall`.\n *\n * Returning `{ block: true }` prevents the tool from executing. The loop emits an error tool result instead.\n * `reason` becomes the text shown in that error result. If omitted, a default blocked message is used.\n */\nexport interface BeforeToolCallResult {\n\tblock?: boolean;\n\treason?: string;\n}\n\n/**\n * Partial override returned from `afterToolCall`.\n *\n * Merge semantics are field-by-field:\n * - `content`: if provided, replaces the tool result content array in full\n * - `details`: if provided, replaces the tool result details value in full\n * - `isError`: if provided, replaces the tool result error flag\n * - `terminate`: if provided, replaces the early-termination hint\n *\n * Omitted fields keep the original executed tool result values.\n * There is no deep merge for `content` or `details`.\n */\nexport interface AfterToolCallResult {\n\tcontent?: (TextContent | ImageContent)[];\n\tdetails?: unknown;\n\tisError?: boolean;\n\t/**\n\t * Hint that the agent should stop after the current tool batch.\n\t * Early termination only happens when every finalized tool result in the batch sets this to true.\n\t */\n\tterminate?: boolean;\n}\n\n/** Context passed to `beforeToolCall`. */\nexport interface BeforeToolCallContext {\n\t/** The assistant message that requested the tool call. */\n\tassistantMessage: AssistantMessage;\n\t/** The raw tool call block from `assistantMessage.content`. */\n\ttoolCall: AgentToolCall;\n\t/** Validated tool arguments for the target tool schema. */\n\targs: unknown;\n\t/** Current agent context at the time the tool call is prepared. */\n\tcontext: AgentContext;\n}\n\n/** Context passed to `afterToolCall`. */\nexport interface AfterToolCallContext {\n\t/** The assistant message that requested the tool call. */\n\tassistantMessage: AssistantMessage;\n\t/** The raw tool call block from `assistantMessage.content`. */\n\ttoolCall: AgentToolCall;\n\t/** Validated tool arguments for the target tool schema. */\n\targs: unknown;\n\t/** The executed tool result before any `afterToolCall` overrides are applied. */\n\tresult: AgentToolResult<any>;\n\t/** Whether the executed tool result is currently treated as an error. */\n\tisError: boolean;\n\t/** Current agent context at the time the tool call is finalized. */\n\tcontext: AgentContext;\n}\n\n/** Context passed to `shouldStopAfterTurn`. */\nexport interface ShouldStopAfterTurnContext {\n\t/** The assistant message that completed the turn. */\n\tmessage: AssistantMessage;\n\t/** Tool result messages passed to the preceding `turn_end` event. */\n\ttoolResults: ToolResultMessage[];\n\t/** Current agent context after the turn's assistant message and tool results have been appended. */\n\tcontext: AgentContext;\n\t/** Messages that this loop invocation will return if it exits at this point. Prompt runs include the initial prompt messages; continuation runs do not include pre-existing context messages. */\n\tnewMessages: AgentMessage[];\n}\n\n/** Replacement runtime state used by the agent loop before starting another provider request. */\nexport interface AgentLoopTurnUpdate {\n\t/** Context for the next provider request. */\n\tcontext?: AgentContext;\n\t/** Model for the next provider request. */\n\tmodel?: Model<any>;\n\t/** Thinking level for the next provider request. */\n\tthinkingLevel?: ThinkingLevel;\n}\n\nexport interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {}\n\nexport interface AgentLoopConfig extends SimpleStreamOptions {\n\tmodel: Model<any>;\n\n\t/**\n\t * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.\n\t *\n\t * Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage\n\t * that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications,\n\t * status messages) should be filtered out.\n\t *\n\t * Contract: must not throw or reject. Return a safe fallback value instead.\n\t * Throwing interrupts the low-level agent loop without producing a normal event sequence.\n\t *\n\t * @example\n\t * ```typescript\n\t * convertToLlm: (messages) => messages.flatMap(m => {\n\t * if (m.role === \"custom\") {\n\t * // Convert custom message to user message\n\t * return [{ role: \"user\", content: m.content, timestamp: m.timestamp }];\n\t * }\n\t * if (m.role === \"notification\") {\n\t * // Filter out UI-only messages\n\t * return [];\n\t * }\n\t * // Pass through standard LLM messages\n\t * return [m];\n\t * })\n\t * ```\n\t */\n\tconvertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;\n\n\t/**\n\t * Optional transform applied to the context before `convertToLlm`.\n\t *\n\t * Use this for operations that work at the AgentMessage level:\n\t * - Context window management (pruning old messages)\n\t * - Injecting context from external sources\n\t *\n\t * Contract: must not throw or reject. Return the original messages or another\n\t * safe fallback value instead.\n\t *\n\t * @example\n\t * ```typescript\n\t * transformContext: async (messages) => {\n\t * if (estimateTokens(messages) > MAX_TOKENS) {\n\t * return pruneOldMessages(messages);\n\t * }\n\t * return messages;\n\t * }\n\t * ```\n\t */\n\ttransformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;\n\n\t/**\n\t * Resolves an API key dynamically for each LLM call.\n\t *\n\t * Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire\n\t * during long-running tool execution phases.\n\t *\n\t * Contract: must not throw or reject. Return undefined when no key is available.\n\t */\n\tgetApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;\n\n\t/**\n\t * Called after each turn fully completes and `turn_end` has been emitted.\n\t *\n\t * If it returns true, the loop emits `agent_end` and exits before polling steering or follow-up queues,\n\t * without starting another LLM call. The current assistant response and any tool executions finish normally.\n\t *\n\t * Use this to request a graceful stop after the current turn, e.g. before context gets too full.\n\t *\n\t * Contract: must not throw or reject. Throwing interrupts the low-level agent loop without producing a normal event sequence.\n\t */\n\tshouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean | Promise<boolean>;\n\n\t/**\n\t * Called after `turn_end` and before the loop decides whether another provider request should start.\n\t * Return replacement context/model/thinking state to affect the next turn in this run.\n\t * Return undefined to keep using the current context/config.\n\t */\n\tprepareNextTurn?: (\n\t\tcontext: PrepareNextTurnContext,\n\t) => AgentLoopTurnUpdate | undefined | Promise<AgentLoopTurnUpdate | undefined>;\n\n\t/**\n\t * Returns steering messages to inject into the conversation mid-run.\n\t *\n\t * Called after the current assistant turn finishes executing its tool calls, unless `shouldStopAfterTurn` exits first.\n\t * If messages are returned, they are added to the context before the next LLM call.\n\t * Tool calls from the current assistant message are not skipped.\n\t *\n\t * Use this for \"steering\" the agent while it's working.\n\t *\n\t * Contract: must not throw or reject. Return [] when no steering messages are available.\n\t */\n\tgetSteeringMessages?: () => Promise<AgentMessage[]>;\n\n\t/**\n\t * Returns follow-up messages to process after the agent would otherwise stop.\n\t *\n\t * Called when the agent has no more tool calls and no steering messages.\n\t * If messages are returned, they're added to the context and the agent\n\t * continues with another turn.\n\t *\n\t * Use this for follow-up messages that should wait until the agent finishes.\n\t *\n\t * Contract: must not throw or reject. Return [] when no follow-up messages are available.\n\t */\n\tgetFollowUpMessages?: () => Promise<AgentMessage[]>;\n\n\t/**\n\t * Tool execution mode.\n\t * - \"sequential\": execute tool calls one by one\n\t * - \"parallel\": preflight tool calls sequentially, then execute allowed tools concurrently;\n\t * emit `tool_execution_end` in tool completion order after each tool is finalized,\n\t * then emit tool-result message artifacts later in assistant source order\n\t *\n\t * Default: \"parallel\"\n\t */\n\ttoolExecution?: ToolExecutionMode;\n\n\t/**\n\t * Called before a tool is executed, after arguments have been validated.\n\t *\n\t * Return `{ block: true }` to prevent execution. The loop emits an error tool result instead.\n\t * The hook receives the agent abort signal and is responsible for honoring it.\n\t */\n\tbeforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;\n\n\t/**\n\t * Called after a tool finishes executing, before `tool_execution_end` and tool-result message events are emitted.\n\t *\n\t * Return an `AfterToolCallResult` to override parts of the executed tool result:\n\t * - `content` replaces the full content array\n\t * - `details` replaces the full details payload\n\t * - `isError` replaces the error flag\n\t * - `terminate` replaces the early-termination hint\n\t *\n\t * Any omitted fields keep their original values. No deep merge is performed.\n\t * The hook receives the agent abort signal and is responsible for honoring it.\n\t */\n\tafterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;\n}\n\n/**\n * Thinking/reasoning level for models that support it.\n * Note: \"xhigh\" is only supported by selected model families. Use model thinking-level metadata\n * from @earendil-works/pi-ai to detect support for a concrete model.\n */\nexport type ThinkingLevel = \"off\" | \"minimal\" | \"low\" | \"medium\" | \"high\" | \"xhigh\";\n\n/**\n * Extensible interface for custom app messages.\n * Apps can extend via declaration merging:\n *\n * @example\n * ```typescript\n * declare module \"@mariozechner/agent\" {\n * interface CustomAgentMessages {\n * artifact: ArtifactMessage;\n * notification: NotificationMessage;\n * }\n * }\n * ```\n */\nexport interface CustomAgentMessages {\n\t// Empty by default - apps extend via declaration merging\n}\n\n/**\n * AgentMessage: Union of LLM messages + custom messages.\n * This abstraction allows apps to add custom message types while maintaining\n * type safety and compatibility with the base LLM messages.\n */\nexport type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];\n\n/**\n * Public agent state.\n *\n * `tools` and `messages` use accessor properties so implementations can copy\n * assigned arrays before storing them.\n */\nexport interface AgentState {\n\t/** System prompt sent with each model request. */\n\tsystemPrompt: string;\n\t/** Active model used for future turns. */\n\tmodel: Model<any>;\n\t/** Requested reasoning level for future turns. */\n\tthinkingLevel: ThinkingLevel;\n\t/** Available tools. Assigning a new array copies the top-level array. */\n\tset tools(tools: AgentTool<any>[]);\n\tget tools(): AgentTool<any>[];\n\t/** Conversation transcript. Assigning a new array copies the top-level array. */\n\tset messages(messages: AgentMessage[]);\n\tget messages(): AgentMessage[];\n\t/**\n\t * True while the agent is processing a prompt or continuation.\n\t *\n\t * This remains true until awaited `agent_end` listeners settle.\n\t */\n\treadonly isStreaming: boolean;\n\t/** Partial assistant message for the current streamed response, if any. */\n\treadonly streamingMessage?: AgentMessage;\n\t/** Tool call ids currently executing. */\n\treadonly pendingToolCalls: ReadonlySet<string>;\n\t/** Error message from the most recent failed or aborted assistant turn, if any. */\n\treadonly errorMessage?: string;\n}\n\n/** Final or partial result produced by a tool. */\nexport interface AgentToolResult<T> {\n\t/** Text or image content returned to the model. */\n\tcontent: (TextContent | ImageContent)[];\n\t/** Arbitrary structured details for logs or UI rendering. */\n\tdetails: T;\n\t/**\n\t * Hint that the agent should stop after the current tool batch.\n\t * Early termination only happens when every finalized tool result in the batch sets this to true.\n\t */\n\tterminate?: boolean;\n}\n\n/** Callback used by tools to stream partial execution updates. */\nexport type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;\n\n/** Tool definition used by the agent runtime. */\nexport interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {\n\t/** Human-readable label for UI display. */\n\tlabel: string;\n\t/**\n\t * Optional compatibility shim for raw tool-call arguments before schema validation.\n\t * Must return an object that matches `TParameters`.\n\t */\n\tprepareArguments?: (args: unknown) => Static<TParameters>;\n\t/** Execute the tool call. Throw on failure instead of encoding errors in `content`. */\n\texecute: (\n\t\ttoolCallId: string,\n\t\tparams: Static<TParameters>,\n\t\tsignal?: AbortSignal,\n\t\tonUpdate?: AgentToolUpdateCallback<TDetails>,\n\t) => Promise<AgentToolResult<TDetails>>;\n\t/**\n\t * Per-tool execution mode override.\n\t * - \"sequential\": this tool must execute one at a time with other tool calls.\n\t * - \"parallel\": this tool can execute concurrently with other tool calls.\n\t *\n\t * If omitted, the default execution mode applies.\n\t */\n\texecutionMode?: ToolExecutionMode;\n}\n\n/** Context snapshot passed into the low-level agent loop. */\nexport interface AgentContext {\n\t/** System prompt included with the request. */\n\tsystemPrompt: string;\n\t/** Transcript visible to the model. */\n\tmessages: AgentMessage[];\n\t/** Tools available for this run. */\n\ttools?: AgentTool<any>[];\n}\n\n/**\n * Events emitted by the Agent for UI updates.\n *\n * `agent_end` is the last event emitted for a run, but awaited `Agent.subscribe()`\n * listeners for that event are still part of run settlement. The agent becomes\n * idle only after those listeners finish.\n */\nexport type AgentEvent =\n\t// Agent lifecycle\n\t| { type: \"agent_start\" }\n\t| { type: \"agent_end\"; messages: AgentMessage[] }\n\t// Turn lifecycle - a turn is one assistant response + any tool calls/results\n\t| { type: \"turn_start\" }\n\t| { type: \"turn_end\"; message: AgentMessage; toolResults: ToolResultMessage[] }\n\t// Message lifecycle - emitted for user, assistant, and toolResult messages\n\t| { type: \"message_start\"; message: AgentMessage }\n\t// Only emitted for assistant messages during streaming\n\t| { type: \"message_update\"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }\n\t| { type: \"message_end\"; message: AgentMessage }\n\t// Tool execution lifecycle\n\t| { type: \"tool_execution_start\"; toolCallId: string; toolName: string; args: any }\n\t| { type: \"tool_execution_update\"; toolCallId: string; toolName: string; args: any; partialResult: any }\n\t| { type: \"tool_execution_end\"; toolCallId: string; toolName: string; result: any; isError: boolean };\n"}],"isError":false,"timestamp":1781660580674}}
|
||
{"type":"message","id":"c8b607c8","parentId":"b9a18250","timestamp":"2026-06-17T01:43:04.537Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我有了完整的了解。让我总结一下 sproutclaw 的工作模式:\n\n1. **核心循环**是 ReAct 模式:\n - LLM 生成响应(可能包含推理和工具调用)\n - 执行工具调用\n - 工具结果返回给 LLM\n - 循环直到没有更多工具调用\n\n2. **没有显式的 plan-execute 模式**:\n - 没有独立的\"计划\"阶段\n - 没有预定义的步骤列表\n - LLM 自己决定每一步做什么\n\n3. 但有一些**类似 plan-execute 的机制**:\n - `shouldStopAfterTurn` 可以在特定条件下停止\n - `prepareNextTurn` 可以修改下一轮的上下文\n - `beforeToolCall` 和 `afterToolCall` 可以拦截和修改工具执行\n - 扩展可以注入自定义逻辑\n\n让我给用户一个清晰的回答。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_8cad3915149d43ef8ae65098","name":"read","arguments":{"path":"/shumengya/project/agent/sproutclaw/packages/agent/src/agent.ts"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":3969,"output":253,"cacheRead":127360,"cacheWrite":0,"totalTokens":131582,"cost":{"input":0.0015876000000000002,"output":0.0005059999999999999,"cacheRead":0.0101888,"cacheWrite":0,"total":0.012282399999999999}},"stopReason":"toolUse","timestamp":1781660580678,"responseId":"5159c2d208b4465ca5d37fb1227e4dac"}}
|
||
{"type":"message","id":"55a574f3","parentId":"c8b607c8","timestamp":"2026-06-17T01:43:04.539Z","message":{"role":"toolResult","toolCallId":"call_8cad3915149d43ef8ae65098","toolName":"read","content":[{"type":"text","text":"import {\n\ttype ImageContent,\n\ttype Message,\n\ttype Model,\n\ttype SimpleStreamOptions,\n\tstreamSimple,\n\ttype TextContent,\n\ttype ThinkingBudgets,\n\ttype Transport,\n} from \"@earendil-works/pi-ai\";\nimport { runAgentLoop, runAgentLoopContinue } from \"./agent-loop.ts\";\nimport type {\n\tAfterToolCallContext,\n\tAfterToolCallResult,\n\tAgentContext,\n\tAgentEvent,\n\tAgentLoopConfig,\n\tAgentLoopTurnUpdate,\n\tAgentMessage,\n\tAgentState,\n\tAgentTool,\n\tBeforeToolCallContext,\n\tBeforeToolCallResult,\n\tQueueMode,\n\tStreamFn,\n\tToolExecutionMode,\n} from \"./types.ts\";\n\nexport type { QueueMode } from \"./types.ts\";\n\nfunction defaultConvertToLlm(messages: AgentMessage[]): Message[] {\n\treturn messages.filter(\n\t\t(message) => message.role === \"user\" || message.role === \"assistant\" || message.role === \"toolResult\",\n\t);\n}\n\nconst EMPTY_USAGE = {\n\tinput: 0,\n\toutput: 0,\n\tcacheRead: 0,\n\tcacheWrite: 0,\n\ttotalTokens: 0,\n\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },\n};\n\nconst DEFAULT_MODEL = {\n\tid: \"unknown\",\n\tname: \"unknown\",\n\tapi: \"unknown\",\n\tprovider: \"unknown\",\n\tbaseUrl: \"\",\n\treasoning: false,\n\tinput: [],\n\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\tcontextWindow: 0,\n\tmaxTokens: 0,\n} satisfies Model<any>;\n\ntype MutableAgentState = Omit<AgentState, \"isStreaming\" | \"streamingMessage\" | \"pendingToolCalls\" | \"errorMessage\"> & {\n\tisStreaming: boolean;\n\tstreamingMessage?: AgentMessage;\n\tpendingToolCalls: Set<string>;\n\terrorMessage?: string;\n};\n\nfunction createMutableAgentState(\n\tinitialState?: Partial<Omit<AgentState, \"pendingToolCalls\" | \"isStreaming\" | \"streamingMessage\" | \"errorMessage\">>,\n): MutableAgentState {\n\tlet tools = initialState?.tools?.slice() ?? [];\n\tlet messages = initialState?.messages?.slice() ?? [];\n\n\treturn {\n\t\tsystemPrompt: initialState?.systemPrompt ?? \"\",\n\t\tmodel: initialState?.model ?? DEFAULT_MODEL,\n\t\tthinkingLevel: initialState?.thinkingLevel ?? \"off\",\n\t\tget tools() {\n\t\t\treturn tools;\n\t\t},\n\t\tset tools(nextTools: AgentTool<any>[]) {\n\t\t\ttools = nextTools.slice();\n\t\t},\n\t\tget messages() {\n\t\t\treturn messages;\n\t\t},\n\t\tset messages(nextMessages: AgentMessage[]) {\n\t\t\tmessages = nextMessages.slice();\n\t\t},\n\t\tisStreaming: false,\n\t\tstreamingMessage: undefined,\n\t\tpendingToolCalls: new Set<string>(),\n\t\terrorMessage: undefined,\n\t};\n}\n\n/** Options for constructing an {@link Agent}. */\nexport interface AgentOptions {\n\tinitialState?: Partial<Omit<AgentState, \"pendingToolCalls\" | \"isStreaming\" | \"streamingMessage\" | \"errorMessage\">>;\n\tconvertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;\n\ttransformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;\n\tstreamFn?: StreamFn;\n\tgetApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;\n\tonPayload?: SimpleStreamOptions[\"onPayload\"];\n\tonResponse?: SimpleStreamOptions[\"onResponse\"];\n\tbeforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;\n\tafterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;\n\tprepareNextTurn?: (\n\t\tsignal?: AbortSignal,\n\t) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;\n\tsteeringMode?: QueueMode;\n\tfollowUpMode?: QueueMode;\n\tsessionId?: string;\n\tthinkingBudgets?: ThinkingBudgets;\n\ttransport?: Transport;\n\tmaxRetryDelayMs?: number;\n\ttoolExecution?: ToolExecutionMode;\n}\n\nclass PendingMessageQueue {\n\tprivate messages: AgentMessage[] = [];\n\tpublic mode: QueueMode;\n\n\tconstructor(mode: QueueMode) {\n\t\tthis.mode = mode;\n\t}\n\n\tenqueue(message: AgentMessage): void {\n\t\tthis.messages.push(message);\n\t}\n\n\thasItems(): boolean {\n\t\treturn this.messages.length > 0;\n\t}\n\n\tdrain(): AgentMessage[] {\n\t\tif (this.mode === \"all\") {\n\t\t\tconst drained = this.messages.slice();\n\t\t\tthis.messages = [];\n\t\t\treturn drained;\n\t\t}\n\n\t\tconst first = this.messages[0];\n\t\tif (!first) {\n\t\t\treturn [];\n\t\t}\n\t\tthis.messages = this.messages.slice(1);\n\t\treturn [first];\n\t}\n\n\tclear(): void {\n\t\tthis.messages = [];\n\t}\n}\n\ntype ActiveRun = {\n\tpromise: Promise<void>;\n\tresolve: () => void;\n\tabortController: AbortController;\n};\n\n/**\n * Stateful wrapper around the low-level agent loop.\n *\n * `Agent` owns the current transcript, emits lifecycle events, executes tools,\n * and exposes queueing APIs for steering and follow-up messages.\n */\nexport class Agent {\n\tprivate _state: MutableAgentState;\n\tprivate readonly listeners = new Set<(event: AgentEvent, signal: AbortSignal) => Promise<void> | void>();\n\tprivate readonly steeringQueue: PendingMessageQueue;\n\tprivate readonly followUpQueue: PendingMessageQueue;\n\n\tpublic convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;\n\tpublic transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;\n\tpublic streamFn: StreamFn;\n\tpublic getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;\n\tpublic onPayload?: SimpleStreamOptions[\"onPayload\"];\n\tpublic onResponse?: SimpleStreamOptions[\"onResponse\"];\n\tpublic beforeToolCall?: (\n\t\tcontext: BeforeToolCallContext,\n\t\tsignal?: AbortSignal,\n\t) => Promise<BeforeToolCallResult | undefined>;\n\tpublic afterToolCall?: (\n\t\tcontext: AfterToolCallContext,\n\t\tsignal?: AbortSignal,\n\t) => Promise<AfterToolCallResult | undefined>;\n\tpublic prepareNextTurn?: (\n\t\tsignal?: AbortSignal,\n\t) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;\n\tprivate activeRun?: ActiveRun;\n\t/** Session identifier forwarded to providers for cache-aware backends. */\n\tpublic sessionId?: string;\n\t/** Optional per-level thinking token budgets forwarded to the stream function. */\n\tpublic thinkingBudgets?: ThinkingBudgets;\n\t/** Preferred transport forwarded to the stream function. */\n\tpublic transport: Transport;\n\t/** Optional cap for provider-requested retry delays. */\n\tpublic maxRetryDelayMs?: number;\n\t/** Tool execution strategy for assistant messages that contain multiple tool calls. */\n\tpublic toolExecution: ToolExecutionMode;\n\n\tconstructor(options: AgentOptions = {}) {\n\t\tthis._state = createMutableAgentState(options.initialState);\n\t\tthis.convertToLlm = options.convertToLlm ?? defaultConvertToLlm;\n\t\tthis.transformContext = options.transformContext;\n\t\tthis.streamFn = options.streamFn ?? streamSimple;\n\t\tthis.getApiKey = options.getApiKey;\n\t\tthis.onPayload = options.onPayload;\n\t\tthis.onResponse = options.onResponse;\n\t\tthis.beforeToolCall = options.beforeToolCall;\n\t\tthis.afterToolCall = options.afterToolCall;\n\t\tthis.prepareNextTurn = options.prepareNextTurn;\n\t\tthis.steeringQueue = new PendingMessageQueue(options.steeringMode ?? \"one-at-a-time\");\n\t\tthis.followUpQueue = new PendingMessageQueue(options.followUpMode ?? \"one-at-a-time\");\n\t\tthis.sessionId = options.sessionId;\n\t\tthis.thinkingBudgets = options.thinkingBudgets;\n\t\tthis.transport = options.transport ?? \"auto\";\n\t\tthis.maxRetryDelayMs = options.maxRetryDelayMs;\n\t\tthis.toolExecution = options.toolExecution ?? \"parallel\";\n\t}\n\n\t/**\n\t * Subscribe to agent lifecycle events.\n\t *\n\t * Listener promises are awaited in subscription order and are included in\n\t * the current run's settlement. Listeners also receive the active abort\n\t * signal for the current run.\n\t *\n\t * `agent_end` is the final emitted event for a run, but the agent does not\n\t * become idle until all awaited listeners for that event have settled.\n\t */\n\tsubscribe(listener: (event: AgentEvent, signal: AbortSignal) => Promise<void> | void): () => void {\n\t\tthis.listeners.add(listener);\n\t\treturn () => this.listeners.delete(listener);\n\t}\n\n\t/**\n\t * Current agent state.\n\t *\n\t * Assigning `state.tools` or `state.messages` copies the provided top-level array.\n\t */\n\tget state(): AgentState {\n\t\treturn this._state;\n\t}\n\n\t/** Controls how queued steering messages are drained. */\n\tset steeringMode(mode: QueueMode) {\n\t\tthis.steeringQueue.mode = mode;\n\t}\n\n\tget steeringMode(): QueueMode {\n\t\treturn this.steeringQueue.mode;\n\t}\n\n\t/** Controls how queued follow-up messages are drained. */\n\tset followUpMode(mode: QueueMode) {\n\t\tthis.followUpQueue.mode = mode;\n\t}\n\n\tget followUpMode(): QueueMode {\n\t\treturn this.followUpQueue.mode;\n\t}\n\n\t/** Queue a message to be injected after the current assistant turn finishes. */\n\tsteer(message: AgentMessage): void {\n\t\tthis.steeringQueue.enqueue(message);\n\t}\n\n\t/** Queue a message to run only after the agent would otherwise stop. */\n\tfollowUp(message: AgentMessage): void {\n\t\tthis.followUpQueue.enqueue(message);\n\t}\n\n\t/** Remove all queued steering messages. */\n\tclearSteeringQueue(): void {\n\t\tthis.steeringQueue.clear();\n\t}\n\n\t/** Remove all queued follow-up messages. */\n\tclearFollowUpQueue(): void {\n\t\tthis.followUpQueue.clear();\n\t}\n\n\t/** Remove all queued steering and follow-up messages. */\n\tclearAllQueues(): void {\n\t\tthis.clearSteeringQueue();\n\t\tthis.clearFollowUpQueue();\n\t}\n\n\t/** Returns true when either queue still contains pending messages. */\n\thasQueuedMessages(): boolean {\n\t\treturn this.steeringQueue.hasItems() || this.followUpQueue.hasItems();\n\t}\n\n\t/** Active abort signal for the current run, if any. */\n\tget signal(): AbortSignal | undefined {\n\t\treturn this.activeRun?.abortController.signal;\n\t}\n\n\t/** Abort the current run, if one is active. */\n\tabort(): void {\n\t\tthis.activeRun?.abortController.abort();\n\t}\n\n\t/**\n\t * Resolve when the current run and all awaited event listeners have finished.\n\t *\n\t * This resolves after `agent_end` listeners settle.\n\t */\n\twaitForIdle(): Promise<void> {\n\t\treturn this.activeRun?.promise ?? Promise.resolve();\n\t}\n\n\t/** Clear transcript state, runtime state, and queued messages. */\n\treset(): void {\n\t\tthis._state.messages = [];\n\t\tthis._state.isStreaming = false;\n\t\tthis._state.streamingMessage = undefined;\n\t\tthis._state.pendingToolCalls = new Set<string>();\n\t\tthis._state.errorMessage = undefined;\n\t\tthis.clearFollowUpQueue();\n\t\tthis.clearSteeringQueue();\n\t}\n\n\t/** Start a new prompt from text, a single message, or a batch of messages. */\n\tasync prompt(message: AgentMessage | AgentMessage[]): Promise<void>;\n\tasync prompt(input: string, images?: ImageContent[]): Promise<void>;\n\tasync prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]): Promise<void> {\n\t\tif (this.activeRun) {\n\t\t\tthrow new Error(\n\t\t\t\t\"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.\",\n\t\t\t);\n\t\t}\n\t\tconst messages = this.normalizePromptInput(input, images);\n\t\tawait this.runPromptMessages(messages);\n\t}\n\n\t/** Continue from the current transcript. The last message must be a user or tool-result message. */\n\tasync continue(): Promise<void> {\n\t\tif (this.activeRun) {\n\t\t\tthrow new Error(\"Agent is already processing. Wait for completion before continuing.\");\n\t\t}\n\n\t\tconst lastMessage = this._state.messages[this._state.messages.length - 1];\n\t\tif (!lastMessage) {\n\t\t\tthrow new Error(\"No messages to continue from\");\n\t\t}\n\n\t\tif (lastMessage.role === \"assistant\") {\n\t\t\tconst queuedSteering = this.steeringQueue.drain();\n\t\t\tif (queuedSteering.length > 0) {\n\t\t\t\tawait this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst queuedFollowUps = this.followUpQueue.drain();\n\t\t\tif (queuedFollowUps.length > 0) {\n\t\t\t\tawait this.runPromptMessages(queuedFollowUps);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthrow new Error(\"Cannot continue from message role: assistant\");\n\t\t}\n\n\t\tawait this.runContinuation();\n\t}\n\n\tprivate normalizePromptInput(\n\t\tinput: string | AgentMessage | AgentMessage[],\n\t\timages?: ImageContent[],\n\t): AgentMessage[] {\n\t\tif (Array.isArray(input)) {\n\t\t\treturn input;\n\t\t}\n\n\t\tif (typeof input !== \"string\") {\n\t\t\treturn [input];\n\t\t}\n\n\t\tconst content: Array<TextContent | ImageContent> = [{ type: \"text\", text: input }];\n\t\tif (images && images.length > 0) {\n\t\t\tcontent.push(...images);\n\t\t}\n\t\treturn [{ role: \"user\", content, timestamp: Date.now() }];\n\t}\n\n\tprivate async runPromptMessages(\n\t\tmessages: AgentMessage[],\n\t\toptions: { skipInitialSteeringPoll?: boolean } = {},\n\t): Promise<void> {\n\t\tawait this.runWithLifecycle(async (signal) => {\n\t\t\tawait runAgentLoop(\n\t\t\t\tmessages,\n\t\t\t\tthis.createContextSnapshot(),\n\t\t\t\tthis.createLoopConfig(options),\n\t\t\t\t(event) => this.processEvents(event),\n\t\t\t\tsignal,\n\t\t\t\tthis.streamFn,\n\t\t\t);\n\t\t});\n\t}\n\n\tprivate async runContinuation(): Promise<void> {\n\t\tawait this.runWithLifecycle(async (signal) => {\n\t\t\tawait runAgentLoopContinue(\n\t\t\t\tthis.createContextSnapshot(),\n\t\t\t\tthis.createLoopConfig(),\n\t\t\t\t(event) => this.processEvents(event),\n\t\t\t\tsignal,\n\t\t\t\tthis.streamFn,\n\t\t\t);\n\t\t});\n\t}\n\n\tprivate createContextSnapshot(): AgentContext {\n\t\treturn {\n\t\t\tsystemPrompt: this._state.systemPrompt,\n\t\t\tmessages: this._state.messages.slice(),\n\t\t\ttools: this._state.tools.slice(),\n\t\t};\n\t}\n\n\tprivate createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig {\n\t\tlet skipInitialSteeringPoll = options.skipInitialSteeringPoll === true;\n\t\treturn {\n\t\t\tmodel: this._state.model,\n\t\t\treasoning: this._state.thinkingLevel === \"off\" ? undefined : this._state.thinkingLevel,\n\t\t\tsessionId: this.sessionId,\n\t\t\tonPayload: this.onPayload,\n\t\t\tonResponse: this.onResponse,\n\t\t\ttransport: this.transport,\n\t\t\tthinkingBudgets: this.thinkingBudgets,\n\t\t\tmaxRetryDelayMs: this.maxRetryDelayMs,\n\t\t\ttoolExecution: this.toolExecution,\n\t\t\tbeforeToolCall: this.beforeToolCall,\n\t\t\tafterToolCall: this.afterToolCall,\n\t\t\tprepareNextTurn: this.prepareNextTurn ? async () => await this.prepareNextTurn?.(this.signal) : undefined,\n\t\t\tconvertToLlm: this.convertToLlm,\n\t\t\ttransformContext: this.transformContext,\n\t\t\tgetApiKey: this.getApiKey,\n\t\t\tgetSteeringMessages: async () => {\n\t\t\t\tif (skipInitialSteeringPoll) {\n\t\t\t\t\tskipInitialSteeringPoll = false;\n\t\t\t\t\treturn [];\n\t\t\t\t}\n\t\t\t\treturn this.steeringQueue.drain();\n\t\t\t},\n\t\t\tgetFollowUpMessages: async () => this.followUpQueue.drain(),\n\t\t};\n\t}\n\n\tprivate async runWithLifecycle(executor: (signal: AbortSignal) => Promise<void>): Promise<void> {\n\t\tif (this.activeRun) {\n\t\t\tthrow new Error(\"Agent is already processing.\");\n\t\t}\n\n\t\tconst abortController = new AbortController();\n\t\tlet resolvePromise = () => {};\n\t\tconst promise = new Promise<void>((resolve) => {\n\t\t\tresolvePromise = resolve;\n\t\t});\n\t\tthis.activeRun = { promise, resolve: resolvePromise, abortController };\n\n\t\tthis._state.isStreaming = true;\n\t\tthis._state.streamingMessage = undefined;\n\t\tthis._state.errorMessage = undefined;\n\n\t\ttry {\n\t\t\tawait executor(abortController.signal);\n\t\t} catch (error) {\n\t\t\tawait this.handleRunFailure(error, abortController.signal.aborted);\n\t\t} finally {\n\t\t\tthis.finishRun();\n\t\t}\n\t}\n\n\tprivate async handleRunFailure(error: unknown, aborted: boolean): Promise<void> {\n\t\tconst failureMessage = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent: [{ type: \"text\", text: \"\" }],\n\t\t\tapi: this._state.model.api,\n\t\t\tprovider: this._state.model.provider,\n\t\t\tmodel: this._state.model.id,\n\t\t\tusage: EMPTY_USAGE,\n\t\t\tstopReason: aborted ? \"aborted\" : \"error\",\n\t\t\terrorMessage: error instanceof Error ? error.message : String(error),\n\t\t\ttimestamp: Date.now(),\n\t\t} satisfies AgentMessage;\n\t\tawait this.processEvents({ type: \"message_start\", message: failureMessage });\n\t\tawait this.processEvents({ type: \"message_end\", message: failureMessage });\n\t\tawait this.processEvents({ type: \"turn_end\", message: failureMessage, toolResults: [] });\n\t\tawait this.processEvents({ type: \"agent_end\", messages: [failureMessage] });\n\t}\n\n\tprivate finishRun(): void {\n\t\tthis._state.isStreaming = false;\n\t\tthis._state.streamingMessage = undefined;\n\t\tthis._state.pendingToolCalls = new Set<string>();\n\t\tthis.activeRun?.resolve();\n\t\tthis.activeRun = undefined;\n\t}\n\n\t/**\n\t * Reduce internal state for a loop event, then await listeners.\n\t *\n\t * `agent_end` only means no further loop events will be emitted. The run is\n\t * considered idle later, after all awaited listeners for `agent_end` finish\n\t * and `finishRun()` clears runtime-owned state.\n\t */\n\tprivate async processEvents(event: AgentEvent): Promise<void> {\n\t\tswitch (event.type) {\n\t\t\tcase \"message_start\":\n\t\t\t\tthis._state.streamingMessage = event.message;\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_update\":\n\t\t\t\tthis._state.streamingMessage = event.message;\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_end\":\n\t\t\t\tthis._state.streamingMessage = undefined;\n\t\t\t\tthis._state.messages.push(event.message);\n\t\t\t\tbreak;\n\n\t\t\tcase \"tool_execution_start\": {\n\t\t\t\tconst pendingToolCalls = new Set(this._state.pendingToolCalls);\n\t\t\t\tpendingToolCalls.add(event.toolCallId);\n\t\t\t\tthis._state.pendingToolCalls = pendingToolCalls;\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\tconst pendingToolCalls = new Set(this._state.pendingToolCalls);\n\t\t\t\tpendingToolCalls.delete(event.toolCallId);\n\t\t\t\tthis._state.pendingToolCalls = pendingToolCalls;\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"turn_end\":\n\t\t\t\tif (event.message.role === \"assistant\" && event.message.errorMessage) {\n\t\t\t\t\tthis._state.errorMessage = event.message.errorMessage;\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"agent_end\":\n\t\t\t\tthis._state.streamingMessage = undefined;\n\t\t\t\tbreak;\n\t\t}\n\n\t\tconst signal = this.activeRun?.abortController.signal;\n\t\tif (!signal) {\n\t\t\tthrow new Error(\"Agent listener invoked outside active run\");\n\t\t}\n\t\tfor (const listener of this.listeners) {\n\t\t\tawait listener(event, signal);\n\t\t}\n\t}\n}\n"}],"isError":false,"timestamp":1781660584539}}
|
||
{"type":"message","id":"10107bde","parentId":"55a574f3","timestamp":"2026-06-17T01:43:30.054Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我有了完整的了解。让我给用户一个清晰的回答,解释 sproutclaw 的工作模式。\n\n从代码分析来看:\n\n1. **核心循环是 ReAct 模式**:\n - `runLoop` 函数是核心\n - 外层 `while(true)` 循环\n - 内层 `while (hasMoreToolCalls || pendingMessages.length > 0)` 循环\n - 每次调用 `streamAssistantResponse` 获取 LLM 响应\n - 如果响应包含工具调用,执行工具\n - 工具结果返回给 LLM,继续循环\n\n2. **没有显式的 plan-execute 模式**:\n - 没有独立的\"计划\"阶段\n - 没有预定义的步骤列表\n - LLM 自己决定每一步做什么\n\n3. 但有一些**可以实现类似 plan-execute 的机制**:\n - `shouldStopAfterTurn` 可以在特定条件下停止\n - `prepareNextTurn` 可以修改下一轮的上下文\n - `beforeToolCall` 和 `afterToolCall` 可以拦截和修改工具执行\n - 扩展可以注入自定义逻辑\n\n让我给用户一个清晰的回答。","thinkingSignature":"reasoning_content"},{"type":"text","text":"根据我对 sproutclaw 源码的分析,让我为你详细解释它的工作模式。\n\n---\n\n# SproutClaw 工作模式分析\n\n## 一、核心结论\n\n**SproutClaw 的核心工作流是 ReAct(Reasoning + Acting)模式**,不是 Plan-Execute 模式。\n\n---\n\n## 二、ReAct 模式的实现\n\n### 2.1 核心循环(agent-loop.ts)\n\n```typescript\nasync function runLoop(\n initialContext: AgentContext,\n newMessages: AgentMessage[],\n config: AgentLoopConfig,\n signal: AbortSignal | undefined,\n emit: AgentEventSink,\n streamFn?: StreamFn,\n): Promise<void> {\n let hasMoreToolCalls = true;\n\n // 内层循环:处理工具调用和 steering 消息\n while (hasMoreToolCalls || pendingMessages.length > 0) {\n // 1. 流式获取 LLM 响应(Reasoning)\n const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);\n \n // 2. 检查是否有工具调用\n const toolCalls = message.content.filter((c) => c.type === \"toolCall\");\n \n // 3. 执行工具调用(Acting)\n if (toolCalls.length > 0) {\n const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);\n hasMoreToolCalls = !executedToolBatch.terminate;\n \n // 4. 工具结果返回给 LLM(Observing)\n for (const result of toolResults) {\n currentContext.messages.push(result);\n }\n }\n \n // 5. 检查是否应该停止\n if (await config.shouldStopAfterTurn?.({ message, toolResults, context: currentContext })) {\n break;\n }\n \n // 6. 获取 steering 消息(用户中途输入)\n pendingMessages = (await config.getSteeringMessages?.()) || [];\n }\n \n // 外层循环:检查 follow-up 消息\n const followUpMessages = (await config.getFollowUpMessages?.()) || [];\n if (followUpMessages.length > 0) {\n pendingMessages = followUpMessages;\n continue; // 继续循环\n }\n}\n```\n\n### 2.2 ReAct 循环图示\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ ReAct Loop │\n├─────────────────────────────────────────────────────────────┤\n│ │\n│ ┌─────────────┐ │\n│ │ 用户输入 │ │\n│ └──────┬──────┘ │\n│ ↓ │\n│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │\n│ │ Reasoning │───→│ Acting │───→│ Observing │ │\n│ │ (LLM 响应) │ │ (执行工具) │ │ (工具结果) │ │\n│ └─────────────┘ └─────────────┘ └──────┬──────┘ │\n│ ↑ │ │\n│ └─────────────────────────────────────┘ │\n│ 循环直到没有工具调用 │\n│ │\n└─────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## 三、为什么不是 Plan-Execute 模式\n\n### 3.1 Plan-Execute 模式的特征\n\n| 特征 | Plan-Execute | SproutClaw (ReAct) |\n|------|-------------|-------------------|\n| **计划阶段** | 独立的计划步骤 | ❌ 没有 |\n| **步骤列表** | 预定义的任务分解 | ❌ LLM 自主决定 |\n| **执行阶段** | 按计划逐步执行 | ✅ 动态决定下一步 |\n| **重新规划** | 失败时重新规划 | ❌ 直接重试或停止 |\n| **步骤存储** | 保存计划状态 | ❌ 无计划状态 |\n\n### 3.2 SproutClaw 的决策方式\n\n```typescript\n// LLM 每次响应时自主决定下一步\nconst message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);\n\n// LLM 可能返回:\n// 1. 纯文本回复(任务完成)\n// 2. 工具调用(需要执行操作)\n// 3. 多个工具调用(并行执行)\n\nconst toolCalls = message.content.filter((c) => c.type === \"toolCall\");\n// LLM 自己决定调用什么工具、传什么参数\n```\n\n**没有预定义的计划列表**,LLM 在每次响应时动态决定下一步。\n\n---\n\n## 四、SproutClaw 的扩展机制\n\n虽然核心是 ReAct,但 SproutClaw 提供了**扩展点**,可以实现类似 Plan-Execute 的行为:\n\n### 4.1 扩展钩子\n\n```typescript\ninterface AgentLoopConfig {\n // 1. 工具调用前拦截\n beforeToolCall?: (context: BeforeToolCallContext) => Promise<BeforeToolCallResult | undefined>;\n \n // 2. 工具调用后拦截\n afterToolCall?: (context: AfterToolCallContext) => Promise<AfterToolCallResult | undefined>;\n \n // 3. 每轮结束后检查是否停止\n shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean | Promise<boolean>;\n \n // 4. 下一轮开始前修改上下文\n prepareNextTurn?: (context: PrepareNextTurnContext) => AgentLoopTurnUpdate | undefined;\n \n // 5. 上下文转换\n transformContext?: (messages: AgentMessage[]) => Promise<AgentMessage[]>;\n}\n```\n\n### 4.2 扩展可以实现的类似 Plan-Execute 行为\n\n```typescript\n// 示例:扩展可以实现任务分解\nextension.on(\"before_agent_start\", async (event) => {\n // 注入任务分解指令\n return {\n systemPrompt: `\n ${event.systemPrompt}\n \n ## 任务分解规则\n 1. 首先分析任务\n 2. 制定执行计划\n 3. 逐步执行\n 4. 验证结果\n `\n };\n});\n\n// 示例:扩展可以在工具调用前检查计划\nextension.on(\"tool_call\", async (event) => {\n // 检查是否符合计划\n if (!isPlanCompliant(event.toolName, event.input)) {\n return { block: true, reason: \"不符合执行计划\" };\n }\n});\n```\n\n---\n\n## 五、与其他 Agent 框架的对比\n\n| 框架 | 核心模式 | 特点 |\n|------|---------|------|\n| **SproutClaw** | ReAct | 简单直接,LLM 自主决策 |\n| **LangChain Agent** | ReAct | 类似,但更复杂 |\n| **AutoGPT** | Plan-Execute | 显式的计划-执行循环 |\n| **CrewAI** | 多 Agent 协作 | 角色分工,任务分配 |\n| **MetaGPT** | SOP 驱动 | 标准化流程 |\n\n---\n\n## 六、总结\n\n| 问题 | 答案 |\n|------|------|\n| SproutClaw 是 ReAct 模式吗? | ✅ **是**,核心循环是典型的 ReAct |\n| 有 Plan-Execute 模式吗? | ❌ **没有**,没有显式的计划阶段 |\n| 能实现类似 Plan-Execute 吗? | ✅ **可以**,通过扩展钩子注入计划逻辑 |\n| 为什么选择 ReAct? | 简单、灵活、LLM 自主决策能力强 |\n\n**SproutClaw 的设计哲学**:让 LLM 自己决定每一步做什么,而不是预先规划好所有步骤。这种模式更灵活,但依赖于 LLM 的推理能力。"}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":4437,"output":2062,"cacheRead":131264,"cacheWrite":0,"totalTokens":137763,"cost":{"input":0.0017748000000000002,"output":0.004124,"cacheRead":0.010501120000000001,"cacheWrite":0,"total":0.016399920000000002}},"stopReason":"stop","timestamp":1781660584542,"responseId":"0afb113619754a23913a643bf1b15b91"}}
|
||
{"type":"message","id":"65040f08","parentId":"10107bde","timestamp":"2026-06-17T02:11:38.997Z","message":{"role":"user","content":[{"type":"text","text":"查看/shumengya/project/agent/mengya-rag项目给我详细介绍一下"}],"timestamp":1781662298996}}
|
||
{"type":"message","id":"f6e553b4","parentId":"65040f08","timestamp":"2026-06-17T02:11:47.218Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户想让我查看 /shumengya/project/agent/mengya-rag 项目并详细介绍。让我先查看这个项目的结构。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_28facaa47fd04338b467d5e7","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/mengya-rag"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":2107,"output":67,"cacheRead":135680,"cacheWrite":0,"totalTokens":137854,"cost":{"input":0.0008428000000000001,"output":0.000134,"cacheRead":0.0108544,"cacheWrite":0,"total":0.0118312}},"stopReason":"toolUse","timestamp":1781662299003,"responseId":"ca089780a35c439db00aa586365fa449"}}
|
||
{"type":"message","id":"b80deec4","parentId":"f6e553b4","timestamp":"2026-06-17T02:11:47.234Z","message":{"role":"toolResult","toolCallId":"call_28facaa47fd04338b467d5e7","toolName":"bash","content":[{"type":"text","text":"总计 596\ndrwxr-xr-x 7 root root 4096 5月12日 17:25 .\ndrwxr-xr-x 5 www www 4096 6月15日 02:03 ..\ndrwxr-xr-x 4 root root 4096 5月12日 16:53 data\n-rw-r--r-- 1 root root 398 5月12日 21:41 .env\n-rw-r--r-- 1 root root 359 5月12日 21:41 .env.example\n-rw-r--r-- 1 root root 76 5月12日 17:09 .gitignore\n-rw-r--r-- 1 root root 623 5月13日 14:16 pyproject.toml\n-rw-r--r-- 1 root root 8402 5月13日 14:22 README.md\ndrwxr-xr-x 2 root root 4096 5月12日 17:13 scripts\ndrwxr-xr-x 3 root root 4096 5月12日 16:53 src\ndrwxr-xr-x 2 root root 4096 5月12日 16:53 tests\n-rw-r--r-- 1 root root 550536 5月12日 21:44 uv.lock\ndrwxr-xr-x 5 root root 4096 5月12日 17:09 .venv\n"}],"isError":false,"timestamp":1781662307234}}
|
||
{"type":"message","id":"d8b5695b","parentId":"b80deec4","timestamp":"2026-06-17T02:11:49.381Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看项目的 README.md 和主要代码文件来了解这个项目。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_ebd97667a75d4a59872d395d","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/README.md"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":553,"output":49,"cacheRead":137728,"cacheWrite":0,"totalTokens":138330,"cost":{"input":0.0002212,"output":0.000098,"cacheRead":0.01101824,"cacheWrite":0,"total":0.01133744}},"stopReason":"toolUse","timestamp":1781662307238,"responseId":"f0d46bba07504b53929e835eb69a70ae"}}
|
||
{"type":"message","id":"d3a17d10","parentId":"d8b5695b","timestamp":"2026-06-17T02:11:49.384Z","message":{"role":"toolResult","toolCallId":"call_ebd97667a75d4a59872d395d","toolName":"read","content":[{"type":"text","text":"# 萌芽 RAG 知识库\n\n轻量版 Obsidian 笔记 RAG 项目,基于 LlamaIndex 和 DeepSeek API。\n检索使用 Markdown 结构感知分块 + 本地小 embedding + BM25 的混合检索,适合个人 Obsidian 笔记这种规模。\n\n## 项目结构\n\n```text\nmengya-rag/\n├── data/\n│ ├── notes/ # 从 bigmengya 同步来的 Obsidian Markdown 笔记\n│ └── index/bm25/ # 本地节点和向量索引\n│ ├── rag.sqlite3 # SQLite + sqlite-vec 本地向量库\n│ └── nodes.jsonl # 分块内容和元数据备份\n├── scripts/\n│ ├── sync_notes.sh # 同步 bigmengya 笔记\n│ ├── build_index.sh # 构建索引\n│ └── ask.sh # 命令行问答\n├── src/mengya_rag/\n│ ├── config.py # 配置读取\n│ ├── markdown_chunker.py # Markdown 结构感知分块\n│ ├── indexing.py # 建索引\n│ ├── retrieval.py # 检索、融合、盘点模式\n│ ├── qa.py # DeepSeek 生成回答\n│ └── cli.py # CLI 命令入口\n├── .env\n├── .env.example\n├── pyproject.toml\n└── README.md\n```\n\n## 技术栈\n\n- RAG 基础框架:LlamaIndex\n- 本地 embedding:`BAAI/bge-small-zh-v1.5`\n- embedding 运行:FastEmbed + ONNX\n- 大模型回答:DeepSeek API\n- 当前推荐模型:`deepseek-v4-flash`\n- 检索方式:BM25 + 向量检索\n- 融合方式:RRF 类融合 + 原始相似度过滤 + 同文件去重\n- 数据库:无\n- 向量库:SQLite + sqlite-vec\n- 服务端:无,当前是命令行工具\n\n## 初始化\n\n```bash\ncd /shumengya/project/agent/mengya-rag\ncp .env.example .env\n```\n\n编辑 `.env`,填入:\n\n```env\nDEEPSEEK_API_KEY=你的 DeepSeek API Key\n```\n\n安装依赖:\n\n```bash\nuv sync\n```\n\n## 同步笔记\n\n笔记源路径:\n\n```text\nbigmengya:/shumengya/docker/mengyanote-backend/data/mengyanote/\n```\n\n同步到本地:\n\n```bash\n./scripts/sync_notes.sh\n# 或\nuv run python scripts/sync_notes.py\n```\n\n## 构建索引\n\n```bash\n./scripts/build_index.sh\n# 或\nuv run python scripts/build_index.py\n```\n\n## 提问\n\n```bash\n./scripts/ask.sh \"萌芽笔记是什么\"\n# 或\nuv run python scripts/ask.py \"萌芽笔记是什么\"\n```\n\n也可以直接使用安装后的命令:\n\n```bash\nuv run mengya-ask \"Docker 常用命令有哪些\"\n```\n\n## Agent 调用推荐\n\n统一入口是 `mengya-rag`。给大模型 Agent 或脚本调用时,推荐固定使用 `--json`,输出是一行 JSON,便于解析。\n\n```bash\n# 查看配置、索引和笔记数量\nuv run mengya-rag status --json\n\n# 只检索,不调用大模型,返回命中文档和内容\nuv run mengya-rag search \"Docker 常用命令\" -k 5 --json\n\n# 返回可直接注入上游大模型的 context 字段\nuv run mengya-rag context \"WireGuard 怎么配置\" -k 4 --json\n\n# 按 search/context 返回的 source 读取完整笔记\nuv run mengya-rag read \"Docker/Docker常用命令总结.md\" --json\n\n# 本项目自己调用 DeepSeek 生成回答\nuv run mengya-rag ask \"Docker 常用命令有哪些\" -k 4 --json\n\n# 同步笔记并重建索引\nuv run mengya-rag reindex --json\n```\n\nAgent 优先使用这几个命令:\n\n- `status --json`:检查索引是否存在、笔记数、节点数、模型配置。\n- `search --json`:拿结构化检索结果,包含 `results[].source`、`title`、`heading_path`、`score`、`content`。\n- `context --json`:拿拼好的 `context` 字符串,适合直接放进其他大模型提示词。\n- `read --json`:按 `source` 读取完整 Markdown 原文,适合检索命中后补充上下文。\n- `ask --json`:让本项目完成 RAG + DeepSeek 回答,返回 `answer` 和 `sources`。\n- `reindex --json`:一条命令完成同步笔记和重建索引。\n\n常用参数:\n\n```bash\n--mode auto|hybrid|inventory # auto 默认;hybrid 普通语义检索;inventory 盘点/目录类检索\n-k, --top-k 数字 # 控制返回条数\n--env-file /path/.env # 指定配置文件\n--notes-dir /path/notes # 覆盖笔记目录\n--index-dir /path/index # 覆盖索引目录\n```\n\n## 查询模式\n\n项目会自动区分两类问题:\n\n- 普通问答:例如“WireGuard 命令怎么用”,走 Markdown 分块 + 向量/BM25 混合检索。\n- 盘点列表:例如“查一下我目前博客文章有哪些”“查一下安卓 Gradle 相关笔记”,走文件级目录检索,优先列出相关 Markdown 文件和内容预览。\n\n## 技术原理\n\n### Markdown 分块\n\n项目不使用固定字符数暴力切割,而是使用结构感知分块:\n\n```text\nMarkdown 文件\n ↓\n解析 frontmatter\n ↓\n识别 H1-H6 标题\n ↓\n按标题层级组织分块\n ↓\n代码块和表格整体保留\n ↓\n长块使用滑动窗口兜底\n```\n\n每个 chunk 前会附加上下文前缀:\n\n```text\n文件: AI/控制台Agent工具安装教程.md\n标题: 控制台Agent工具安装教程\n标题路径: H1 > H2 > H3\n```\n\n每个 chunk 会保存这些元数据:\n\n```text\nsource_file\nrel_path\nfile_name\nfolder_path\ntitle\nheading_path\nh1\nh2\nh3\nh4\nh5\nh6\nchunk_index\ntags\ncreated_at\n```\n\n### 索引构建\n\n执行:\n\n```bash\n./scripts/build_index.sh\n```\n\n构建流程:\n\n```text\nMarkdown 文件\n ↓\n结构感知分块\n ↓\n生成 TextNode\n ↓\n写入 nodes.jsonl\n ↓\n用 bge-small-zh-v1.5 生成 embedding\n ↓\n归一化向量\n ↓\n写入 SQLite + sqlite-vec\n```\n\n当前索引规模参考:\n\n```text\n分块数: 1802\n索引大小: 约 13M\nnodes.jsonl: 约 3.6M\nrag.sqlite3: 约 9.3M,包含 chunk 表和 sqlite-vec 向量表\n```\n\n### 普通问答检索\n\n普通问答适合“怎么做”“是什么”“原理是什么”这类问题。\n\n```text\n用户问题\n ↓\nquery 清洗\n ↓\nBM25 关键词检索\n ↓\n本地向量语义检索\n ↓\nRRF 融合\n ↓\n同文件去重\n ↓\n相关性过滤\n ↓\ntop-k chunk 注入 DeepSeek\n ↓\n生成回答和来源\n```\n\n### 盘点列表检索\n\n盘点列表适合“有哪些”“查一下”“相关笔记”“目录”“清单”这类问题。\n\n```text\n用户问题\n ↓\n识别为盘点类问题\n ↓\n构建文件级摘要节点\n ↓\n文件级 BM25 检索\n ↓\n关键词覆盖过滤\n ↓\n输出 Markdown 文件列表、路径和内容预览\n```\n\n## 重新更新知识库\n\n笔记变更后按顺序执行:\n\n```bash\n./scripts/sync_notes.sh\n./scripts/build_index.sh\n```\n\n## 设计取舍\n\n- Markdown 笔记按结构感知分块:识别 frontmatter、H1-H6、代码块、表格。\n- H1-H6 标题会写入 `h1` 到 `h6` 元数据,同时保存 `heading_path` 面包屑。\n- 代码块和表格整体保留,不跨块切割;长块再用滑动窗口兜底。\n- 每个 chunk 都带 `source_file`、`heading_path`、`chunk_index`、`tags`、`created_at`。\n- 本地 embedding 使用 `BAAI/bge-small-zh-v1.5`,通过 FastEmbed/ONNX 运行,不依赖 PyTorch 和 GPU。\n- 检索采用 BM25 + 向量召回,再用 RRF 融合和同文件去重,避免只靠关键词或只靠语义。\n- 使用 DeepSeek 只负责生成回答:减少 API 调用,只在提问时调用大模型。\n- 本地向量库使用 SQLite + sqlite-vec:无需 Qdrant、Milvus、Postgres 等额外服务。\n\n## 当前效果\n\n目前比较适合处理:\n\n- 某类笔记有哪些\n- 某个教程在哪里\n- 根据笔记总结某个主题\n- 列出博客文章\n- 列出 Agent 安装教程\n- 查 Gradle 构建相关内容\n\n示例:\n\n```bash\n./scripts/ask.sh '查一下我目前博客文章有哪些'\n./scripts/ask.sh '查一下我的安卓gradle构建相关笔记'\n./scripts/ask.sh '查一下我的Agent安装教程'\n```\n\n## 当前限制\n\n- 当前是 SQLite + sqlite-vec 单机向量库,数据量很大后可以换 Qdrant 或 Chroma。\n- 没有 reranker,复杂问题可能还会有弱相关内容混入。\n- embedding 模型是轻量中文模型,效果够用但不是最强。\n- `build_index` 每次是全量重建,还没有增量索引。\n- frontmatter 解析是轻量手写版,不是完整 YAML 解析。\n- 当前是 CLI 工具,没有 Web 页面和 API 服务。\n\n## 后续优化方向\n\n- 增量索引,只重建变更文件。\n- 加 reranker,例如 `bge-reranker-v2-m3`。\n- 给盘点类问题增加目录树模式。\n- 增加 `--debug` 输出检索过程。\n- 数据量变大后接 Qdrant。\n\n## 调参\n\n`.env` 中可调整:\n\n```env\nTOP_K=6\nEMBED_MODEL=BAAI/bge-small-zh-v1.5\nEMBED_BATCH_SIZE=32\nVECTOR_WEIGHT=0.65\nBM25_WEIGHT=0.35\n```\n"}],"isError":false,"timestamp":1781662309384}}
|
||
{"type":"message","id":"7c1c6adb","parentId":"d3a17d10","timestamp":"2026-06-17T02:11:51.365Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"这是一个很详细的 README。让我继续查看源代码文件来更深入了解项目。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_b4e4b75aa72744e88546f8d1","name":"bash","arguments":{"command":"ls -la /shumengya/project/agent/mengya-rag/src/mengya_rag/"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":2628,"output":61,"cacheRead":138240,"cacheWrite":0,"totalTokens":140929,"cost":{"input":0.0010512000000000002,"output":0.000122,"cacheRead":0.0110592,"cacheWrite":0,"total":0.012232400000000001}},"stopReason":"toolUse","timestamp":1781662309388,"responseId":"eac460b014864cff87f867847f10a04a"}}
|
||
{"type":"message","id":"3e5dfcac","parentId":"7c1c6adb","timestamp":"2026-06-17T02:11:51.384Z","message":{"role":"toolResult","toolCallId":"call_b4e4b75aa72744e88546f8d1","toolName":"bash","content":[{"type":"text","text":"总计 80\ndrwxr-xr-x 3 root root 4096 5月13日 14:15 .\ndrwxr-xr-x 3 root root 4096 5月12日 16:53 ..\n-rw-r--r-- 1 root root 18172 5月13日 14:22 cli.py\n-rw-r--r-- 1 root root 2227 5月13日 14:14 config.py\n-rw-r--r-- 1 root root 2448 5月12日 21:35 embeddings.py\n-rw-r--r-- 1 root root 3848 5月12日 21:46 indexing.py\n-rw-r--r-- 1 root root 53 5月12日 17:09 __init__.py\n-rw-r--r-- 1 root root 8592 5月12日 21:07 markdown_chunker.py\ndrwxr-xr-x 2 root root 4096 5月13日 14:23 __pycache__\n-rw-r--r-- 1 root root 2854 5月13日 14:23 qa.py\n-rw-r--r-- 1 root root 11689 5月12日 21:46 retrieval.py\n-rw-r--r-- 1 root root 3530 5月12日 21:46 vector_store.py\n"}],"isError":false,"timestamp":1781662311384}}
|
||
{"type":"message","id":"1c4fc9a2","parentId":"3e5dfcac","timestamp":"2026-06-17T02:11:53.640Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看主要的源代码文件。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_4ac233aec51c47d58505bba4","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/src/mengya_rag/markdown_chunker.py"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":465,"output":51,"cacheRead":140864,"cacheWrite":0,"totalTokens":141380,"cost":{"input":0.00018600000000000002,"output":0.000102,"cacheRead":0.01126912,"cacheWrite":0,"total":0.01155712}},"stopReason":"toolUse","timestamp":1781662311386,"responseId":"d971637dc564433da6eca6cb09980787"}}
|
||
{"type":"message","id":"88609454","parentId":"1c4fc9a2","timestamp":"2026-06-17T02:11:53.643Z","message":{"role":"toolResult","toolCallId":"call_4ac233aec51c47d58505bba4","toolName":"read","content":[{"type":"text","text":"from __future__ import annotations\n\nimport re\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\n\nfrom llama_index.core.schema import TextNode\n\n\nTARGET_TOKENS = 512\nMAX_TOKENS = 1024\nOVERLAP_TOKENS = 96\n\n\n@dataclass\nclass MarkdownBlock:\n kind: str\n text: str\n level: int | None = None\n title: str = \"\"\n\n\n@dataclass\nclass Section:\n headings: dict[int, str]\n blocks: list[MarkdownBlock] = field(default_factory=list)\n\n\ndef extract_frontmatter(text: str) -> tuple[dict[str, object], str]:\n if not text.startswith(\"---\\n\"):\n return {}, text\n\n end = text.find(\"\\n---\", 4)\n if end == -1:\n return {}, text\n\n raw = text[4:end].strip()\n body = text[end + len(\"\\n---\") :].lstrip(\"\\n\")\n metadata: dict[str, object] = {}\n lines = raw.splitlines()\n index = 0\n\n while index < len(lines):\n line = lines[index]\n if \":\" not in line:\n index += 1\n continue\n\n key, value = line.split(\":\", 1)\n key = key.strip()\n value = value.strip()\n\n if value:\n if value.startswith(\"[\") and value.endswith(\"]\"):\n metadata[key] = [\n item.strip().strip(\"'\\\"\")\n for item in value[1:-1].split(\",\")\n if item.strip()\n ]\n else:\n metadata[key] = value.strip(\"'\\\"\")\n index += 1\n continue\n\n items: list[str] = []\n index += 1\n while index < len(lines) and lines[index].lstrip().startswith(\"- \"):\n items.append(lines[index].split(\"- \", 1)[1].strip().strip(\"'\\\"\"))\n index += 1\n metadata[key] = items\n\n return metadata, body\n\n\ndef parse_markdown_blocks(text: str) -> list[MarkdownBlock]:\n lines = text.splitlines()\n blocks: list[MarkdownBlock] = []\n paragraph: list[str] = []\n index = 0\n\n def flush_paragraph() -> None:\n nonlocal paragraph\n if paragraph:\n blocks.append(MarkdownBlock(kind=\"text\", text=\"\\n\".join(paragraph).strip()))\n paragraph = []\n\n while index < len(lines):\n line = lines[index]\n stripped = line.strip()\n\n if stripped.startswith(\"```\") or stripped.startswith(\"~~~\"):\n flush_paragraph()\n fence = stripped[:3]\n code_lines = [line]\n index += 1\n while index < len(lines):\n code_lines.append(lines[index])\n if lines[index].strip().startswith(fence):\n index += 1\n break\n index += 1\n blocks.append(MarkdownBlock(kind=\"code\", text=\"\\n\".join(code_lines)))\n continue\n\n heading = re.match(r\"^(#{1,6})\\s+(.+?)\\s*$\", line)\n if heading:\n flush_paragraph()\n level = len(heading.group(1))\n title = heading.group(2).strip()\n blocks.append(\n MarkdownBlock(kind=\"heading\", text=line.strip(), level=level, title=title)\n )\n index += 1\n continue\n\n if stripped.startswith(\"|\") and \"|\" in stripped[1:]:\n flush_paragraph()\n table_lines = [line]\n index += 1\n while index < len(lines):\n next_line = lines[index]\n if not next_line.strip().startswith(\"|\"):\n break\n table_lines.append(next_line)\n index += 1\n blocks.append(MarkdownBlock(kind=\"table\", text=\"\\n\".join(table_lines)))\n continue\n\n if not stripped:\n flush_paragraph()\n index += 1\n continue\n\n paragraph.append(line)\n index += 1\n\n flush_paragraph()\n return blocks\n\n\ndef build_sections(blocks: list[MarkdownBlock]) -> list[Section]:\n sections: list[Section] = []\n headings: dict[int, str] = {}\n current = Section(headings={})\n\n for block in blocks:\n if block.kind != \"heading\":\n current.blocks.append(block)\n continue\n\n level = block.level or 1\n for old_level in list(headings):\n if old_level >= level:\n del headings[old_level]\n headings[level] = block.title\n current_headings = dict(headings)\n\n if level <= 2 or token_count(current.blocks) >= TARGET_TOKENS:\n if current.blocks:\n sections.append(current)\n current = Section(headings=current_headings, blocks=[block])\n continue\n\n current.blocks.append(block)\n current.headings = current_headings\n\n if current.blocks:\n sections.append(current)\n return sections\n\n\ndef token_count(blocks: list[MarkdownBlock]) -> int:\n text = \"\\n\".join(block.text for block in blocks)\n return estimate_tokens(text)\n\n\ndef estimate_tokens(text: str) -> int:\n chinese = len(re.findall(r\"[\\u4e00-\\u9fff]\", text))\n latin = len(re.findall(r\"[A-Za-z0-9_#+.-]+\", text))\n other = max(len(text) - chinese, 0) // 6\n return chinese + latin + other\n\n\ndef split_section_blocks(section: Section) -> list[list[MarkdownBlock]]:\n chunks: list[list[MarkdownBlock]] = []\n current: list[MarkdownBlock] = []\n\n for block in section.blocks:\n block_tokens = estimate_tokens(block.text)\n current_tokens = token_count(current)\n\n if block.kind in {\"code\", \"table\"}:\n if current and current_tokens + block_tokens > MAX_TOKENS:\n chunks.append(current)\n current = []\n current.append(block)\n continue\n\n if current and current_tokens + block_tokens > MAX_TOKENS:\n chunks.append(current)\n current = overlap_tail(current)\n\n current.append(block)\n\n if current:\n chunks.append(current)\n return chunks\n\n\ndef overlap_tail(blocks: list[MarkdownBlock]) -> list[MarkdownBlock]:\n kept: list[MarkdownBlock] = []\n total = 0\n for block in reversed(blocks):\n if block.kind in {\"code\", \"table\"}:\n continue\n count = estimate_tokens(block.text)\n if kept and total + count > OVERLAP_TOKENS:\n break\n kept.insert(0, block)\n total += count\n return kept\n\n\ndef chunk_markdown_file(path: Path, notes_dir: Path) -> list[TextNode]:\n text = path.read_text(encoding=\"utf-8\", errors=\"ignore\")\n frontmatter, body = extract_frontmatter(text)\n blocks = parse_markdown_blocks(body)\n sections = build_sections(blocks)\n\n rel_path = path.relative_to(notes_dir)\n title = path.stem\n mtime = path.stat().st_mtime\n tags = frontmatter.get(\"tags\", [])\n if isinstance(tags, str):\n tags = [tags]\n\n nodes: list[TextNode] = []\n chunk_index = 0\n for section in sections:\n heading_path = heading_path_from_levels(section.headings)\n prefix = build_context_prefix(title, rel_path, heading_path)\n for chunk_blocks in split_section_blocks(section):\n chunk_text = \"\\n\\n\".join(block.text for block in chunk_blocks).strip()\n if not chunk_text:\n continue\n node_text = f\"{prefix}\\n\\n{chunk_text}\".strip()\n nodes.append(\n TextNode(\n text=node_text,\n metadata={\n \"source_file\": str(rel_path),\n \"rel_path\": str(rel_path),\n \"file_name\": path.name,\n \"folder_path\": str(rel_path.parent),\n \"title\": title,\n \"heading_path\": \" > \".join(heading_path),\n \"h1\": section.headings.get(1, \"\"),\n \"h2\": section.headings.get(2, \"\"),\n \"h3\": section.headings.get(3, \"\"),\n \"h4\": section.headings.get(4, \"\"),\n \"h5\": section.headings.get(5, \"\"),\n \"h6\": section.headings.get(6, \"\"),\n \"chunk_index\": chunk_index,\n \"tags\": tags,\n \"created_at\": mtime,\n },\n )\n )\n chunk_index += 1\n\n return nodes\n\n\ndef heading_path_from_levels(headings: dict[int, str]) -> list[str]:\n return [headings[level] for level in sorted(headings)]\n\n\ndef build_context_prefix(title: str, rel_path: Path, heading_path: list[str]) -> str:\n lines = [\n f\"文件: {rel_path}\",\n f\"标题: {title}\",\n ]\n if heading_path:\n lines.append(f\"标题路径: {' > '.join(heading_path)}\")\n return \"\\n\".join(lines)\n"}],"isError":false,"timestamp":1781662313643}}
|
||
{"type":"message","id":"d752149e","parentId":"88609454","timestamp":"2026-06-17T02:11:56.393Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我继续查看其他核心文件。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_0d3deae170ed4a48af5470c1","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/src/mengya_rag/retrieval.py"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1976,"output":49,"cacheRead":141312,"cacheWrite":0,"totalTokens":143337,"cost":{"input":0.0007904,"output":0.000098,"cacheRead":0.011304960000000001,"cacheWrite":0,"total":0.01219336}},"stopReason":"toolUse","timestamp":1781662313648,"responseId":"622451cbfb4244b487f8a113f07a5bba"}}
|
||
{"type":"message","id":"0aff7172","parentId":"d752149e","timestamp":"2026-06-17T02:11:56.396Z","message":{"role":"toolResult","toolCallId":"call_0d3deae170ed4a48af5470c1","toolName":"read","content":[{"type":"text","text":"from __future__ import annotations\n\nimport math\nimport re\nfrom collections import Counter\nfrom dataclasses import dataclass\n\nimport numpy as np\nfrom llama_index.core.schema import TextNode\n\nfrom .config import Settings\nfrom .embeddings import embed_query\nfrom .indexing import chinese_tokenizer\nfrom .vector_store import db_path, search_vectors\n\n\n@dataclass(frozen=True)\nclass RetrievedNode:\n node: TextNode\n score: float\n raw_score: float = 0.0\n\n\ndef clean_query(query: str) -> str:\n query = query.strip()\n for phrase in [\n \"请根据笔记简要回答\",\n \"请根据笔记回答\",\n \"根据笔记简要回答\",\n \"根据笔记回答\",\n \"简要回答\",\n \"有哪些\",\n \"是什么\",\n ]:\n query = query.replace(phrase, \" \")\n for word in [\n \"我的\",\n \"我\",\n \"内容\",\n \"请\",\n \"根据\",\n \"简要\",\n \"回答\",\n \"哪些\",\n \"什么\",\n \"一下\",\n ]:\n query = query.replace(word, \" \")\n query = re.sub(r\"[,。!?、;:,.!?;:]+\", \" \", query)\n return \" \".join(query.split())\n\n\ndef inventory_keywords(query: str) -> list[str]:\n stopwords = {\n \"查一下\",\n \"目前\",\n \"我的\",\n \"我\",\n \"相关\",\n \"笔记\",\n \"教程\",\n \"有哪些\",\n \"有什么\",\n \"列表\",\n \"清单\",\n \"目录\",\n \"安装\",\n }\n words: list[str] = []\n cleaned = clean_query(query).lower()\n for part in re.findall(r\"[a-z0-9_#+.-]+|[\\u4e00-\\u9fff]+\", cleaned):\n if part in stopwords:\n continue\n if re.fullmatch(r\"[a-z0-9_#+.-]+\", part):\n words.append(part)\n continue\n for stopword in stopwords:\n part = part.replace(stopword, \"\")\n if len(part) >= 2:\n words.append(part)\n return words\n\n\nclass LocalBM25:\n def __init__(self, nodes: list[TextNode]) -> None:\n self.nodes = nodes\n self.doc_tokens = [chinese_tokenizer(self._weighted_text(node)) for node in nodes]\n self.doc_freq: Counter[str] = Counter()\n self.term_freqs: list[Counter[str]] = []\n\n for tokens in self.doc_tokens:\n term_freq = Counter(tokens)\n self.term_freqs.append(term_freq)\n self.doc_freq.update(term_freq.keys())\n\n self.doc_count = len(nodes)\n self.doc_lengths = [len(tokens) for tokens in self.doc_tokens]\n self.avg_doc_length = (\n sum(self.doc_lengths) / self.doc_count if self.doc_count else 0.0\n )\n\n def _weighted_text(self, node: TextNode) -> str:\n title = str(node.metadata.get(\"title\", \"\"))\n rel_path = str(node.metadata.get(\"rel_path\", \"\"))\n path_text = rel_path.replace(\"/\", \" \").replace(\".md\", \"\")\n content = node.get_content()\n\n return \"\\n\".join(\n [\n \" \".join([title] * 8),\n \" \".join([path_text] * 4),\n content,\n ]\n )\n\n def search(self, query: str, top_k: int) -> list[RetrievedNode]:\n query_terms = chinese_tokenizer(query)\n if not query_terms or not self.nodes:\n return []\n\n k1 = 1.5\n b = 0.75\n scored_by_file: dict[str, RetrievedNode] = {}\n extra_scores_by_file: Counter[str] = Counter()\n\n for index, term_freq in enumerate(self.term_freqs):\n score = 0.0\n doc_length = self.doc_lengths[index] or 1\n\n for term in query_terms:\n freq = term_freq.get(term, 0)\n if freq == 0:\n continue\n\n doc_freq = self.doc_freq.get(term, 0)\n idf = math.log(1 + (self.doc_count - doc_freq + 0.5) / (doc_freq + 0.5))\n denom = freq + k1 * (\n 1 - b + b * doc_length / (self.avg_doc_length or 1)\n )\n score += idf * freq * (k1 + 1) / denom\n\n if score <= 0:\n continue\n\n node = self.nodes[index]\n rel_path = str(node.metadata.get(\"rel_path\", node.node_id))\n current = scored_by_file.get(rel_path)\n if current is None or score > current.score:\n if current is not None:\n extra_scores_by_file[rel_path] += current.score * 0.2\n scored_by_file[rel_path] = RetrievedNode(\n node=node,\n score=score,\n raw_score=score,\n )\n else:\n extra_scores_by_file[rel_path] += score * 0.2\n\n scored = [\n RetrievedNode(\n node=item.node,\n score=item.score + extra_scores_by_file[rel_path],\n raw_score=item.raw_score,\n )\n for rel_path, item in scored_by_file.items()\n ]\n scored.sort(key=lambda item: item.score, reverse=True)\n return scored[:top_k]\n\n\nclass HybridRetriever:\n def __init__(self, settings: Settings, nodes: list[TextNode]) -> None:\n self.settings = settings\n self.nodes = nodes\n self.bm25 = LocalBM25(nodes)\n\n def search(self, query: str, top_k: int) -> list[RetrievedNode]:\n query = clean_query(query)\n candidate_k = min(max(top_k * 8, 30), len(self.nodes))\n bm25_results = self.bm25.search(query, candidate_k)\n vector_results = self._vector_search(query, candidate_k)\n return self._fuse_results(bm25_results, vector_results, top_k)\n\n def _vector_search(self, query: str, top_k: int) -> list[RetrievedNode]:\n query_vector = embed_query(self.settings, query)\n rows = search_vectors(db_path(self.settings.index_dir), query_vector, top_k)\n results: list[RetrievedNode] = []\n for rowid, distance in rows:\n index = rowid - 1\n if index < 0 or index >= len(self.nodes):\n continue\n score = 1.0 / (1.0 + distance)\n results.append(\n RetrievedNode(\n node=self.nodes[index],\n score=score,\n raw_score=score,\n )\n )\n return results\n\n def _fuse_results(\n self,\n bm25_results: list[RetrievedNode],\n vector_results: list[RetrievedNode],\n top_k: int,\n ) -> list[RetrievedNode]:\n fused: dict[str, tuple[TextNode, float]] = {}\n seen_files: set[str] = set()\n\n def add(results: list[RetrievedNode], weight: float) -> None:\n if not results:\n return\n best_raw = max(item.raw_score or item.score for item in results)\n if best_raw <= 0:\n return\n\n for rank, item in enumerate(results, start=1):\n key = item.node.node_id\n raw = item.raw_score or item.score\n normalized = raw / best_raw\n if normalized < 0.18:\n continue\n score = weight * normalized / (60 + rank)\n node, current = fused.get(key, (item.node, 0.0))\n fused[key] = (node, current + score)\n\n add(bm25_results, self.settings.bm25_weight)\n add(vector_results, self.settings.vector_weight)\n\n ranked = sorted(fused.values(), key=lambda pair: pair[1], reverse=True)\n if not ranked:\n return []\n\n best_score = ranked[0][1]\n final: list[RetrievedNode] = []\n for node, score in ranked:\n if score < best_score * 0.7 and len(final) >= 1:\n continue\n rel_path = str(node.metadata.get(\"rel_path\", node.node_id))\n if rel_path in seen_files:\n continue\n seen_files.add(rel_path)\n final.append(RetrievedNode(node=node, score=score))\n if len(final) >= top_k:\n break\n\n return final\n\n\ndef is_inventory_query(query: str) -> bool:\n return any(\n word in query\n for word in [\"有哪些\", \"有什么\", \"哪些\", \"列表\", \"清单\", \"目录\", \"查一下\", \"相关笔记\"]\n )\n\n\ndef build_file_nodes(nodes: list[TextNode]) -> list[TextNode]:\n by_path: dict[str, list[TextNode]] = {}\n for node in nodes:\n rel_path = str(node.metadata.get(\"rel_path\", node.node_id))\n by_path.setdefault(rel_path, []).append(node)\n\n file_nodes: list[TextNode] = []\n for rel_path, file_chunks in by_path.items():\n first = file_chunks[0]\n title = str(first.metadata.get(\"title\", rel_path.rsplit(\"/\", 1)[-1]))\n preview = \"\\n\".join(\n chunk.get_content().strip() for chunk in file_chunks[:2] if chunk.get_content().strip()\n )\n text = f\"标题: {title}\\n路径: {rel_path}\\n内容预览:\\n{preview[:1200]}\"\n file_nodes.append(\n TextNode(\n text=text,\n metadata={\n \"rel_path\": rel_path,\n \"title\": title,\n \"is_file_summary\": True,\n },\n )\n )\n return file_nodes\n\n\nclass InventoryRetriever:\n def __init__(self, nodes: list[TextNode]) -> None:\n self.file_nodes = build_file_nodes(nodes)\n self.bm25 = LocalBM25(self.file_nodes)\n\n def search(self, query: str, top_k: int) -> list[RetrievedNode]:\n query = clean_query(query)\n direct = self._direct_directory_results(query, top_k)\n if direct:\n return direct\n\n results = self.bm25.search(query, top_k)\n results = self._filter_by_keywords(query, results)\n if not results:\n return []\n\n best = results[0].score\n kept = [item for item in results if item.score >= best * 0.4]\n return kept[:top_k]\n\n def _filter_by_keywords(\n self,\n query: str,\n results: list[RetrievedNode],\n ) -> list[RetrievedNode]:\n keywords = inventory_keywords(query)\n if not keywords:\n return results\n\n latin_keywords = [\n keyword\n for keyword in keywords\n if re.fullmatch(r\"[a-z0-9_#+.-]+\", keyword)\n ]\n\n filtered: list[RetrievedNode] = []\n for item in results:\n haystack = (\n f\"{item.node.metadata.get('title', '')} \"\n f\"{item.node.metadata.get('rel_path', '')} \"\n f\"{item.node.get_content()}\"\n ).lower()\n if latin_keywords and not all(keyword in haystack for keyword in latin_keywords):\n continue\n\n matched = sum(1 for keyword in keywords if keyword in haystack)\n if matched == 0:\n continue\n if len(keywords) >= 2 and matched / len(keywords) < 0.5:\n continue\n filtered.append(item)\n\n return filtered or results[: min(3, len(results))]\n\n def _direct_directory_results(self, query: str, top_k: int) -> list[RetrievedNode]:\n candidates = [\n node\n for node in self.file_nodes\n if str(node.metadata.get(\"rel_path\", \"\")).startswith(f\"{query}/\")\n ]\n if not candidates and query.endswith(\"文章\"):\n candidates = [\n node\n for node in self.file_nodes\n if str(node.metadata.get(\"rel_path\", \"\")).startswith(f\"{query}/\")\n ]\n if not candidates:\n return []\n\n candidates.sort(key=lambda node: str(node.metadata.get(\"rel_path\", \"\")))\n return [\n RetrievedNode(node=node, score=float(top_k - index), raw_score=float(top_k - index))\n for index, node in enumerate(candidates[:top_k])\n ]\n"}],"isError":false,"timestamp":1781662316395}}
|
||
{"type":"message","id":"835a6dee","parentId":"0aff7172","timestamp":"2026-06-17T02:12:02.984Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我继续查看其他核心文件。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_4fe3153cae8a4672b1186b78","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/src/mengya_rag/cli.py"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":2797,"output":47,"cacheRead":143232,"cacheWrite":0,"totalTokens":146076,"cost":{"input":0.0011188,"output":0.000094,"cacheRead":0.01145856,"cacheWrite":0,"total":0.01267136}},"stopReason":"toolUse","timestamp":1781662316400,"responseId":"82c8baf2502e409083c2bbd6167acbf8"}}
|
||
{"type":"message","id":"a4a19040","parentId":"835a6dee","timestamp":"2026-06-17T02:12:02.995Z","message":{"role":"toolResult","toolCallId":"call_4fe3153cae8a4672b1186b78","toolName":"read","content":[{"type":"text","text":"from __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\nimport time\nfrom pathlib import Path\nfrom typing import Any\n\nos.environ.setdefault(\"TRANSFORMERS_VERBOSITY\", \"error\")\n\nfrom .config import Settings, load_settings, override_settings\nfrom .indexing import build_bm25_index, load_index_nodes, markdown_files\nfrom .indexing import sync_notes as sync_notes_impl\nfrom .vector_store import db_path\n\n\ndef _positive_int(value: str) -> int:\n parsed = int(value)\n if parsed <= 0:\n raise argparse.ArgumentTypeError(\"必须是大于 0 的整数\")\n return parsed\n\n\ndef _existing_file(value: str) -> Path:\n path = Path(value)\n if not path.exists():\n raise argparse.ArgumentTypeError(f\"文件不存在: {path}\")\n if not path.is_file():\n raise argparse.ArgumentTypeError(f\"不是文件: {path}\")\n return path\n\n\ndef _add_config_args(parser: argparse.ArgumentParser) -> None:\n parser.add_argument(\n \"--env-file\",\n type=_existing_file,\n default=argparse.SUPPRESS,\n help=\"指定 .env 配置文件\",\n )\n parser.add_argument(\n \"--notes-dir\",\n type=Path,\n default=argparse.SUPPRESS,\n help=\"覆盖 NOTES_DIR\",\n )\n parser.add_argument(\n \"--index-dir\",\n type=Path,\n default=argparse.SUPPRESS,\n help=\"覆盖 INDEX_DIR\",\n )\n\n\ndef _add_output_args(parser: argparse.ArgumentParser) -> None:\n parser.add_argument(\n \"--format\",\n choices=(\"text\", \"json\"),\n default=argparse.SUPPRESS,\n help=\"输出格式,Agent 调用建议使用 json\",\n )\n parser.add_argument(\n \"--json\",\n action=\"store_true\",\n default=argparse.SUPPRESS,\n help=\"等同于 --format json\",\n )\n\n\ndef _add_retrieval_args(parser: argparse.ArgumentParser) -> None:\n parser.add_argument(\n \"--mode\",\n choices=(\"auto\", \"hybrid\", \"inventory\"),\n default=\"auto\",\n help=\"检索模式,默认 auto 自动判断\",\n )\n parser.add_argument(\"-k\", \"--top-k\", type=_positive_int, help=\"覆盖返回条数\")\n\n\ndef _settings_from_args(args: argparse.Namespace) -> Settings:\n settings = load_settings(getattr(args, \"env_file\", None))\n return override_settings(\n settings,\n notes_dir=getattr(args, \"notes_dir\", None),\n index_dir=getattr(args, \"index_dir\", None),\n )\n\n\ndef _json_default(value: object) -> str:\n if isinstance(value, Path):\n return str(value)\n raise TypeError(f\"Object of type {type(value).__name__} is not JSON serializable\")\n\n\ndef _write_json(payload: dict[str, Any]) -> None:\n print(json.dumps(payload, ensure_ascii=False, default=_json_default))\n\n\ndef _wants_json(args: argparse.Namespace) -> bool:\n return bool(getattr(args, \"json\", False) or getattr(args, \"format\", \"text\") == \"json\")\n\n\ndef _emit(args: argparse.Namespace, payload: dict[str, Any], text: str | None = None) -> None:\n if _wants_json(args):\n _write_json(payload)\n return\n if text is not None:\n print(text)\n\n\ndef _fail(args: argparse.Namespace, message: str, *, code: int = 1) -> None:\n if _wants_json(args):\n _write_json({\"ok\": False, \"error\": message})\n raise SystemExit(code)\n raise SystemExit(message)\n\n\ndef _question_from_args(args: argparse.Namespace) -> str:\n question = \" \".join(getattr(args, \"question\", [])).strip()\n if not question and not sys.stdin.isatty():\n question = sys.stdin.read().strip()\n return question\n\n\ndef _print_sources(sources: list[str]) -> None:\n if not sources:\n print(\"\\n来源: 无\")\n return\n print(\"\\n来源:\")\n for source in sources:\n print(f\"- {source}\")\n\n\ndef _preview_text(text: str, limit: int) -> str:\n text = \" \".join(text.strip().split())\n if len(text) <= limit:\n return text\n return f\"{text[:limit].rstrip()}...\"\n\n\ndef _resolve_note_path(settings: Settings, source: str) -> Path:\n notes_root = settings.notes_dir.resolve()\n note_path = (notes_root / source).resolve()\n try:\n note_path.relative_to(notes_root)\n except ValueError as exc:\n raise ValueError(f\"笔记路径不能越过 NOTES_DIR: {source}\") from exc\n if not note_path.exists():\n raise FileNotFoundError(f\"笔记不存在: {source}\")\n if not note_path.is_file():\n raise IsADirectoryError(f\"不是笔记文件: {source}\")\n return note_path\n\n\ndef _source_text(item: Any) -> str:\n metadata = item.node.metadata\n rel_path = metadata.get(\"rel_path\") or metadata.get(\"file_path\") or \"未知来源\"\n return f\"{rel_path} score={item.score:.4f}\"\n\n\ndef _node_payload(item: Any, *, include_content: bool = True) -> dict[str, Any]:\n metadata = item.node.metadata\n payload: dict[str, Any] = {\n \"source\": metadata.get(\"rel_path\") or metadata.get(\"file_path\") or \"未知来源\",\n \"title\": metadata.get(\"title\", \"\"),\n \"heading_path\": metadata.get(\"heading_path\", \"\"),\n \"chunk_index\": metadata.get(\"chunk_index\"),\n \"score\": item.score,\n \"raw_score\": item.raw_score,\n \"metadata\": dict(metadata),\n }\n if include_content:\n payload[\"content\"] = item.node.get_content().strip()\n return payload\n\n\ndef _retrieve(args: argparse.Namespace) -> list[Any]:\n from .qa import retrieve_nodes\n\n question = _question_from_args(args)\n if not question:\n _fail(args, \"请提供检索内容,例如: mengya-rag search Docker\")\n\n settings = _settings_from_args(args)\n try:\n return retrieve_nodes(\n settings,\n question,\n mode=args.mode,\n top_k=args.top_k,\n )\n except Exception as exc:\n _fail(args, f\"检索失败: {exc}\")\n\n\ndef _sync_notes(args: argparse.Namespace) -> int:\n settings = _settings_from_args(args)\n started = time.time()\n try:\n sync_notes_impl(settings)\n except Exception as exc:\n _fail(args, f\"同步失败: {exc}\")\n\n payload = {\n \"ok\": True,\n \"command\": \"sync\",\n \"notes_remote\": settings.notes_remote,\n \"notes_dir\": settings.notes_dir,\n \"elapsed_seconds\": round(time.time() - started, 3),\n }\n _emit(args, payload, f\"笔记已同步到: {settings.notes_dir}\")\n return 0\n\n\ndef _build_index(args: argparse.Namespace) -> int:\n settings = _settings_from_args(args)\n started = time.time()\n try:\n count = build_bm25_index(settings)\n except Exception as exc:\n _fail(args, f\"构建索引失败: {exc}\")\n\n payload = {\n \"ok\": True,\n \"command\": \"index\",\n \"index_dir\": settings.index_dir,\n \"db_path\": db_path(settings.index_dir),\n \"node_count\": count,\n \"elapsed_seconds\": round(time.time() - started, 3),\n }\n text = f\"索引已生成: {settings.index_dir}\\n索引分块数: {count}\"\n _emit(args, payload, text)\n return 0\n\n\ndef _reindex(args: argparse.Namespace) -> int:\n settings = _settings_from_args(args)\n started = time.time()\n try:\n sync_notes_impl(settings)\n count = build_bm25_index(settings)\n except Exception as exc:\n _fail(args, f\"更新知识库失败: {exc}\")\n\n payload = {\n \"ok\": True,\n \"command\": \"reindex\",\n \"notes_remote\": settings.notes_remote,\n \"notes_dir\": settings.notes_dir,\n \"index_dir\": settings.index_dir,\n \"db_path\": db_path(settings.index_dir),\n \"node_count\": count,\n \"elapsed_seconds\": round(time.time() - started, 3),\n }\n text = f\"知识库已更新: {settings.index_dir}\\n索引分块数: {count}\"\n _emit(args, payload, text)\n return 0\n\n\ndef _ask(args: argparse.Namespace) -> int:\n from .qa import ask_question\n\n question = _question_from_args(args)\n if not question:\n _fail(args, \"请提供问题,例如: mengya-rag ask Docker 常用命令有哪些\")\n\n settings = _settings_from_args(args)\n try:\n answer, sources = ask_question(\n settings,\n question,\n mode=args.mode,\n top_k=args.top_k,\n )\n except Exception as exc:\n _fail(args, f\"提问失败: {exc}\")\n\n payload = {\n \"ok\": True,\n \"command\": \"ask\",\n \"question\": question,\n \"answer\": answer,\n \"sources\": sources,\n \"mode\": args.mode,\n \"top_k\": args.top_k,\n }\n if _wants_json(args):\n _write_json(payload)\n else:\n print(answer)\n if not args.no_source:\n _print_sources(sources)\n return 0\n\n\ndef _search(args: argparse.Namespace) -> int:\n question = _question_from_args(args)\n if not question:\n _fail(args, \"请提供检索内容,例如: mengya-rag search Docker\")\n\n nodes = _retrieve(args)\n include_content = _wants_json(args) or args.show_content\n\n if not nodes:\n _emit(\n args,\n {\n \"ok\": True,\n \"command\": \"search\",\n \"question\": question,\n \"results\": [],\n },\n \"未检索到相关内容。\",\n )\n return 0\n\n payload = {\n \"ok\": True,\n \"command\": \"search\",\n \"question\": question,\n \"mode\": args.mode,\n \"top_k\": args.top_k,\n \"results\": [\n _node_payload(item, include_content=include_content) for item in nodes\n ],\n }\n if _wants_json(args):\n _write_json(payload)\n return 0\n\n for index, item in enumerate(nodes, start=1):\n metadata = item.node.metadata\n heading_path = metadata.get(\"heading_path\", \"\")\n print(f\"{index}. {_source_text(item)}\")\n if heading_path:\n print(f\" 标题路径: {heading_path}\")\n if args.show_content:\n preview = _preview_text(item.node.get_content(), args.preview_chars)\n print(f\" 预览: {preview}\")\n return 0\n\n\ndef _context(args: argparse.Namespace) -> int:\n question = _question_from_args(args)\n if not question:\n _fail(args, \"请提供问题,例如: mengya-rag context Docker 常用命令\")\n\n nodes = _retrieve(args)\n context_parts: list[str] = []\n sources: list[dict[str, Any]] = []\n for index, item in enumerate(nodes, start=1):\n metadata = item.node.metadata\n source = metadata.get(\"rel_path\") or metadata.get(\"file_path\") or \"未知来源\"\n content = item.node.get_content().strip()\n context_parts.append(f\"[{index}] 来源: {source}\\n{content}\")\n sources.append(_node_payload(item, include_content=False))\n\n context = \"\\n\\n\".join(context_parts)\n payload = {\n \"ok\": True,\n \"command\": \"context\",\n \"question\": question,\n \"mode\": args.mode,\n \"top_k\": args.top_k,\n \"context\": context,\n \"sources\": sources,\n }\n if _wants_json(args):\n _write_json(payload)\n else:\n print(context or \"未检索到相关内容。\")\n return 0\n\n\ndef _read(args: argparse.Namespace) -> int:\n settings = _settings_from_args(args)\n source = args.source.strip()\n if not source:\n _fail(args, \"请提供笔记相对路径,例如: mengya-rag read Docker/Docker常用命令总结.md\")\n\n try:\n path = _resolve_note_path(settings, source)\n content = path.read_text(encoding=\"utf-8\")\n except Exception as exc:\n _fail(args, f\"读取笔记失败: {exc}\")\n\n original_length = len(content)\n if args.max_chars is not None:\n content = content[: args.max_chars]\n\n payload = {\n \"ok\": True,\n \"command\": \"read\",\n \"source\": source,\n \"path\": path,\n \"content\": content,\n \"truncated\": args.max_chars is not None and original_length > len(content),\n }\n _emit(args, payload, content)\n return 0\n\n\ndef _status(args: argparse.Namespace) -> int:\n settings = _settings_from_args(args)\n db_file = db_path(settings.index_dir)\n nodes_file = settings.index_dir / \"nodes.jsonl\"\n\n note_count: int | None = None\n note_error: str | None = None\n try:\n note_count = len(markdown_files(settings.notes_dir))\n except Exception as exc:\n note_error = str(exc)\n\n node_count: int | None = None\n node_error: str | None = None\n try:\n node_count = len(load_index_nodes(settings))\n except Exception as exc:\n node_error = str(exc)\n\n payload = {\n \"ok\": True,\n \"command\": \"status\",\n \"project_root\": Path(__file__).resolve().parents[2],\n \"notes_dir\": settings.notes_dir,\n \"index_dir\": settings.index_dir,\n \"db_path\": db_file,\n \"db_exists\": db_file.exists(),\n \"nodes_file\": nodes_file,\n \"nodes_file_exists\": nodes_file.exists(),\n \"deepseek_api_key_configured\": bool(settings.deepseek_api_key),\n \"deepseek_model\": settings.deepseek_model,\n \"embed_backend\": settings.embed_backend,\n \"embed_model\": settings.embed_model,\n \"top_k\": settings.top_k,\n \"list_top_k\": settings.list_top_k,\n \"markdown_note_count\": note_count,\n \"markdown_note_error\": note_error,\n \"index_node_count\": node_count,\n \"index_node_error\": node_error,\n }\n text_lines = [\n f\"项目目录: {payload['project_root']}\",\n f\"笔记目录: {settings.notes_dir}\",\n f\"索引目录: {settings.index_dir}\",\n f\"向量库: {db_file} ({'存在' if db_file.exists() else '不存在'})\",\n f\"节点文件: {nodes_file} ({'存在' if nodes_file.exists() else '不存在'})\",\n f\"DeepSeek Key: {'已配置' if settings.deepseek_api_key else '未配置'}\",\n f\"DeepSeek 模型: {settings.deepseek_model}\",\n f\"Embedding: {settings.embed_backend} / {settings.embed_model}\",\n f\"默认 top_k: {settings.top_k}\",\n f\"默认 list_top_k: {settings.list_top_k}\",\n f\"Markdown 笔记数: {note_count if note_error is None else f'读取失败 ({note_error})'}\",\n f\"索引节点数: {node_count if node_error is None else f'读取失败 ({node_error})'}\",\n ]\n _emit(args, payload, \"\\n\".join(text_lines))\n return 0\n\n\ndef _add_question_arg(parser: argparse.ArgumentParser) -> None:\n parser.add_argument(\"question\", nargs=\"*\", help=\"问题;省略时从 stdin 读取\")\n\n\ndef build_parser(prog: str = \"mengya-rag\") -> argparse.ArgumentParser:\n config_parent = argparse.ArgumentParser(add_help=False)\n _add_config_args(config_parent)\n _add_output_args(config_parent)\n\n parser = argparse.ArgumentParser(\n prog=prog,\n description=\"萌芽 RAG 知识库命令行工具\",\n parents=[config_parent],\n )\n\n subparsers = parser.add_subparsers(dest=\"command\")\n\n sync_parser = subparsers.add_parser(\n \"sync\",\n help=\"从远端同步 Markdown 笔记\",\n parents=[config_parent],\n )\n sync_parser.set_defaults(func=_sync_notes)\n\n index_parser = subparsers.add_parser(\n \"index\",\n help=\"构建本地检索索引\",\n parents=[config_parent],\n )\n index_parser.set_defaults(func=_build_index)\n\n reindex_parser = subparsers.add_parser(\n \"reindex\",\n help=\"同步笔记并重建索引\",\n parents=[config_parent],\n )\n reindex_parser.set_defaults(func=_reindex)\n\n ask_parser = subparsers.add_parser(\n \"ask\",\n help=\"检索笔记并调用 DeepSeek 回答\",\n parents=[config_parent],\n )\n _add_retrieval_args(ask_parser)\n ask_parser.add_argument(\"--no-source\", action=\"store_true\", help=\"不显示来源\")\n _add_question_arg(ask_parser)\n ask_parser.set_defaults(func=_ask)\n\n search_parser = subparsers.add_parser(\n \"search\",\n help=\"只检索来源,不调用大模型\",\n parents=[config_parent],\n )\n _add_retrieval_args(search_parser)\n search_parser.add_argument(\n \"--show-content\",\n action=\"store_true\",\n help=\"显示命中的内容预览\",\n )\n search_parser.add_argument(\n \"--preview-chars\",\n type=_positive_int,\n default=260,\n help=\"内容预览字符数,默认 260\",\n )\n _add_question_arg(search_parser)\n search_parser.set_defaults(func=_search)\n\n context_parser = subparsers.add_parser(\n \"context\",\n help=\"返回可直接注入大模型的检索上下文\",\n parents=[config_parent],\n )\n _add_retrieval_args(context_parser)\n _add_question_arg(context_parser)\n context_parser.set_defaults(func=_context)\n\n read_parser = subparsers.add_parser(\n \"read\",\n help=\"按 search 返回的 source 读取完整笔记\",\n parents=[config_parent],\n )\n read_parser.add_argument(\"source\", help=\"相对 NOTES_DIR 的 Markdown 路径\")\n read_parser.add_argument(\n \"--max-chars\",\n type=_positive_int,\n help=\"最多输出多少字符,默认完整输出\",\n )\n read_parser.set_defaults(func=_read)\n\n status_parser = subparsers.add_parser(\n \"status\",\n help=\"查看配置和索引状态\",\n parents=[config_parent],\n )\n status_parser.set_defaults(func=_status)\n\n return parser\n\n\ndef main(argv: list[str] | None = None) -> int:\n parser = build_parser()\n args = parser.parse_args(argv)\n\n if not hasattr(args, \"func\"):\n parser.print_help()\n return 0\n\n return int(args.func(args) or 0)\n\n\ndef sync_notes() -> None:\n parser = argparse.ArgumentParser(description=\"同步萌芽 RAG 笔记\")\n _add_config_args(parser)\n _add_output_args(parser)\n args = parser.parse_args()\n _sync_notes(args)\n\n\ndef build_index() -> None:\n parser = argparse.ArgumentParser(description=\"构建萌芽 RAG 索引\")\n _add_config_args(parser)\n _add_output_args(parser)\n args = parser.parse_args()\n _build_index(args)\n\n\ndef ask() -> None:\n parser = argparse.ArgumentParser(description=\"向萌芽 RAG 知识库提问\")\n _add_config_args(parser)\n _add_output_args(parser)\n _add_retrieval_args(parser)\n parser.add_argument(\"--no-source\", action=\"store_true\", help=\"不显示来源\")\n _add_question_arg(parser)\n args = parser.parse_args()\n _ask(args)\n\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n"}],"isError":false,"timestamp":1781662322994}}
|
||
{"type":"message","id":"8ff624b1","parentId":"a4a19040","timestamp":"2026-06-17T02:12:07.338Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我继续查看其他核心文件。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_cb3bf3923f0e4e438c58183c","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/src/mengya_rag/qa.py"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":4411,"output":48,"cacheRead":145984,"cacheWrite":0,"totalTokens":150443,"cost":{"input":0.0017644000000000002,"output":0.000096,"cacheRead":0.01167872,"cacheWrite":0,"total":0.01353912}},"stopReason":"toolUse","timestamp":1781662322999,"responseId":"8481673c698a423f9f66f7c8c8e28973"}}
|
||
{"type":"message","id":"eafaff47","parentId":"8ff624b1","timestamp":"2026-06-17T02:12:07.340Z","message":{"role":"toolResult","toolCallId":"call_cb3bf3923f0e4e438c58183c","toolName":"read","content":[{"type":"text","text":"from __future__ import annotations\n\nfrom .config import Settings\nfrom .indexing import load_index_nodes\nfrom .retrieval import HybridRetriever, InventoryRetriever, RetrievedNode, is_inventory_query\n\n\nQA_PROMPT = \"\"\"你是萌芽 RAG 知识库助手。请只根据下面检索到的笔记内容回答问题。\n如果笔记内容不足以回答,请直接说明没有在笔记中找到足够依据。\n回答要准确、完整,优先使用中文。\n如果用户在问“有哪些/列表/目录/相关笔记/查一下”,请优先按来源文件列出条目,不要只总结一句话。\n\n笔记内容:\n---------------------\n{context}\n---------------------\n\n问题:{question}\n回答:\"\"\"\n\n\ndef format_source(item: RetrievedNode) -> str:\n metadata = item.node.metadata\n rel_path = metadata.get(\"rel_path\") or metadata.get(\"file_path\") or \"未知来源\"\n return f\"{rel_path} score={item.score:.4f}\"\n\n\ndef format_context(items: list[RetrievedNode]) -> str:\n parts: list[str] = []\n for index, item in enumerate(items, start=1):\n rel_path = item.node.metadata.get(\"rel_path\", \"未知来源\")\n parts.append(\n f\"[{index}] 来源: {rel_path}\\n\"\n f\"{item.node.get_content().strip()}\"\n )\n return \"\\n\\n\".join(parts)\n\n\ndef retrieve_nodes(\n settings: Settings,\n question: str,\n *,\n mode: str = \"auto\",\n top_k: int | None = None,\n) -> list[RetrievedNode]:\n if mode not in {\"auto\", \"hybrid\", \"inventory\"}:\n raise ValueError(\"mode 只能是 auto、hybrid 或 inventory\")\n\n index_nodes = load_index_nodes(settings)\n use_inventory = mode == \"inventory\" or (\n mode == \"auto\" and is_inventory_query(question)\n )\n limit = top_k or (settings.list_top_k if use_inventory else settings.top_k)\n\n if use_inventory:\n retriever = InventoryRetriever(index_nodes)\n return retriever.search(question, limit)\n\n retriever = HybridRetriever(settings, index_nodes)\n return retriever.search(question, limit)\n\n\ndef ask_question(\n settings: Settings,\n question: str,\n *,\n mode: str = \"auto\",\n top_k: int | None = None,\n) -> tuple[str, list[str]]:\n from llama_index.llms.deepseek import DeepSeek\n\n if not settings.deepseek_api_key:\n raise RuntimeError(\"缺少 DEEPSEEK_API_KEY,请先复制 .env.example 为 .env 并填写。\")\n\n nodes = retrieve_nodes(settings, question, mode=mode, top_k=top_k)\n\n llm = DeepSeek(\n model=settings.deepseek_model,\n api_key=settings.deepseek_api_key,\n temperature=settings.deepseek_temperature,\n )\n prompt = QA_PROMPT.format(context=format_context(nodes), question=question)\n response = llm.complete(prompt)\n\n sources = [format_source(node) for node in nodes]\n return str(response), sources\n\n\n_format_source = format_source\n_format_context = format_context\n"}],"isError":false,"timestamp":1781662327340}}
|
||
{"type":"message","id":"3ee86719","parentId":"eafaff47","timestamp":"2026-06-17T02:12:09.362Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我继续查看配置文件和向量存储文件。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_4b4bb1143c884e60932b4d61","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/src/mengya_rag/config.py"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":785,"output":51,"cacheRead":150336,"cacheWrite":0,"totalTokens":151172,"cost":{"input":0.00031400000000000004,"output":0.000102,"cacheRead":0.01202688,"cacheWrite":0,"total":0.01244288}},"stopReason":"toolUse","timestamp":1781662327343,"responseId":"8f789c14de60482896d5b09126d4b1c6"}}
|
||
{"type":"message","id":"6f045bfe","parentId":"3ee86719","timestamp":"2026-06-17T02:12:09.364Z","message":{"role":"toolResult","toolCallId":"call_4b4bb1143c884e60932b4d61","toolName":"read","content":[{"type":"text","text":"from __future__ import annotations\n\nimport os\nfrom dataclasses import replace\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nfrom dotenv import load_dotenv\n\n\nPROJECT_ROOT = Path(__file__).resolve().parents[2]\n\n\ndef _path_from_env(name: str, default: str) -> Path:\n value = os.getenv(name, default)\n path = Path(value)\n if not path.is_absolute():\n path = PROJECT_ROOT / path\n return path\n\n\n@dataclass(frozen=True)\nclass Settings:\n notes_remote: str\n notes_dir: Path\n index_dir: Path\n deepseek_api_key: str\n deepseek_model: str\n deepseek_temperature: float\n top_k: int\n list_top_k: int\n embed_backend: str\n embed_model: str\n embed_batch_size: int\n embed_query_prompt_name: str\n vector_weight: float\n bm25_weight: float\n\n\ndef load_settings(env_file: str | Path | None = None) -> Settings:\n if env_file is None:\n load_dotenv(PROJECT_ROOT / \".env\")\n else:\n load_dotenv(env_file)\n\n return Settings(\n notes_remote=os.getenv(\n \"NOTES_REMOTE\",\n \"bigmengya:/shumengya/docker/mengyanote-backend/data/mengyanote/\",\n ),\n notes_dir=_path_from_env(\"NOTES_DIR\", \"data/notes\"),\n index_dir=_path_from_env(\"INDEX_DIR\", \"data/index/bm25\"),\n deepseek_api_key=os.getenv(\"DEEPSEEK_API_KEY\", \"\"),\n deepseek_model=os.getenv(\"DEEPSEEK_MODEL\", \"deepseek-chat\"),\n deepseek_temperature=float(os.getenv(\"DEEPSEEK_TEMPERATURE\", \"0.2\")),\n top_k=int(os.getenv(\"TOP_K\", \"6\")),\n list_top_k=int(os.getenv(\"LIST_TOP_K\", \"20\")),\n embed_backend=os.getenv(\"EMBED_BACKEND\", \"fastembed\"),\n embed_model=os.getenv(\"EMBED_MODEL\", \"BAAI/bge-small-zh-v1.5\"),\n embed_batch_size=int(os.getenv(\"EMBED_BATCH_SIZE\", \"32\")),\n embed_query_prompt_name=os.getenv(\"EMBED_QUERY_PROMPT_NAME\", \"\"),\n vector_weight=float(os.getenv(\"VECTOR_WEIGHT\", \"0.65\")),\n bm25_weight=float(os.getenv(\"BM25_WEIGHT\", \"0.35\")),\n )\n\n\ndef override_settings(settings: Settings, **values: object) -> Settings:\n overrides = {key: value for key, value in values.items() if value is not None}\n if not overrides:\n return settings\n return replace(settings, **overrides)\n"}],"isError":false,"timestamp":1781662329364}}
|
||
{"type":"message","id":"44439b83","parentId":"6f045bfe","timestamp":"2026-06-17T02:12:11.314Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我继续查看向量存储和索引文件。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_e0c4257c96cb44ee8f3f8964","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/src/mengya_rag/vector_store.py"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":648,"output":52,"cacheRead":151104,"cacheWrite":0,"totalTokens":151804,"cost":{"input":0.0002592,"output":0.000104,"cacheRead":0.01208832,"cacheWrite":0,"total":0.012451519999999999}},"stopReason":"toolUse","timestamp":1781662329368,"responseId":"68d8a4eda4174a65a804c8f051b34c42"}}
|
||
{"type":"message","id":"1ffe9cd5","parentId":"44439b83","timestamp":"2026-06-17T02:12:11.316Z","message":{"role":"toolResult","toolCallId":"call_e0c4257c96cb44ee8f3f8964","toolName":"read","content":[{"type":"text","text":"from __future__ import annotations\n\nimport sqlite3\nfrom pathlib import Path\n\nimport numpy as np\nimport sqlite_vec\nfrom llama_index.core.schema import TextNode\n\n\nDB_FILE = \"rag.sqlite3\"\n\n\ndef db_path(index_dir: Path) -> Path:\n return index_dir / DB_FILE\n\n\ndef connect_vector_db(path: Path) -> sqlite3.Connection:\n conn = sqlite3.connect(path)\n conn.enable_load_extension(True)\n sqlite_vec.load(conn)\n conn.enable_load_extension(False)\n conn.row_factory = sqlite3.Row\n return conn\n\n\ndef init_vector_db(path: Path, dim: int) -> None:\n conn = connect_vector_db(path)\n try:\n conn.execute(\"drop table if exists chunks\")\n conn.execute(\"drop table if exists chunk_vectors\")\n conn.execute(\n \"\"\"\n create table chunks (\n id integer primary key,\n node_json text not null,\n rel_path text not null,\n title text not null,\n heading_path text not null,\n chunk_index integer not null\n )\n \"\"\"\n )\n conn.execute(\n f\"create virtual table chunk_vectors using vec0(embedding float[{dim}])\"\n )\n conn.execute(\"create index idx_chunks_rel_path on chunks(rel_path)\")\n conn.commit()\n finally:\n conn.close()\n\n\ndef write_vector_db(path: Path, nodes: list[TextNode], vectors: np.ndarray) -> None:\n if len(nodes) != len(vectors):\n raise ValueError(\"节点数量和向量数量不一致\")\n\n init_vector_db(path, int(vectors.shape[1]))\n conn = connect_vector_db(path)\n try:\n chunk_rows = []\n vector_rows = []\n for rowid, (node, vector) in enumerate(zip(nodes, vectors), start=1):\n chunk_rows.append(\n (\n rowid,\n node.model_dump_json(),\n str(node.metadata.get(\"rel_path\", \"\")),\n str(node.metadata.get(\"title\", \"\")),\n str(node.metadata.get(\"heading_path\", \"\")),\n int(node.metadata.get(\"chunk_index\", 0)),\n )\n )\n vector_rows.append((rowid, np.asarray(vector, dtype=np.float32).tobytes()))\n\n conn.executemany(\n \"\"\"\n insert into chunks\n (id, node_json, rel_path, title, heading_path, chunk_index)\n values (?, ?, ?, ?, ?, ?)\n \"\"\",\n chunk_rows,\n )\n conn.executemany(\n \"insert into chunk_vectors(rowid, embedding) values (?, ?)\",\n vector_rows,\n )\n conn.commit()\n finally:\n conn.close()\n\n\ndef load_nodes_from_db(path: Path) -> list[TextNode]:\n conn = connect_vector_db(path)\n try:\n rows = conn.execute(\"select node_json from chunks order by id\").fetchall()\n return [TextNode.model_validate_json(row[\"node_json\"]) for row in rows]\n finally:\n conn.close()\n\n\ndef search_vectors(path: Path, query_vector: np.ndarray, top_k: int) -> list[tuple[int, float]]:\n conn = connect_vector_db(path)\n try:\n vector_blob = np.asarray(query_vector, dtype=np.float32).tobytes()\n rows = conn.execute(\n \"\"\"\n select rowid, distance\n from chunk_vectors\n where embedding match ?\n order by distance\n limit ?\n \"\"\",\n (vector_blob, top_k),\n ).fetchall()\n return [(int(row[\"rowid\"]), float(row[\"distance\"])) for row in rows]\n finally:\n conn.close()\n"}],"isError":false,"timestamp":1781662331316}}
|
||
{"type":"message","id":"16bc0a96","parentId":"1ffe9cd5","timestamp":"2026-06-17T02:12:14.612Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下索引构建文件和脚本。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_7374586aa9884e3a9a294244","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/src/mengya_rag/indexing.py"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":815,"output":52,"cacheRead":151744,"cacheWrite":0,"totalTokens":152611,"cost":{"input":0.000326,"output":0.000104,"cacheRead":0.01213952,"cacheWrite":0,"total":0.01256952}},"stopReason":"toolUse","timestamp":1781662331321,"responseId":"299122a72650432ea3f56dd61224db7e"}}
|
||
{"type":"message","id":"9939c633","parentId":"16bc0a96","timestamp":"2026-06-17T02:12:14.613Z","message":{"role":"toolResult","toolCallId":"call_7374586aa9884e3a9a294244","toolName":"read","content":[{"type":"text","text":"from __future__ import annotations\n\nimport re\nimport shutil\nimport subprocess\nfrom pathlib import Path\n\nimport numpy as np\nfrom llama_index.core.schema import TextNode\n\nfrom .config import Settings\nfrom .embeddings import embed_documents\nfrom .markdown_chunker import chunk_markdown_file\nfrom .vector_store import db_path, load_nodes_from_db, write_vector_db\n\n\ndef sync_notes(settings: Settings) -> None:\n settings.notes_dir.mkdir(parents=True, exist_ok=True)\n subprocess.run(\n [\n \"rsync\",\n \"-av\",\n \"--delete\",\n \"--exclude=.git/\",\n \"--exclude=.trash/\",\n \"--exclude=.obsidian/workspace*\",\n settings.notes_remote,\n f\"{settings.notes_dir}/\",\n ],\n check=True,\n )\n\n\ndef markdown_files(notes_dir: Path) -> list[Path]:\n if not notes_dir.exists():\n raise FileNotFoundError(f\"笔记目录不存在: {notes_dir}\")\n\n return sorted(notes_dir.rglob(\"*.md\"))\n\n\ndef chinese_tokenizer(text: str) -> list[str]:\n stopwords = {\n \"请\",\n \"根据\",\n \"笔记\",\n \"简要\",\n \"回答\",\n \"哪些\",\n \"什么\",\n \"内容\",\n \"一下\",\n \"我的\",\n \"有哪些\",\n \"是什么\",\n }\n tokens: list[str] = []\n\n for part in re.findall(r\"[A-Za-z0-9_#+.-]+|[\\u4e00-\\u9fff]+\", text.lower()):\n if re.fullmatch(r\"[A-Za-z0-9_#+.-]+\", part):\n if part in {\"md\", \"markdown\"}:\n continue\n if part not in stopwords:\n tokens.append(part)\n continue\n\n if len(part) == 1:\n if part not in stopwords:\n tokens.append(part)\n continue\n\n max_n = min(4, len(part))\n for n in range(2, max_n + 1):\n for index in range(0, len(part) - n + 1):\n token = part[index : index + n]\n if token not in stopwords:\n tokens.append(token)\n\n return tokens\n\n\nNODES_FILE = \"nodes.jsonl\"\n\n\ndef build_bm25_index(settings: Settings) -> int:\n nodes: list[TextNode] = []\n for path in markdown_files(settings.notes_dir):\n nodes.extend(chunk_markdown_file(path, settings.notes_dir))\n\n if settings.index_dir.exists():\n shutil.rmtree(settings.index_dir)\n settings.index_dir.mkdir(parents=True, exist_ok=True)\n\n nodes_file = settings.index_dir / NODES_FILE\n with nodes_file.open(\"w\", encoding=\"utf-8\") as f:\n for node in nodes:\n f.write(node.model_dump_json())\n f.write(\"\\n\")\n\n texts = [node_to_embedding_text(node) for node in nodes]\n vectors = embed_documents(settings, texts)\n write_vector_db(db_path(settings.index_dir), nodes, vectors)\n return len(nodes)\n\n\ndef load_index_nodes(settings: Settings) -> list[TextNode]:\n database = db_path(settings.index_dir)\n if database.exists():\n return load_nodes_from_db(database)\n\n nodes_file = settings.index_dir / NODES_FILE\n if not nodes_file.exists():\n raise FileNotFoundError(\n f\"索引目录不存在: {settings.index_dir},请先运行 mengya-build-index\"\n )\n\n nodes: list[TextNode] = []\n with nodes_file.open(\"r\", encoding=\"utf-8\") as f:\n for line in f:\n line = line.strip()\n if line:\n nodes.append(TextNode.model_validate_json(line))\n return nodes\n\n\ndef node_to_embedding_text(node: TextNode) -> str:\n title = str(node.metadata.get(\"title\", \"\"))\n rel_path = str(node.metadata.get(\"rel_path\", \"\"))\n heading_path = str(node.metadata.get(\"heading_path\", \"\"))\n tags = node.metadata.get(\"tags\", [])\n return (\n f\"文件名: {title}\\n\"\n f\"路径: {rel_path}\\n\"\n f\"标题路径: {heading_path}\\n\"\n f\"标签: {tags}\\n\"\n f\"正文:\\n{node.get_content()}\"\n )\n"}],"isError":false,"timestamp":1781662334613}}
|
||
{"type":"message","id":"609df56a","parentId":"9939c633","timestamp":"2026-06-17T02:12:17.658Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 embedding 文件和脚本。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_185e35c518fa4bb5b1e1fdfe","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/src/mengya_rag/embeddings.py"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":1000,"output":50,"cacheRead":152512,"cacheWrite":0,"totalTokens":153562,"cost":{"input":0.0004,"output":0.00009999999999999999,"cacheRead":0.01220096,"cacheWrite":0,"total":0.01270096}},"stopReason":"toolUse","timestamp":1781662334615,"responseId":"c70b86b65b804ce29af6ce858197bf4b"}}
|
||
{"type":"message","id":"a44dc1d2","parentId":"609df56a","timestamp":"2026-06-17T02:12:17.660Z","message":{"role":"toolResult","toolCallId":"call_185e35c518fa4bb5b1e1fdfe","toolName":"read","content":[{"type":"text","text":"from __future__ import annotations\n\nfrom functools import lru_cache\n\nimport numpy as np\nfrom fastembed import TextEmbedding\n\nfrom .config import Settings\n\n\nQWEN_QUERY_INSTRUCTION = (\n \"Instruct: Given a search query in Chinese or English, retrieve relevant \"\n \"Markdown note passages from a personal knowledge base.\\nQuery: \"\n)\n\n\n@lru_cache(maxsize=2)\ndef _fastembed_model(model_name: str) -> TextEmbedding:\n return TextEmbedding(model_name=model_name)\n\n\n@lru_cache(maxsize=2)\ndef _sentence_transformer_model(model_name: str):\n from sentence_transformers import SentenceTransformer\n\n return SentenceTransformer(model_name)\n\n\ndef embed_documents(settings: Settings, texts: list[str]) -> np.ndarray:\n if settings.embed_backend == \"sentence-transformers\":\n model = _sentence_transformer_model(settings.embed_model)\n vectors = model.encode(\n texts,\n batch_size=settings.embed_batch_size,\n normalize_embeddings=True,\n show_progress_bar=False,\n )\n return np.asarray(vectors, dtype=np.float32)\n\n model = _fastembed_model(settings.embed_model)\n vectors = np.array(\n list(model.embed(texts, batch_size=settings.embed_batch_size)),\n dtype=np.float32,\n )\n return normalize_vectors(vectors)\n\n\ndef embed_query(settings: Settings, query: str) -> np.ndarray:\n if settings.embed_backend == \"sentence-transformers\":\n model = _sentence_transformer_model(settings.embed_model)\n prompt_name = settings.embed_query_prompt_name or None\n if prompt_name:\n vector = model.encode(\n [query],\n prompt_name=prompt_name,\n normalize_embeddings=True,\n show_progress_bar=False,\n )[0]\n else:\n vector = model.encode(\n [query],\n normalize_embeddings=True,\n show_progress_bar=False,\n )[0]\n return np.asarray(vector, dtype=np.float32)\n\n model = _fastembed_model(settings.embed_model)\n vector = np.array(next(model.query_embed(query)), dtype=np.float32)\n return normalize_vector(vector)\n\n\ndef normalize_vectors(vectors: np.ndarray) -> np.ndarray:\n norms = np.linalg.norm(vectors, axis=1, keepdims=True)\n return vectors / np.clip(norms, 1e-12, None)\n\n\ndef normalize_vector(vector: np.ndarray) -> np.ndarray:\n return vector / max(float(np.linalg.norm(vector)), 1e-12)\n"}],"isError":false,"timestamp":1781662337660}}
|
||
{"type":"message","id":"baa86ce4","parentId":"a44dc1d2","timestamp":"2026-06-17T02:12:23.650Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下脚本目录和 pyproject.toml 文件。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_5d284b007c3a4e7a86f62c3f","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/pyproject.toml"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":613,"output":49,"cacheRead":153472,"cacheWrite":0,"totalTokens":154134,"cost":{"input":0.0002452,"output":0.000098,"cacheRead":0.01227776,"cacheWrite":0,"total":0.01262096}},"stopReason":"toolUse","timestamp":1781662337663,"responseId":"16622e3d27ca45ed86fe8618243262a6"}}
|
||
{"type":"message","id":"97fd07ec","parentId":"baa86ce4","timestamp":"2026-06-17T02:12:23.652Z","message":{"role":"toolResult","toolCallId":"call_5d284b007c3a4e7a86f62c3f","toolName":"read","content":[{"type":"text","text":"[project]\nname = \"mengya-rag\"\nversion = \"0.1.0\"\ndescription = \"轻量版萌芽 Obsidian RAG 知识库\"\nrequires-python = \">=3.11\"\ndependencies = [\n \"fastembed>=0.7.0\",\n \"llama-index-core>=0.12.0\",\n \"llama-index-llms-deepseek>=0.1.0\",\n \"python-dotenv>=1.0.1\",\n \"sqlite-vec>=0.1.9\",\n]\n\n[project.scripts]\nmengya-rag = \"mengya_rag.cli:main\"\nmengya-sync-notes = \"mengya_rag.cli:sync_notes\"\nmengya-build-index = \"mengya_rag.cli:build_index\"\nmengya-ask = \"mengya_rag.cli:ask\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src/mengya_rag\"]\n"}],"isError":false,"timestamp":1781662343652}}
|
||
{"type":"message","id":"f7a68b90","parentId":"97fd07ec","timestamp":"2026-06-17T02:12:25.742Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下脚本目录。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_63259968cd224145a58b1777","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/scripts/ask.sh"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":326,"output":43,"cacheRead":154048,"cacheWrite":0,"totalTokens":154417,"cost":{"input":0.0001304,"output":0.00008599999999999999,"cacheRead":0.01232384,"cacheWrite":0,"total":0.012540240000000001}},"stopReason":"toolUse","timestamp":1781662343655,"responseId":"d13da28f61cc4b4fb0713e8df9b79ed7"}}
|
||
{"type":"message","id":"250988ec","parentId":"f7a68b90","timestamp":"2026-06-17T02:12:25.744Z","message":{"role":"toolResult","toolCallId":"call_63259968cd224145a58b1777","toolName":"read","content":[{"type":"text","text":"#!/usr/bin/env bash\nset -euo pipefail\n\ncd \"$(dirname \"$0\")/..\"\nexport TRANSFORMERS_VERBOSITY=error\nuv run mengya-ask \"$@\"\n"}],"isError":false,"timestamp":1781662345744}}
|
||
{"type":"message","id":"1b9e32c4","parentId":"250988ec","timestamp":"2026-06-17T02:12:28.846Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我查看一下 .env.example 文件来了解配置。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_288ebeb794194b6e8812178c","name":"read","arguments":{"path":"/shumengya/project/agent/mengya-rag/.env.example"}}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":102,"output":46,"cacheRead":154368,"cacheWrite":0,"totalTokens":154516,"cost":{"input":0.0000408,"output":0.000092,"cacheRead":0.01234944,"cacheWrite":0,"total":0.01248224}},"stopReason":"toolUse","timestamp":1781662345746,"responseId":"5f5c6b3c6d41454c832fd3c2dce59f9f"}}
|
||
{"type":"message","id":"6037f2e1","parentId":"1b9e32c4","timestamp":"2026-06-17T02:12:28.848Z","message":{"role":"toolResult","toolCallId":"call_288ebeb794194b6e8812178c","toolName":"read","content":[{"type":"text","text":"DEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_TEMPERATURE=0.2\n\nNOTES_REMOTE=bigmengya:/shumengya/docker/mengyanote-backend/data/mengyanote/\nNOTES_DIR=data/notes\nINDEX_DIR=data/index/bm25\nTOP_K=6\nLIST_TOP_K=20\nEMBED_BACKEND=fastembed\nEMBED_MODEL=BAAI/bge-small-zh-v1.5\nEMBED_BATCH_SIZE=32\nEMBED_QUERY_PROMPT_NAME=\nVECTOR_WEIGHT=0.65\nBM25_WEIGHT=0.35\n"}],"isError":false,"timestamp":1781662348848}}
|
||
{"type":"message","id":"a925a3a9","parentId":"6037f2e1","timestamp":"2026-06-17T02:13:06.133Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"现在我已经收集了足够的信息来给用户详细介绍这个项目。让我组织一下内容,给用户一个全面的介绍。","thinkingSignature":"reasoning_content"},{"type":"text","text":"我已经查看了 mengya-rag 项目的完整代码。让我为你详细介绍这个项目。\n\n---\n\n# 萌芽 RAG 知识库 (mengya-rag) 详细介绍\n\n## 一、项目概述\n\n**萌芽 RAG 知识库**是一个轻量级的个人 Obsidian 笔记 RAG(检索增强生成)项目,专为个人知识管理设计。\n\n| 特性 | 说明 |\n|------|------|\n| **定位** | 个人 Obsidian 笔记的智能检索和问答 |\n| **技术栈** | LlamaIndex + FastEmbed + SQLite + DeepSeek |\n| **特点** | 无需 GPU、无需外部向量数据库、本地 embedding |\n| **规模** | 适合个人笔记(当前 1802 个分块,约 13MB 索引) |\n\n---\n\n## 二、系统架构\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│ mengya-rag 系统架构 │\n├─────────────────────────────────────────────────────────────────┤\n│ │\n│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │\n│ │ Obsidian │ │ sync │ │ 本地笔记 │ │\n│ │ 笔记源 │───→│ notes.sh │───→│ data/notes │ │\n│ │ (bigmengya) │ │ │ │ │ │\n│ └─────────────┘ └─────────────┘ └──────┬──────┘ │\n│ │ │\n│ ↓ │\n│ ┌─────────────────────────────────────────────────────────┐ │\n│ │ Markdown 结构感知分块 │ │\n│ │ - 解析 frontmatter │ │\n│ │ - 识别 H1-H6 标题层级 │ │\n│ │ - 代码块和表格整体保留 │ │\n│ │ - 滑动窗口长块分割 │ │\n│ └─────────────────────────────────────────────────────────┘ │\n│ │ │\n│ ↓ │\n│ ┌─────────────────────────────────────────────────────────┐ │\n│ │ 索引构建 │ │\n│ │ ┌─────────────┐ ┌─────────────┐ │ │\n│ │ │ BM25 索引 │ │ 向量索引 │ │ │\n│ │ │ (关键词) │ │ (语义) │ │ │\n│ │ └─────────────┘ └─────────────┘ │ │\n│ │ │ │ │ │\n│ │ └────────┬─────────┘ │ │\n│ │ ↓ │ │\n│ │ SQLite + sqlite-vec │ │\n│ │ (rag.sqlite3) │ │\n│ └─────────────────────────────────────────────────────────┘ │\n│ │ │\n│ ↓ │\n│ ┌─────────────────────────────────────────────────────────┐ │\n│ │ 检索融合 │ │\n│ │ - BM25 关键词检索 │ │\n│ │ - 向量语义检索 │ │\n│ │ - RRF 融合 │ │\n│ │ - 同文件去重 │ │\n│ └─────────────────────────────────────────────────────────┘ │\n│ │ │\n│ ↓ │\n│ ┌─────────────────────────────────────────────────────────┐ │\n│ │ DeepSeek 生成 │ │\n│ │ - 注入检索上下文 │ │\n│ │ - 生成回答和来源 │ │\n│ └─────────────────────────────────────────────────────────┘ │\n│ │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n---\n\n## 三、核心模块详解\n\n### 3.1 Markdown 结构感知分块 (markdown_chunker.py)\n\n这是项目的核心创新点,不是简单的按字符数切割:\n\n```python\n# 分块流程\nMarkdown 文件\n ↓\n解析 frontmatter (提取 tags, created_at 等)\n ↓\n识别 H1-H6 标题层级\n ↓\n按标题层级组织分块 (Section)\n ↓\n代码块和表格整体保留 (不跨块切割)\n ↓\n长块使用滑动窗口兜底 (TARGET_TOKENS=512, MAX_TOKENS=1024)\n```\n\n**每个 chunk 保存的元数据**:\n```python\nmetadata = {\n \"source_file\": \"AI/控制台Agent工具安装教程.md\", # 相对路径\n \"rel_path\": \"AI/控制台Agent工具安装教程.md\",\n \"file_name\": \"控制台Agent工具安装教程.md\",\n \"folder_path\": \"AI\",\n \"title\": \"控制台Agent工具安装教程\",\n \"heading_path\": \"H1 > H2 > H3\", # 面包屑导航\n \"h1\": \"AI工具\",\n \"h2\": \"控制台Agent\",\n \"h3\": \"安装教程\",\n \"chunk_index\": 0,\n \"tags\": [\"AI\", \"Agent\"],\n \"created_at\": 1778572129.327,\n}\n```\n\n**上下文前缀**:\n```python\nprefix = f\"\"\"文件: {rel_path}\n标题: {title}\n标题路径: {heading_path}\"\"\"\n\n# 最终 chunk 文本\nnode_text = f\"{prefix}\\n\\n{chunk_content}\"\n```\n\n### 3.2 向量存储 (vector_store.py)\n\n使用 SQLite + sqlite-vec 实现本地向量数据库:\n\n```python\n# 数据库结构\nchunks 表:\n - id: 主键\n - node_json: 完整节点 JSON\n - rel_path: 相对路径\n - title: 标题\n - heading_path: 标题路径\n - chunk_index: 分块索引\n\nchunk_vectors 表 (sqlite-vec 虚拟表):\n - rowid: 对应 chunks.id\n - embedding: float 向量 (384 维)\n```\n\n**搜索流程**:\n```python\ndef search_vectors(path, query_vector, top_k):\n # 使用 sqlite-vec 的余弦距离搜索\n rows = conn.execute(\"\"\"\n select rowid, distance\n from chunk_vectors\n where embedding match ?\n order by distance\n limit ?\n \"\"\", (vector_blob, top_k))\n return [(rowid, distance) for rowid, distance in rows]\n```\n\n### 3.3 混合检索 (retrieval.py)\n\n实现 BM25 + 向量的混合检索:\n\n```python\nclass HybridRetriever:\n def search(self, query, top_k):\n # 1. 查询清洗\n query = clean_query(query)\n \n # 2. BM25 关键词检索\n bm25_results = self.bm25.search(query, candidate_k)\n \n # 3. 向量语义检索\n vector_results = self._vector_search(query, candidate_k)\n \n # 4. RRF 融合\n return self._fuse_results(bm25_results, vector_results, top_k)\n```\n\n**RRF 融合算法**:\n```python\ndef _fuse_results(self, bm25_results, vector_results, top_k):\n fused = {}\n \n def add(results, weight):\n for rank, item in enumerate(results, start=1):\n # 归一化分数\n normalized = raw / best_raw\n # RRF 公式: weight * normalized / (60 + rank)\n score = weight * normalized / (60 + rank)\n fused[key] = (node, current + score)\n \n add(bm25_results, self.settings.bm25_weight) # 0.35\n add(vector_results, self.settings.vector_weight) # 0.65\n \n # 同文件去重 + 相关性过滤\n return final[:top_k]\n```\n\n### 3.4 BM25 本地实现 (LocalBM25)\n\n完全本地实现的 BM25,不依赖外部服务:\n\n```python\nclass LocalBM25:\n def __init__(self, nodes):\n # 中文分词 (n-gram)\n self.doc_tokens = [chinese_tokenizer(text) for node in nodes]\n \n # 计算 IDF\n self.doc_freq = Counter()\n for tokens in self.doc_tokens:\n self.doc_freq.update(set(tokens))\n \n def search(self, query, top_k):\n query_terms = chinese_tokenizer(query)\n for term in query_terms:\n # BM25 公式\n idf = log(1 + (N - df + 0.5) / (df + 0.5))\n score += idf * tf * (k1 + 1) / (tf + k1 * (1 - b + b * dl / avgdl))\n```\n\n**中文分词**:\n```python\ndef chinese_tokenizer(text):\n # 支持中文和英文\n for part in re.findall(r\"[A-Za-z0-9_#+.-]+|[\\u4e00-\\u9fff]+\", text):\n # 英文: 整词保留\n # 中文: n-gram 切分 (2-4 字)\n for n in range(2, min(4, len(part)) + 1):\n for i in range(len(part) - n + 1):\n tokens.append(part[i:i+n])\n```\n\n### 3.5 盘点检索模式 (InventoryRetriever)\n\n针对\"有哪些\"\"目录\"\"清单\"类问题的特殊检索:\n\n```python\nclass InventoryRetriever:\n def search(self, query, top_k):\n # 1. 构建文件级摘要节点\n file_nodes = build_file_nodes(nodes)\n \n # 2. BM25 检索\n results = self.bm25.search(query, top_k)\n \n # 3. 关键词覆盖过滤\n results = self._filter_by_keywords(query, results)\n \n return results\n\ndef build_file_nodes(nodes):\n # 每个文件一个摘要节点\n for rel_path, chunks in by_path.items():\n text = f\"标题: {title}\\n路径: {rel_path}\\n内容预览:\\n{preview}\"\n file_nodes.append(TextNode(text=text, metadata={...}))\n```\n\n---\n\n## 四、CLI 命令系统\n\n项目提供丰富的 CLI 命令:\n\n| 命令 | 说明 | 输出 |\n|------|------|------|\n| `status` | 查看配置和索引状态 | JSON/文本 |\n| `sync` | 从 bigmengya 同步笔记 | - |\n| `index` | 构建本地检索索引 | - |\n| `reindex` | 同步 + 重建索引 | - |\n| `search` | 只检索,不调用大模型 | 检索结果 |\n| `context` | 返回可注入大模型的上下文 | context 字符串 |\n| `read` | 按 source 读取完整笔记 | 原文 |\n| `ask` | 检索 + DeepSeek 生成回答 | 回答 + 来源 |\n\n**Agent 调用示例**:\n```bash\n# 查看状态\nuv run mengya-rag status --json\n\n# 检索\nuv run mengya-rag search \"Docker 常用命令\" -k 5 --json\n\n# 获取上下文\nuv run mengya-rag context \"WireGuard 怎么配置\" -k 4 --json\n\n# 读取笔记\nuv run mengya-rag read \"Docker/Docker常用命令总结.md\" --json\n\n# 问答\nuv run mengya-rag ask \"Docker 常用命令有哪些\" -k 4 --json\n\n# 更新知识库\nuv run mengya-rag reindex --json\n```\n\n---\n\n## 五、技术栈详解\n\n| 组件 | 技术选择 | 原因 |\n|------|---------|------|\n| **RAG 框架** | LlamaIndex | 成熟的 RAG 框架 |\n| **本地 Embedding** | FastEmbed + ONNX | 无需 GPU,CPU 即可运行 |\n| **Embedding 模型** | BAAI/bge-small-zh-v1.5 | 轻量中文模型,效果够用 |\n| **大模型** | DeepSeek API | 性价比高,中文能力强 |\n| **向量数据库** | SQLite + sqlite-vec | 无需额外服务,单机部署 |\n| **BM25** | 本地 Python 实现 | 无需 Elasticsearch 等服务 |\n| **笔记同步** | rsync | 从 bigmengya 同步 |\n| **依赖管理** | uv | 快速、现代的 Python 包管理 |\n\n---\n\n## 六、配置参数\n\n`.env` 文件配置:\n\n```env\n# DeepSeek 配置\nDEEPSEEK_API_KEY=你的 API Key\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_TEMPERATURE=0.2\n\n# 笔记源\nNOTES_REMOTE=bigmengya:/shumengya/docker/mengyanote-backend/data/mengyanote/\nNOTES_DIR=data/notes\nINDEX_DIR=data/index/bm25\n\n# 检索参数\nTOP_K=6 # 普通问答返回条数\nLIST_TOP_K=20 # 盘点列表返回条数\n\n# Embedding 配置\nEMBED_BACKEND=fastembed\nEMBED_MODEL=BAAI/bge-small-zh-v1.5\nEMBED_BATCH_SIZE=32\n\n# 融合权重\nVECTOR_WEIGHT=0.65 # 向量检索权重\nBM25_WEIGHT=0.35 # BM25 检索权重\n```\n\n---\n\n## 七、数据流示例\n\n### 7.1 索引构建流程\n\n```\n1. rsync 同步笔记\n bigmengya:/shumengya/docker/mengyanote-backend/data/mengyanote/\n → data/notes/\n\n2. 扫描所有 .md 文件\n data/notes/**/*.md → 150+ 个笔记文件\n\n3. 结构感知分块\n 每个文件 → 多个 TextNode (平均 12 个/文件)\n 总计: 1802 个 chunks\n\n4. 生成 embedding\n 使用 bge-small-zh-v1.5\n 384 维向量\n\n5. 写入 SQLite\n chunks 表 + chunk_vectors 表\n 索引大小: 约 13MB\n```\n\n### 7.2 查询流程\n\n```\n用户问题: \"Docker 常用命令有哪些\"\n\n1. 查询清洗\n \"Docker 常用命令有哪些\" → \"Docker 常用命令\"\n\n2. 识别查询类型\n 包含\"有哪些\" → 盘点模式\n\n3. BM25 检索\n 中文 n-gram 分词\n \"Docker\" \"常用\" \"命令\"\n → 匹配文件级摘要\n\n4. 关键词过滤\n 保留包含关键词的文件\n\n5. 返回结果\n - Docker/Docker常用命令总结.md\n - Docker/Docker安装教程.md\n - ...\n```\n\n---\n\n## 八、项目优势\n\n| 优势 | 说明 |\n|------|------|\n| **零依赖外部服务** | SQLite + 本地 embedding,无需 Qdrant、Milvus、Elasticsearch |\n| **无需 GPU** | FastEmbed + ONNX,CPU 即可运行 |\n| **结构感知分块** | 保留 Markdown 语义结构,不是暴力切割 |\n| **混合检索** | BM25 + 向量,兼顾关键词和语义 |\n| **轻量部署** | 单个 Python 项目,`uv sync` 即可 |\n| **Agent 友好** | `--json` 输出,便于程序调用 |\n\n---\n\n## 九、当前限制与优化方向\n\n### 限制\n- 全量重建索引,无增量索引\n- 无 reranker,复杂问题可能有弱相关内容\n- embedding 模型轻量,效果不是最强\n- 无 Web 界面和 API 服务\n\n### 优化方向\n- [ ] 增量索引,只重建变更文件\n- [ ] 加 reranker (如 bge-reranker-v2-m3)\n- [ ] 盘点类问题增加目录树模式\n- [ ] 增加 `--debug` 输出检索过程\n- [ ] 数据量变大后接 Qdrant\n\n---\n\n## 十、使用示例\n\n```bash\n# 进入项目目录\ncd /shumengya/project/agent/mengya-rag\n\n# 查看状态\nuv run mengya-rag status\n\n# 同步笔记\nuv run mengya-rag sync\n\n# 构建索引\nuv run mengya-rag index\n\n# 更新知识库 (同步 + 索引)\nuv run mengya-rag reindex\n\n# 搜索\nuv run mengya-rag search \"WireGuard 配置\" -k 5\n\n# 获取上下文 (适合注入其他大模型)\nuv run mengya-rag context \"Docker 常用命令\" -k 4 --json\n\n# 问答\nuv run mengya-rag ask \"萌芽笔记是什么\"\n\n# 读取完整笔记\nuv run mengya-rag read \"AI/控制台Agent工具安装教程.md\"\n```\n\n这是一个设计精巧的轻量级 RAG 系统,非常适合个人知识管理场景 (^▽^)"}],"api":"openai-completions","provider":"xiaomimimo-openai","model":"mimo-v2.5","usage":{"input":232,"output":4066,"cacheRead":154432,"cacheWrite":0,"totalTokens":158730,"cost":{"input":0.0000928,"output":0.008132,"cacheRead":0.01235456,"cacheWrite":0,"total":0.02057936}},"stopReason":"stop","timestamp":1781662348850,"responseId":"c4967910ae96460fbaf65ccd1c7d8965"}}
|