diff --git a/README-ja.md b/README-ja.md index 8717201b8..379876bd3 100644 --- a/README-ja.md +++ b/README-ja.md @@ -241,6 +241,10 @@ cp .env.example .env # .env を編集して ANTHROPIC_API_KEY を入力 python agents/s01_agent_loop.py # ここから開始 python agents/s12_worktree_task_isolation.py # 全セッションの到達点 python agents/s_full.py # 総括: 全メカニズム統合 + +cd agents-ts && npm install +npm run s01 # TypeScript 版の開始地点 +npm run s12 # TypeScript 版の到達点 ``` ### Web プラットフォーム @@ -291,6 +295,7 @@ s08 バックグラウンドタスク [6] s10 チームプロトコル learn-claude-code/ | |-- agents/ # Python リファレンス実装 (s01-s12 + s_full 総括) +|-- agents-ts/ # TypeScript 実行版 (s01-s12) |-- docs/{en,zh,ja}/ # メンタルモデル優先のドキュメント (3言語) |-- web/ # インタラクティブ学習プラットフォーム (Next.js) |-- skills/ # s05 の Skill ファイル diff --git a/README-zh.md b/README-zh.md index fff83e48f..98b97ce20 100644 --- a/README-zh.md +++ b/README-zh.md @@ -241,6 +241,10 @@ cp .env.example .env # 编辑 .env 填入你的 ANTHROPIC_API_KEY python agents/s01_agent_loop.py # 从这里开始 python agents/s12_worktree_task_isolation.py # 完整递进终点 python agents/s_full.py # 总纲: 全部机制合一 + +cd agents-ts && npm install +npm run s01 # TypeScript 版本入口 +npm run s12 # TypeScript 完整递进终点 ``` ### Web 平台 @@ -291,6 +295,7 @@ s08 后台任务 [6] s10 团队协议 [12] learn-claude-code/ | |-- agents/ # Python 参考实现 (s01-s12 + s_full 总纲) +|-- agents-ts/ # TypeScript 可运行实现 (s01-s12) |-- docs/{en,zh,ja}/ # 心智模型优先的文档 (3 种语言) |-- web/ # 交互式学习平台 (Next.js) |-- skills/ # s05 的 Skill 文件 diff --git a/README.md b/README.md index c25a63458..f34b4e659 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,10 @@ cp .env.example .env # Edit .env with your ANTHROPIC_API_KEY python agents/s01_agent_loop.py # Start here python agents/s12_worktree_task_isolation.py # Full progression endpoint python agents/s_full.py # Capstone: all mechanisms combined + +cd agents-ts && npm install +npm run s01 # TypeScript version entrypoint +npm run s12 # TypeScript full progression endpoint ``` ### Web Platform @@ -290,6 +294,7 @@ s08 Background Tasks [6] s10 Team Protocols [12] learn-claude-code/ | |-- agents/ # Python reference implementations (s01-s12 + s_full capstone) +|-- agents-ts/ # TypeScript runnable implementations (s01-s12) |-- docs/{en,zh,ja}/ # Mental-model-first documentation (3 languages) |-- web/ # Interactive learning platform (Next.js) |-- skills/ # Skill files for s05 diff --git a/agents-ts/.env.example b/agents-ts/.env.example new file mode 100644 index 000000000..5a796141f --- /dev/null +++ b/agents-ts/.env.example @@ -0,0 +1,3 @@ +ANTHROPIC_API_KEY=sk-ant-xxx +MODEL_ID=claude-sonnet-4-6 +# ANTHROPIC_BASE_URL=https://api.anthropic.com diff --git a/agents-ts/.gitignore b/agents-ts/.gitignore new file mode 100644 index 000000000..713d5006d --- /dev/null +++ b/agents-ts/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/agents-ts/README.md b/agents-ts/README.md new file mode 100644 index 000000000..d9e9c715f --- /dev/null +++ b/agents-ts/README.md @@ -0,0 +1,58 @@ +# TypeScript Agents + +`agents-ts` 是一套可单独运行的 TypeScript 版本示例。 + +## 环境要求 + +- Node.js 20+ +- npm 10+ + +## 安装 + +```bash +cd agents-ts +npm install +``` + +## 配置 + +复制 `.env.example` 为 `.env`,然后填写你的凭证。 + +默认接入参数: + +- `MODEL_ID=claude-sonnet-4-6` +- `ANTHROPIC_BASE_URL` 留空时走 Anthropic 官方默认端点 + +鉴权优先级: + +1. `ANTHROPIC_AUTH_TOKEN` +2. `ANTHROPIC_API_KEY` + +## 运行 + +```bash +npm run s01 +``` + + +## 校验 + +```bash +npm run typecheck +``` + + +## 章节脚本 + +- `npm run s01` +- `npm run s02` +- `npm run s03` +- `npm run s04` +- `npm run s05` +- `npm run s06` +- `npm run s07` +- `npm run s08` +- `npm run s09` +- `npm run s10` +- `npm run s11` +- `npm run s12` diff --git a/agents-ts/defaults.test.ts b/agents-ts/defaults.test.ts new file mode 100644 index 000000000..c1eb2907d --- /dev/null +++ b/agents-ts/defaults.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { DEFAULT_BASE_URL, DEFAULT_MODEL, resolveModel } from "./shared"; + +test("TypeScript agents default model matches the Python repo", () => { + assert.equal(DEFAULT_MODEL, "claude-sonnet-4-6"); +}); + +test("TypeScript agents do not hardcode a custom base URL by default", () => { + assert.equal(DEFAULT_BASE_URL, undefined); +}); + +test("resolveModel falls back to the shared default model", () => { + const previousModelId = process.env.MODEL_ID; + const previousAnthropicModel = process.env.ANTHROPIC_MODEL; + + delete process.env.MODEL_ID; + delete process.env.ANTHROPIC_MODEL; + + try { + assert.equal(resolveModel(), "claude-sonnet-4-6"); + } finally { + if (previousModelId === undefined) { + delete process.env.MODEL_ID; + } else { + process.env.MODEL_ID = previousModelId; + } + + if (previousAnthropicModel === undefined) { + delete process.env.ANTHROPIC_MODEL; + } else { + process.env.ANTHROPIC_MODEL = previousAnthropicModel; + } + } +}); diff --git a/agents-ts/package-lock.json b/agents-ts/package-lock.json new file mode 100644 index 000000000..3dd85cdcb --- /dev/null +++ b/agents-ts/package-lock.json @@ -0,0 +1,1022 @@ +{ + "name": "learn-claude-code-ts-agents", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "learn-claude-code-ts-agents", + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", + "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/agents-ts/package.json b/agents-ts/package.json new file mode 100644 index 000000000..966a364f6 --- /dev/null +++ b/agents-ts/package.json @@ -0,0 +1,29 @@ +{ + "name": "learn-claude-code-ts-agents", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "s01": "tsx s01_agent_loop.ts", + "s02": "tsx s02_tool_use.ts", + "s03": "tsx s03_todo_write.ts", + "s04": "tsx s04_subagent.ts", + "s05": "tsx s05_skill_loading.ts", + "s06": "tsx s06_context_compact.ts", + "s07": "tsx s07_task_system.ts", + "s08": "tsx s08_background_tasks.ts", + "s09": "tsx s09_agent_teams.ts", + "s10": "tsx s10_team_protocols.ts", + "s11": "tsx s11_autonomous_agents.ts", + "s12": "tsx s12_worktree_task_isolation.ts" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } +} diff --git a/agents-ts/s01_agent_loop.ts b/agents-ts/s01_agent_loop.ts new file mode 100644 index 000000000..a2512560d --- /dev/null +++ b/agents-ts/s01_agent_loop.ts @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * s01_agent_loop.ts - The Agent Loop + * + * The entire secret of an AI coding agent in one pattern: + * + * while (stopReason === "tool_use") { + * response = LLM(messages, tools) + * executeTools() + * appendResults() + * } + */ + +import { spawnSync } from "node:child_process"; +import process from "node:process"; +import { createInterface } from "node:readline/promises"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type ToolUseName = "bash"; + +type ToolUseBlock = { + id: string; + type: "tool_use"; + name: ToolUseName; + input: Record; +}; + +type TextBlock = { + type: "text"; + text: string; +}; + +type ToolResultBlock = { + type: "tool_result"; + tool_use_id: string; + content: string; +}; + +type MessageContent = string | Array; + +type Message = { + role: "user" | "assistant"; + content: MessageContent; +}; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); +const client = createAnthropicClient(); + +const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use bash to solve tasks. Act, don't explain.`); + +function runBash(command: string): string { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) { + return "Error: Dangerous command blocked"; + } + + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" + ? ["/d", "/s", "/c", command] + : ["-lc", command]; + + const result = spawnSync(shell, args, { + cwd: WORKDIR, + encoding: "utf8", + timeout: 120_000, + }); + + if (result.error?.name === "TimeoutError") { + return "Error: Timeout (120s)"; + } + + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +const TOOL_HANDLERS: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), +}; + +const TOOLS = [ + { + name: "bash", + description: shellToolDescription(), + input_schema: { + type: "object", + properties: { + command: { type: "string" }, + }, + required: ["command"], + }, + }, +]; + +function assistantText(content: Array) { + return content + .filter((block): block is TextBlock => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +export async function agentLoop(messages: Message[]) { + while (true) { + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + + messages.push({ + role: "assistant", + content: response.content as Array, + }); + + if (response.stop_reason !== "tool_use") { + return; + } + + const results: ToolResultBlock[] = []; + + for (const block of response.content) { + if (block.type !== "tool_use") continue; + + const handler = TOOL_HANDLERS[block.name as ToolUseName]; + const output = handler + ? handler(block.input as Record) + : `Unknown tool: ${block.name}`; + + console.log(`> ${block.name}: ${output.slice(0, 200)}`); + results.push({ + type: "tool_result", + tool_use_id: block.id, + content: output, + }); + } + + messages.push({ + role: "user", + content: results, + }); + } +} + +async function main() { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const history: Message[] = []; + + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms01 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) { + break; + } + + history.push({ role: "user", content: query }); + await agentLoop(history); + + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last); + if (text) console.log(text); + } + console.log(); + } + + rl.close(); +} + +void main(); diff --git a/agents-ts/s02_tool_use.ts b/agents-ts/s02_tool_use.ts new file mode 100644 index 000000000..210636896 --- /dev/null +++ b/agents-ts/s02_tool_use.ts @@ -0,0 +1,274 @@ +#!/usr/bin/env node +/** + * s02_tool_use.ts - Tools + * + * The loop from s01 does not change. We add more tools and a dispatch map: + * + * { tool_name: handler } + * + * Key insight: adding a tool means adding one handler. + */ + +import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import process from "node:process"; +import { createInterface } from "node:readline/promises"; +import { resolve } from "node:path"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type ToolUseName = "bash" | "read_file" | "write_file" | "edit_file"; + +type ToolUseBlock = { + id: string; + type: "tool_use"; + name: ToolUseName; + input: Record; +}; + +type TextBlock = { + type: "text"; + text: string; +}; + +type ToolResultBlock = { + type: "tool_result"; + tool_use_id: string; + content: string; +}; + +type MessageContent = string | Array; + +type Message = { + role: "user" | "assistant"; + content: MessageContent; +}; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); +const client = createAnthropicClient(); + +const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use tools to solve tasks. Act, don't explain.`); + +function safePath(relativePath: string): string { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) { + throw new Error(`Path escapes workspace: ${relativePath}`); + } + return filePath; +} + +function runBash(command: string): string { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) { + return "Error: Dangerous command blocked"; + } + + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" + ? ["/d", "/s", "/c", command] + : ["-lc", command]; + + const result = spawnSync(shell, args, { + cwd: WORKDIR, + encoding: "utf8", + timeout: 120_000, + }); + + if (result.error?.name === "TimeoutError") { + return "Error: Timeout (120s)"; + } + + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +function runRead(path: string, limit?: number): string { + try { + let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); + if (limit && limit < lines.length) { + lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); + } + return lines.join("\n").slice(0, 50_000); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runWrite(path: string, content: string): string { + try { + const filePath = safePath(path); + mkdirSync(resolve(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content, "utf8"); + return `Wrote ${content.length} bytes`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runEdit(path: string, oldText: string, newText: string): string { + try { + const filePath = safePath(path); + const content = readFileSync(filePath, "utf8"); + if (!content.includes(oldText)) { + return `Error: Text not found in ${path}`; + } + writeFileSync(filePath, content.replace(oldText, newText), "utf8"); + return `Edited ${path}`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +const TOOL_HANDLERS: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => + runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), +}; + +const TOOLS = [ + { + name: "bash", + description: shellToolDescription(), + input_schema: { + type: "object", + properties: { + command: { type: "string" }, + }, + required: ["command"], + }, + }, + { + name: "read_file", + description: "Read file contents.", + input_schema: { + type: "object", + properties: { + path: { type: "string" }, + limit: { type: "integer" }, + }, + required: ["path"], + }, + }, + { + name: "write_file", + description: "Write content to file.", + input_schema: { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" }, + }, + required: ["path", "content"], + }, + }, + { + name: "edit_file", + description: "Replace exact text in file.", + input_schema: { + type: "object", + properties: { + path: { type: "string" }, + old_text: { type: "string" }, + new_text: { type: "string" }, + }, + required: ["path", "old_text", "new_text"], + }, + }, +]; + +function assistantText(content: Array) { + return content + .filter((block): block is TextBlock => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +export async function agentLoop(messages: Message[]) { + while (true) { + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + + messages.push({ + role: "assistant", + content: response.content as Array, + }); + + if (response.stop_reason !== "tool_use") { + return; + } + + const results: ToolResultBlock[] = []; + + for (const block of response.content) { + if (block.type !== "tool_use") continue; + + const handler = TOOL_HANDLERS[block.name as ToolUseName]; + const output = handler + ? handler(block.input as Record) + : `Unknown tool: ${block.name}`; + + console.log(`> ${block.name}: ${output.slice(0, 200)}`); + results.push({ + type: "tool_result", + tool_use_id: block.id, + content: output, + }); + } + + messages.push({ + role: "user", + content: results, + }); + } +} + +async function main() { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const history: Message[] = []; + + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms02 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) { + break; + } + + history.push({ role: "user", content: query }); + await agentLoop(history); + + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last); + if (text) console.log(text); + } + console.log(); + } + + rl.close(); +} + +void main(); diff --git a/agents-ts/s03_todo_write.ts b/agents-ts/s03_todo_write.ts new file mode 100644 index 000000000..a19c28043 --- /dev/null +++ b/agents-ts/s03_todo_write.ts @@ -0,0 +1,383 @@ +#!/usr/bin/env node +/** + * s03_todo_write.ts - TodoWrite + * + * The model tracks its own progress through a TodoManager. + * A nag reminder pushes it to keep the plan updated. + */ + +import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; +import { createInterface } from "node:readline/promises"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type ToolUseName = "bash" | "read_file" | "write_file" | "edit_file" | "todo"; + +type TodoStatus = "pending" | "in_progress" | "completed"; + +type ToolUseBlock = { + id: string; + type: "tool_use"; + name: ToolUseName; + input: Record; +}; + +type TextBlock = { + type: "text"; + text: string; +}; + +type ToolResultBlock = { + type: "tool_result"; + tool_use_id: string; + content: string; +}; + +type MessageContent = string | Array; + +type Message = { + role: "user" | "assistant"; + content: MessageContent; +}; + +type TodoItem = { + id: string; + text: string; + status: TodoStatus; +}; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); +const client = createAnthropicClient(); + +const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. +Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done. +Prefer tools over prose.`); + +class TodoManager { + private items: TodoItem[] = []; + + update(items: unknown): string { + if (!Array.isArray(items)) { + throw new Error("items must be an array"); + } + if (items.length > 20) { + throw new Error("Max 20 todos allowed"); + } + + let inProgressCount = 0; + const validated = items.map((item, index) => { + const record = (item ?? {}) as Record; + const text = String(record.text ?? "").trim(); + const status = String(record.status ?? "pending").toLowerCase() as TodoStatus; + const id = String(record.id ?? index + 1); + + if (!text) throw new Error(`Item ${id}: text required`); + if (!["pending", "in_progress", "completed"].includes(status)) { + throw new Error(`Item ${id}: invalid status '${status}'`); + } + if (status === "in_progress") inProgressCount += 1; + + return { id, text, status }; + }); + + if (inProgressCount > 1) { + throw new Error("Only one task can be in_progress at a time"); + } + + this.items = validated; + return this.render(); + } + + render(): string { + if (this.items.length === 0) return "No todos."; + + const lines = this.items.map((item) => { + const marker = { + pending: "[ ]", + in_progress: "[>]", + completed: "[x]", + }[item.status]; + return `${marker} #${item.id}: ${item.text}`; + }); + + const done = this.items.filter((item) => item.status === "completed").length; + lines.push(`\n(${done}/${this.items.length} completed)`); + return lines.join("\n"); + } +} + +const TODO = new TodoManager(); + +function safePath(relativePath: string): string { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) { + throw new Error(`Path escapes workspace: ${relativePath}`); + } + return filePath; +} + +function runBash(command: string): string { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) { + return "Error: Dangerous command blocked"; + } + + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" + ? ["/d", "/s", "/c", command] + : ["-lc", command]; + + const result = spawnSync(shell, args, { + cwd: WORKDIR, + encoding: "utf8", + timeout: 120_000, + }); + + if (result.error?.name === "TimeoutError") { + return "Error: Timeout (120s)"; + } + + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +function runRead(path: string, limit?: number): string { + try { + let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); + if (limit && limit < lines.length) { + lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); + } + return lines.join("\n").slice(0, 50_000); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runWrite(path: string, content: string): string { + try { + const filePath = safePath(path); + mkdirSync(resolve(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content, "utf8"); + return `Wrote ${content.length} bytes`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runEdit(path: string, oldText: string, newText: string): string { + try { + const filePath = safePath(path); + const content = readFileSync(filePath, "utf8"); + if (!content.includes(oldText)) { + return `Error: Text not found in ${path}`; + } + writeFileSync(filePath, content.replace(oldText, newText), "utf8"); + return `Edited ${path}`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +const TOOL_HANDLERS: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => + runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), + todo: (input) => TODO.update(input.items), +}; + +const TOOLS = [ + { + name: "bash", + description: shellToolDescription(), + input_schema: { + type: "object", + properties: { + command: { type: "string" }, + }, + required: ["command"], + }, + }, + { + name: "read_file", + description: "Read file contents.", + input_schema: { + type: "object", + properties: { + path: { type: "string" }, + limit: { type: "integer" }, + }, + required: ["path"], + }, + }, + { + name: "write_file", + description: "Write content to file.", + input_schema: { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" }, + }, + required: ["path", "content"], + }, + }, + { + name: "edit_file", + description: "Replace exact text in file.", + input_schema: { + type: "object", + properties: { + path: { type: "string" }, + old_text: { type: "string" }, + new_text: { type: "string" }, + }, + required: ["path", "old_text", "new_text"], + }, + }, + { + name: "todo", + description: "Update task list. Track progress on multi-step tasks.", + input_schema: { + type: "object", + properties: { + items: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + text: { type: "string" }, + status: { + type: "string", + enum: ["pending", "in_progress", "completed"], + }, + }, + required: ["id", "text", "status"], + }, + }, + }, + required: ["items"], + }, + }, +]; + +function assistantText(content: Array) { + return content + .filter((block): block is TextBlock => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +export async function agentLoop(messages: Message[]) { + let roundsSinceTodo = 0; + + while (true) { + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + + messages.push({ + role: "assistant", + content: response.content as Array, + }); + + if (response.stop_reason !== "tool_use") { + return; + } + + const results: Array = []; + let usedTodo = false; + + for (const block of response.content) { + if (block.type !== "tool_use") continue; + + const handler = TOOL_HANDLERS[block.name as ToolUseName]; + let output: string; + + try { + output = handler + ? handler(block.input as Record) + : `Unknown tool: ${block.name}`; + } catch (error) { + output = `Error: ${error instanceof Error ? error.message : String(error)}`; + } + + console.log(`> ${block.name}: ${output.slice(0, 200)}`); + results.push({ + type: "tool_result", + tool_use_id: block.id, + content: output, + }); + + if (block.name === "todo") { + usedTodo = true; + } + } + + roundsSinceTodo = usedTodo ? 0 : roundsSinceTodo + 1; + if (roundsSinceTodo >= 3) { + results.unshift({ + type: "text", + text: "Update your todos.", + }); + } + + messages.push({ + role: "user", + content: results, + }); + } +} + +async function main() { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const history: Message[] = []; + + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms03 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) { + break; + } + + history.push({ role: "user", content: query }); + await agentLoop(history); + + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last); + if (text) console.log(text); + } + console.log(); + } + + rl.close(); +} + +void main(); diff --git a/agents-ts/s04_subagent.ts b/agents-ts/s04_subagent.ts new file mode 100644 index 000000000..cfa816686 --- /dev/null +++ b/agents-ts/s04_subagent.ts @@ -0,0 +1,332 @@ +#!/usr/bin/env node +/** + * s04_subagent.ts - Subagents + * + * Spawn a child agent with fresh messages=[]. + * The child shares the filesystem, but returns only a short summary. + */ + +import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; +import { createInterface } from "node:readline/promises"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type BaseToolName = "bash" | "read_file" | "write_file" | "edit_file"; +type ParentToolName = BaseToolName | "task"; + +type ToolUseBlock = { + id: string; + type: "tool_use"; + name: ParentToolName; + input: Record; +}; + +type TextBlock = { + type: "text"; + text: string; +}; + +type ToolResultBlock = { + type: "tool_result"; + tool_use_id: string; + content: string; +}; + +type MessageContent = string | Array; + +type Message = { + role: "user" | "assistant"; + content: MessageContent; +}; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); +const client = createAnthropicClient(); + +const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use the task tool to delegate exploration or subtasks.`); +const SUBAGENT_SYSTEM = buildSystemPrompt(`You are a coding subagent at ${WORKDIR}. Complete the given task, then summarize your findings.`); + +function safePath(relativePath: string): string { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) { + throw new Error(`Path escapes workspace: ${relativePath}`); + } + return filePath; +} + +function runBash(command: string): string { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) { + return "Error: Dangerous command blocked"; + } + + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" + ? ["/d", "/s", "/c", command] + : ["-lc", command]; + + const result = spawnSync(shell, args, { + cwd: WORKDIR, + encoding: "utf8", + timeout: 120_000, + }); + + if (result.error?.name === "TimeoutError") { + return "Error: Timeout (120s)"; + } + + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +function runRead(path: string, limit?: number): string { + try { + let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); + if (limit && limit < lines.length) { + lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); + } + return lines.join("\n").slice(0, 50_000); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runWrite(path: string, content: string): string { + try { + const filePath = safePath(path); + mkdirSync(resolve(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content, "utf8"); + return `Wrote ${content.length} bytes`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runEdit(path: string, oldText: string, newText: string): string { + try { + const filePath = safePath(path); + const content = readFileSync(filePath, "utf8"); + if (!content.includes(oldText)) { + return `Error: Text not found in ${path}`; + } + writeFileSync(filePath, content.replace(oldText, newText), "utf8"); + return `Edited ${path}`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +const TOOL_HANDLERS: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => + runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), +}; + +const CHILD_TOOLS = [ + { + name: "bash", + description: shellToolDescription(), + input_schema: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }, + }, + { + name: "read_file", + description: "Read file contents.", + input_schema: { + type: "object", + properties: { path: { type: "string" }, limit: { type: "integer" } }, + required: ["path"], + }, + }, + { + name: "write_file", + description: "Write content to file.", + input_schema: { + type: "object", + properties: { path: { type: "string" }, content: { type: "string" } }, + required: ["path", "content"], + }, + }, + { + name: "edit_file", + description: "Replace exact text in file.", + input_schema: { + type: "object", + properties: { + path: { type: "string" }, + old_text: { type: "string" }, + new_text: { type: "string" }, + }, + required: ["path", "old_text", "new_text"], + }, + }, +]; + +const PARENT_TOOLS = [ + ...CHILD_TOOLS, + { + name: "task", + description: "Spawn a subagent with fresh context. It shares the filesystem but not conversation history.", + input_schema: { + type: "object", + properties: { + prompt: { type: "string" }, + description: { type: "string" }, + }, + required: ["prompt"], + }, + }, +]; + +function assistantText(content: Array) { + return content + .filter((block): block is TextBlock => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +async function runSubagent(prompt: string): Promise { + const subMessages: Message[] = [{ role: "user", content: prompt }]; + let response: Anthropic.Messages.Message | null = null; + + for (let attempt = 0; attempt < 30; attempt += 1) { + response = await client.messages.create({ + model: MODEL, + system: SUBAGENT_SYSTEM, + messages: subMessages as Anthropic.Messages.MessageParam[], + tools: CHILD_TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + + subMessages.push({ + role: "assistant", + content: response.content as Array, + }); + + if (response.stop_reason !== "tool_use") { + break; + } + + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use" || block.name === "task") continue; + const handler = TOOL_HANDLERS[block.name as BaseToolName]; + const output = handler + ? handler(block.input as Record) + : `Unknown tool: ${block.name}`; + results.push({ + type: "tool_result", + tool_use_id: block.id, + content: String(output).slice(0, 50_000), + }); + } + + subMessages.push({ role: "user", content: results }); + } + + if (!response) return "(no summary)"; + const texts: string[] = []; + for (const block of response.content) { + if (block.type === "text") texts.push(block.text); + } + return texts.join("") || "(no summary)"; +} + +export async function agentLoop(messages: Message[]) { + while (true) { + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: PARENT_TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + + messages.push({ + role: "assistant", + content: response.content as Array, + }); + + if (response.stop_reason !== "tool_use") { + return; + } + + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + + let output: string; + if (block.name === "task") { + const input = block.input as { description?: string; prompt?: string }; + const description = String(input.description ?? "subtask"); + console.log(`> task (${description}): ${String(input.prompt ?? "").slice(0, 80)}`); + output = await runSubagent(String(input.prompt ?? "")); + } else { + const handler = TOOL_HANDLERS[block.name as BaseToolName]; + output = handler + ? handler(block.input as Record) + : `Unknown tool: ${block.name}`; + } + + console.log(` ${output.slice(0, 200)}`); + results.push({ + type: "tool_result", + tool_use_id: block.id, + content: output, + }); + } + + messages.push({ role: "user", content: results }); + } +} + +async function main() { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const history: Message[] = []; + + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms04 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) { + break; + } + + history.push({ role: "user", content: query }); + await agentLoop(history); + + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last); + if (text) console.log(text); + } + console.log(); + } + + rl.close(); +} + +void main(); diff --git a/agents-ts/s05_skill_loading.ts b/agents-ts/s05_skill_loading.ts new file mode 100644 index 000000000..1ffc7559a --- /dev/null +++ b/agents-ts/s05_skill_loading.ts @@ -0,0 +1,375 @@ +#!/usr/bin/env node +/** + * s05_skill_loading.ts - Skills + * + * Two-layer skill injection: + * 1. Keep lightweight skill metadata in the system prompt. + * 2. Load the full SKILL.md body only when the model asks for it. + */ + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; +import { createInterface } from "node:readline/promises"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "load_skill"; + +type ToolUseBlock = { + id: string; + type: "tool_use"; + name: ToolName; + input: Record; +}; + +type TextBlock = { + type: "text"; + text: string; +}; + +type ToolResultBlock = { + type: "tool_result"; + tool_use_id: string; + content: string; +}; + +type MessageContent = string | Array; + +type Message = { + role: "user" | "assistant"; + content: MessageContent; +}; + +type SkillRecord = { + meta: Record; + body: string; + path: string; +}; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); +const SKILLS_DIR = resolve(WORKDIR, "..", "skills"); +const client = createAnthropicClient(); + +function safePath(relativePath: string): string { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) { + throw new Error(`Path escapes workspace: ${relativePath}`); + } + return filePath; +} + +function runBash(command: string): string { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) { + return "Error: Dangerous command blocked"; + } + + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" + ? ["/d", "/s", "/c", command] + : ["-lc", command]; + + const result = spawnSync(shell, args, { + cwd: WORKDIR, + encoding: "utf8", + timeout: 120_000, + }); + + if (result.error?.name === "TimeoutError") { + return "Error: Timeout (120s)"; + } + + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +function runRead(path: string, limit?: number): string { + try { + let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); + if (limit && limit < lines.length) { + lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); + } + return lines.join("\n").slice(0, 50_000); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runWrite(path: string, content: string): string { + try { + const filePath = safePath(path); + mkdirSync(resolve(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content, "utf8"); + return `Wrote ${content.length} bytes`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runEdit(path: string, oldText: string, newText: string): string { + try { + const filePath = safePath(path); + const content = readFileSync(filePath, "utf8"); + if (!content.includes(oldText)) { + return `Error: Text not found in ${path}`; + } + writeFileSync(filePath, content.replace(oldText, newText), "utf8"); + return `Edited ${path}`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function parseFrontmatter(text: string): { meta: Record; body: string } { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/m.exec(text); + if (!match) { + return { meta: {}, body: text.trim() }; + } + + const meta: Record = {}; + for (const line of match[1].split(/\r?\n/)) { + const separator = line.indexOf(":"); + if (separator < 0) continue; + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + if (key) meta[key] = value; + } + + return { meta, body: match[2].trim() }; +} + +function collectSkillFiles(dir: string): string[] { + if (!existsSync(dir)) { + return []; + } + + const files: string[] = []; + const stack = [dir]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + + for (const entry of readdirSync(current)) { + const entryPath = resolve(current, entry); + const stats = statSync(entryPath); + if (stats.isDirectory()) { + stack.push(entryPath); + continue; + } + + if (stats.isFile() && entry === "SKILL.md") { + files.push(entryPath); + } + } + } + + return files.sort((a, b) => a.localeCompare(b)); +} + +class SkillLoader { + skills: Record = {}; + + constructor(private skillsDir: string) { + this.loadAll(); + } + + private loadAll() { + for (const filePath of collectSkillFiles(this.skillsDir)) { + const text = readFileSync(filePath, "utf8"); + const { meta, body } = parseFrontmatter(text); + const normalized = filePath.replace(/\\/g, "/"); + const fallbackName = normalized.split("/").slice(-2, -1)[0] ?? "unknown"; + const name = meta.name || fallbackName; + this.skills[name] = { meta, body, path: filePath }; + } + } + + getDescriptions(): string { + const entries = Object.entries(this.skills); + if (entries.length === 0) { + return "(no skills available)"; + } + + return entries + .map(([name, skill]) => { + const desc = skill.meta.description || "No description"; + const tags = skill.meta.tags ? ` [${skill.meta.tags}]` : ""; + return ` - ${name}: ${desc}${tags}`; + }) + .join("\n"); + } + + getContent(name: string): string { + const skill = this.skills[name]; + if (!skill) { + const names = Object.keys(this.skills).join(", "); + return `Error: Unknown skill '${name}'. Available: ${names}`; + } + return `\n${skill.body}\n`; + } +} + +const skillLoader = new SkillLoader(SKILLS_DIR); + +const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. +Use load_skill to access specialized knowledge before tackling unfamiliar topics. + +Skills available: +${skillLoader.getDescriptions()}`); + +const TOOL_HANDLERS: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => + runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), + load_skill: (input) => skillLoader.getContent(String(input.name ?? "")), +}; + +const TOOLS = [ + { + name: "bash", + description: shellToolDescription(), + input_schema: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }, + }, + { + name: "read_file", + description: "Read file contents.", + input_schema: { + type: "object", + properties: { path: { type: "string" }, limit: { type: "integer" } }, + required: ["path"], + }, + }, + { + name: "write_file", + description: "Write content to file.", + input_schema: { + type: "object", + properties: { path: { type: "string" }, content: { type: "string" } }, + required: ["path", "content"], + }, + }, + { + name: "edit_file", + description: "Replace exact text in file.", + input_schema: { + type: "object", + properties: { + path: { type: "string" }, + old_text: { type: "string" }, + new_text: { type: "string" }, + }, + required: ["path", "old_text", "new_text"], + }, + }, + { + name: "load_skill", + description: "Load specialized knowledge by name.", + input_schema: { + type: "object", + properties: { + name: { type: "string", description: "Skill name to load" }, + }, + required: ["name"], + }, + }, +]; + +function assistantText(content: Array) { + return content + .filter((block): block is TextBlock => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +export async function agentLoop(messages: Message[]) { + while (true) { + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + + messages.push({ + role: "assistant", + content: response.content as Array, + }); + + if (response.stop_reason !== "tool_use") { + return; + } + + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + + const handler = TOOL_HANDLERS[block.name as ToolName]; + const output = handler + ? handler(block.input as Record) + : `Unknown tool: ${block.name}`; + + console.log(`> ${block.name}: ${output.slice(0, 200)}`); + results.push({ + type: "tool_result", + tool_use_id: block.id, + content: output, + }); + } + + messages.push({ role: "user", content: results }); + } +} + +async function main() { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const history: Message[] = []; + + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms05 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) { + break; + } + + history.push({ role: "user", content: query }); + await agentLoop(history); + + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last); + if (text) console.log(text); + } + console.log(); + } + + rl.close(); +} + +void main(); diff --git a/agents-ts/s06_context_compact.ts b/agents-ts/s06_context_compact.ts new file mode 100644 index 000000000..212fb03ce --- /dev/null +++ b/agents-ts/s06_context_compact.ts @@ -0,0 +1,390 @@ +#!/usr/bin/env node +/** + * s06_context_compact.ts - Compact + * + * Three-layer compression pipeline: + * 1. Micro-compact old tool results before each model call. + * 2. Auto-compact when the token estimate crosses a threshold. + * 3. Expose a compact tool for manual summarization. + */ + +import { spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; +import { createInterface } from "node:readline/promises"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "compact"; + +type ToolUseBlock = { + id: string; + type: "tool_use"; + name: ToolName; + input: Record; +}; + +type TextBlock = { + type: "text"; + text: string; +}; + +type ToolResultBlock = { + type: "tool_result"; + tool_use_id: string; + content: string; +}; + +type AssistantBlock = ToolUseBlock | TextBlock; +type MessageContent = string | Array; + +type Message = { + role: "user" | "assistant"; + content: MessageContent; +}; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); +const THRESHOLD = 50_000; +const KEEP_RECENT = 3; +const TRANSCRIPT_DIR = resolve(WORKDIR, ".transcripts"); +const client = createAnthropicClient(); + +const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use tools to solve tasks.`); + +function safePath(relativePath: string): string { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) { + throw new Error(`Path escapes workspace: ${relativePath}`); + } + return filePath; +} + +function runBash(command: string): string { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) { + return "Error: Dangerous command blocked"; + } + + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" + ? ["/d", "/s", "/c", command] + : ["-lc", command]; + + const result = spawnSync(shell, args, { + cwd: WORKDIR, + encoding: "utf8", + timeout: 120_000, + }); + + if (result.error?.name === "TimeoutError") { + return "Error: Timeout (120s)"; + } + + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +function runRead(path: string, limit?: number): string { + try { + let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); + if (limit && limit < lines.length) { + lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); + } + return lines.join("\n").slice(0, 50_000); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runWrite(path: string, content: string): string { + try { + const filePath = safePath(path); + mkdirSync(resolve(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content, "utf8"); + return `Wrote ${content.length} bytes`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runEdit(path: string, oldText: string, newText: string): string { + try { + const filePath = safePath(path); + const content = readFileSync(filePath, "utf8"); + if (!content.includes(oldText)) { + return `Error: Text not found in ${path}`; + } + writeFileSync(filePath, content.replace(oldText, newText), "utf8"); + return `Edited ${path}`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function estimateTokens(messages: Message[]): number { + return JSON.stringify(messages).length / 4; +} + +function isToolResultBlock(block: unknown): block is ToolResultBlock { + return !!block + && typeof block === "object" + && "type" in block + && (block as { type?: string }).type === "tool_result"; +} + +function isToolUseBlock(block: unknown): block is ToolUseBlock { + return !!block + && typeof block === "object" + && "type" in block + && (block as { type?: string }).type === "tool_use"; +} + +function microCompact(messages: Message[]): Message[] { + const toolResults: ToolResultBlock[] = []; + + for (const message of messages) { + if (message.role !== "user" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (isToolResultBlock(part)) { + toolResults.push(part); + } + } + } + + if (toolResults.length <= KEEP_RECENT) { + return messages; + } + + const toolNameMap: Record = {}; + for (const message of messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const block of message.content) { + if (isToolUseBlock(block)) { + toolNameMap[block.id] = block.name; + } + } + } + + for (const result of toolResults.slice(0, -KEEP_RECENT)) { + if (result.content.length <= 100) continue; + const toolName = toolNameMap[result.tool_use_id] ?? "unknown"; + result.content = `[Previous: used ${toolName}]`; + } + + return messages; +} + +async function autoCompact(messages: Message[]): Promise { + if (!existsSync(TRANSCRIPT_DIR)) { + mkdirSync(TRANSCRIPT_DIR, { recursive: true }); + } + + const transcriptPath = resolve(TRANSCRIPT_DIR, `transcript_${Date.now()}.jsonl`); + for (const message of messages) { + appendFileSync(transcriptPath, `${JSON.stringify(message)}\n`, "utf8"); + } + console.log(`[transcript saved: ${transcriptPath}]`); + + const conversationText = JSON.stringify(messages).slice(0, 80_000); + const response = await client.messages.create({ + model: MODEL, + messages: [{ + role: "user", + content: + "Summarize this conversation for continuity. Include: " + + "1) What was accomplished, 2) Current state, 3) Key decisions made. " + + `Be concise but preserve critical details.\n\n${conversationText}`, + }], + max_tokens: 2000, + }); + + const summaryParts: string[] = []; + for (const block of response.content) { + if (block.type === "text") summaryParts.push(block.text); + } + const summary = summaryParts.join("") || "(no summary)"; + + return [ + { + role: "user", + content: `[Conversation compressed. Transcript: ${transcriptPath}]\n\n${summary}`, + }, + { + role: "assistant", + content: "Understood. I have the context from the summary. Continuing.", + }, + ]; +} + +const TOOL_HANDLERS: Record, (input: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => + runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), +}; + +const TOOLS = [ + { + name: "bash", + description: shellToolDescription(), + input_schema: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }, + }, + { + name: "read_file", + description: "Read file contents.", + input_schema: { + type: "object", + properties: { path: { type: "string" }, limit: { type: "integer" } }, + required: ["path"], + }, + }, + { + name: "write_file", + description: "Write content to file.", + input_schema: { + type: "object", + properties: { path: { type: "string" }, content: { type: "string" } }, + required: ["path", "content"], + }, + }, + { + name: "edit_file", + description: "Replace exact text in file.", + input_schema: { + type: "object", + properties: { + path: { type: "string" }, + old_text: { type: "string" }, + new_text: { type: "string" }, + }, + required: ["path", "old_text", "new_text"], + }, + }, + { + name: "compact", + description: "Trigger manual conversation compression.", + input_schema: { + type: "object", + properties: { + focus: { type: "string", description: "What to preserve in the summary" }, + }, + }, + }, +]; + +function assistantText(content: AssistantBlock[]) { + return content + .filter((block): block is TextBlock => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +export async function agentLoop(messages: Message[]) { + while (true) { + microCompact(messages); + + if (estimateTokens(messages) > THRESHOLD) { + console.log("[auto_compact triggered]"); + messages.splice(0, messages.length, ...(await autoCompact(messages))); + } + + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + + messages.push({ + role: "assistant", + content: response.content as AssistantBlock[], + }); + + if (response.stop_reason !== "tool_use") { + return; + } + + let manualCompact = false; + const results: ToolResultBlock[] = []; + + for (const block of response.content) { + if (block.type !== "tool_use") continue; + + let output: string; + if (block.name === "compact") { + manualCompact = true; + output = "Compressing..."; + } else { + const handler = TOOL_HANDLERS[block.name as Exclude]; + output = handler + ? handler(block.input as Record) + : `Unknown tool: ${block.name}`; + } + + console.log(`> ${block.name}: ${output.slice(0, 200)}`); + results.push({ + type: "tool_result", + tool_use_id: block.id, + content: output, + }); + } + + messages.push({ role: "user", content: results }); + + if (manualCompact) { + console.log("[manual compact]"); + messages.splice(0, messages.length, ...(await autoCompact(messages))); + } + } +} + +async function main() { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const history: Message[] = []; + + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms06 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) { + break; + } + + history.push({ role: "user", content: query }); + await agentLoop(history); + + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last as AssistantBlock[]); + if (text) console.log(text); + } + console.log(); + } + + rl.close(); +} + +void main(); diff --git a/agents-ts/s07_task_system.ts b/agents-ts/s07_task_system.ts new file mode 100644 index 000000000..791e2f3f8 --- /dev/null +++ b/agents-ts/s07_task_system.ts @@ -0,0 +1,291 @@ +#!/usr/bin/env node +/** + * s07_task_system.ts - Tasks + * + * Persistent task graph stored in .tasks/. + */ + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; +import { createInterface } from "node:readline/promises"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type TaskStatus = "pending" | "in_progress" | "completed"; +type ToolName = + | "bash" + | "read_file" + | "write_file" + | "edit_file" + | "task_create" + | "task_update" + | "task_list" + | "task_get"; + +type Task = { + id: number; + subject: string; + description: string; + status: TaskStatus; + blockedBy: number[]; + blocks: number[]; + owner: string; +}; + +type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record }; +type TextBlock = { type: "text"; text: string }; +type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string }; +type Message = { role: "user" | "assistant"; content: string | Array }; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); +const TASKS_DIR = resolve(WORKDIR, ".tasks"); +const client = createAnthropicClient(); + +const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use task tools to plan and track work.`); + +function safePath(relativePath: string) { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) { + throw new Error(`Path escapes workspace: ${relativePath}`); + } + return filePath; +} + +function runBash(command: string): string { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked"; + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command]; + const result = spawnSync(shell, args, { cwd: WORKDIR, encoding: "utf8", timeout: 120_000 }); + if (result.error?.name === "TimeoutError") return "Error: Timeout (120s)"; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +function runRead(path: string, limit?: number): string { + try { + let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); + if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); + return lines.join("\n").slice(0, 50_000); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runWrite(path: string, content: string): string { + try { + const filePath = safePath(path); + mkdirSync(resolve(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content, "utf8"); + return `Wrote ${content.length} bytes`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runEdit(path: string, oldText: string, newText: string): string { + try { + const filePath = safePath(path); + const content = readFileSync(filePath, "utf8"); + if (!content.includes(oldText)) return `Error: Text not found in ${path}`; + writeFileSync(filePath, content.replace(oldText, newText), "utf8"); + return `Edited ${path}`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +class TaskManager { + private nextId: number; + + constructor(private tasksDir: string) { + mkdirSync(tasksDir, { recursive: true }); + this.nextId = this.maxId() + 1; + } + + private maxId(): number { + return readdirSync(this.tasksDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /^task_\d+\.json$/.test(entry.name)) + .map((entry) => Number(entry.name.match(/\d+/)?.[0] ?? 0)) + .reduce((max, id) => Math.max(max, id), 0); + } + + private filePath(taskId: number) { + return resolve(this.tasksDir, `task_${taskId}.json`); + } + + private load(taskId: number): Task { + const path = this.filePath(taskId); + if (!existsSync(path)) throw new Error(`Task ${taskId} not found`); + return JSON.parse(readFileSync(path, "utf8")) as Task; + } + + private save(task: Task) { + writeFileSync(this.filePath(task.id), `${JSON.stringify(task, null, 2)}\n`, "utf8"); + } + + create(subject: string, description = "") { + const task: Task = { + id: this.nextId, + subject, + description, + status: "pending", + blockedBy: [], + blocks: [], + owner: "", + }; + this.save(task); + this.nextId += 1; + return JSON.stringify(task, null, 2); + } + + get(taskId: number) { + return JSON.stringify(this.load(taskId), null, 2); + } + + private clearDependency(completedId: number) { + for (const entry of readdirSync(this.tasksDir, { withFileTypes: true })) { + if (!entry.isFile() || !/^task_\d+\.json$/.test(entry.name)) continue; + const path = resolve(this.tasksDir, entry.name); + const task = JSON.parse(readFileSync(path, "utf8")) as Task; + if (task.blockedBy.includes(completedId)) { + task.blockedBy = task.blockedBy.filter((id) => id !== completedId); + this.save(task); + } + } + } + + update(taskId: number, status?: string, addBlockedBy?: number[], addBlocks?: number[]) { + const task = this.load(taskId); + if (status) { + if (!["pending", "in_progress", "completed"].includes(status)) { + throw new Error(`Invalid status: ${status}`); + } + task.status = status as TaskStatus; + if (task.status === "completed") this.clearDependency(taskId); + } + if (addBlockedBy?.length) task.blockedBy = [...new Set(task.blockedBy.concat(addBlockedBy))]; + if (addBlocks?.length) { + task.blocks = [...new Set(task.blocks.concat(addBlocks))]; + for (const blockedId of addBlocks) { + try { + const blocked = this.load(blockedId); + if (!blocked.blockedBy.includes(taskId)) { + blocked.blockedBy.push(taskId); + this.save(blocked); + } + } catch {} + } + } + this.save(task); + return JSON.stringify(task, null, 2); + } + + listAll() { + const tasks = readdirSync(this.tasksDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /^task_\d+\.json$/.test(entry.name)) + .map((entry) => JSON.parse(readFileSync(resolve(this.tasksDir, entry.name), "utf8")) as Task) + .sort((a, b) => a.id - b.id); + if (!tasks.length) return "No tasks."; + return tasks + .map((task) => { + const marker = { pending: "[ ]", in_progress: "[>]", completed: "[x]" }[task.status] ?? "[?]"; + const blocked = task.blockedBy.length ? ` (blocked by: ${JSON.stringify(task.blockedBy)})` : ""; + return `${marker} #${task.id}: ${task.subject}${blocked}`; + }) + .join("\n"); + } +} + +const TASKS = new TaskManager(TASKS_DIR); + +const TOOL_HANDLERS: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), + task_create: (input) => TASKS.create(String(input.subject ?? ""), String(input.description ?? "")), + task_update: (input) => TASKS.update( + Number(input.task_id ?? 0), + typeof input.status === "string" ? input.status : undefined, + Array.isArray(input.addBlockedBy) ? input.addBlockedBy.map(Number) : undefined, + Array.isArray(input.addBlocks) ? input.addBlocks.map(Number) : undefined, + ), + task_list: () => TASKS.listAll(), + task_get: (input) => TASKS.get(Number(input.task_id ?? 0)), +}; + +const TOOLS = [ + { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } }, + { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } }, + { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } }, + { name: "edit_file", description: "Replace exact text in file.", input_schema: { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] } }, + { name: "task_create", description: "Create a new task.", input_schema: { type: "object", properties: { subject: { type: "string" }, description: { type: "string" } }, required: ["subject"] } }, + { name: "task_update", description: "Update a task's status or dependencies.", input_schema: { type: "object", properties: { task_id: { type: "integer" }, status: { type: "string", enum: ["pending", "in_progress", "completed"] }, addBlockedBy: { type: "array", items: { type: "integer" } }, addBlocks: { type: "array", items: { type: "integer" } } }, required: ["task_id"] } }, + { name: "task_list", description: "List all tasks with status summary.", input_schema: { type: "object", properties: {} } }, + { name: "task_get", description: "Get full details of a task by ID.", input_schema: { type: "object", properties: { task_id: { type: "integer" } }, required: ["task_id"] } }, +]; + +function assistantText(content: Array) { + return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n"); +} + +export async function agentLoop(messages: Message[]) { + while (true) { + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + messages.push({ role: "assistant", content: response.content as Array }); + if (response.stop_reason !== "tool_use") return; + + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + const handler = TOOL_HANDLERS[block.name as ToolName]; + const output = handler ? handler(block.input as Record) : `Unknown tool: ${block.name}`; + console.log(`> ${block.name}: ${output.slice(0, 200)}`); + results.push({ type: "tool_result", tool_use_id: block.id, content: output }); + } + messages.push({ role: "user", content: results }); + } +} + +async function main() { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const history: Message[] = []; + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms07 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) break; + history.push({ role: "user", content: query }); + await agentLoop(history); + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last as Array); + if (text) console.log(text); + } + console.log(); + } + rl.close(); +} + +void main(); diff --git a/agents-ts/s08_background_tasks.ts b/agents-ts/s08_background_tasks.ts new file mode 100644 index 000000000..847ce5bab --- /dev/null +++ b/agents-ts/s08_background_tasks.ts @@ -0,0 +1,252 @@ +#!/usr/bin/env node +/** + * s08_background_tasks.ts - Background Tasks + * + * Run commands in background child processes and inject notifications later. + */ + +import { spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; +import { createInterface } from "node:readline/promises"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "background_run" | "check_background"; +type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record }; +type TextBlock = { type: "text"; text: string }; +type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string }; +type Message = { role: "user" | "assistant"; content: string | Array }; + +type BackgroundTask = { + status: "running" | "completed" | "timeout" | "error"; + result: string | null; + command: string; +}; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); +const client = createAnthropicClient(); + +const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use background_run for long-running commands.`); + +function safePath(relativePath: string) { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) throw new Error(`Path escapes workspace: ${relativePath}`); + return filePath; +} + +function runCommand(command: string, cwd: string, timeout = 120_000): string { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked"; + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command]; + const result = spawnSync(shell, args, { cwd, encoding: "utf8", timeout }); + if (result.error?.name === "TimeoutError") return `Error: Timeout (${Math.floor(timeout / 1000)}s)`; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +function runBash(command: string): string { + return runCommand(command, WORKDIR, 120_000); +} + +function runRead(path: string, limit?: number): string { + try { + let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); + if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); + return lines.join("\n").slice(0, 50_000); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runWrite(path: string, content: string): string { + try { + const filePath = safePath(path); + mkdirSync(resolve(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content, "utf8"); + return `Wrote ${content.length} bytes`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runEdit(path: string, oldText: string, newText: string): string { + try { + const filePath = safePath(path); + const content = readFileSync(filePath, "utf8"); + if (!content.includes(oldText)) return `Error: Text not found in ${path}`; + writeFileSync(filePath, content.replace(oldText, newText), "utf8"); + return `Edited ${path}`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +class BackgroundManager { + tasks: Record = {}; + private notificationQueue: Array<{ task_id: string; status: string; command: string; result: string }> = []; + + run(command: string): string { + const taskId = randomUUID().slice(0, 8); + this.tasks[taskId] = { status: "running", result: null, command }; + + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command]; + const child = spawn(shell, args, { cwd: WORKDIR, stdio: ["ignore", "pipe", "pipe"] }); + + let output = ""; + const timer = setTimeout(() => { + child.kill(); + this.tasks[taskId].status = "timeout"; + this.tasks[taskId].result = "Error: Timeout (300s)"; + this.notificationQueue.push({ + task_id: taskId, + status: "timeout", + command: command.slice(0, 80), + result: "Error: Timeout (300s)", + }); + }, 300_000); + + child.stdout.on("data", (chunk) => { output += String(chunk); }); + child.stderr.on("data", (chunk) => { output += String(chunk); }); + child.on("error", (error) => { + clearTimeout(timer); + this.tasks[taskId].status = "error"; + this.tasks[taskId].result = `Error: ${error.message}`; + this.notificationQueue.push({ + task_id: taskId, + status: "error", + command: command.slice(0, 80), + result: `Error: ${error.message}`.slice(0, 500), + }); + }); + child.on("close", () => { + if (this.tasks[taskId].status !== "running") return; + clearTimeout(timer); + const result = output.trim().slice(0, 50_000) || "(no output)"; + this.tasks[taskId].status = "completed"; + this.tasks[taskId].result = result; + this.notificationQueue.push({ + task_id: taskId, + status: "completed", + command: command.slice(0, 80), + result: result.slice(0, 500), + }); + }); + + return `Background task ${taskId} started: ${command.slice(0, 80)}`; + } + + check(taskId?: string): string { + if (taskId) { + const task = this.tasks[taskId]; + if (!task) return `Error: Unknown task ${taskId}`; + return `[${task.status}] ${task.command.slice(0, 60)}\n${task.result ?? "(running)"}`; + } + const lines = Object.entries(this.tasks).map(([id, task]) => `${id}: [${task.status}] ${task.command.slice(0, 60)}`); + return lines.length ? lines.join("\n") : "No background tasks."; + } + + drainNotifications() { + const notifications = [...this.notificationQueue]; + this.notificationQueue = []; + return notifications; + } +} + +const BG = new BackgroundManager(); + +const TOOL_HANDLERS: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), + background_run: (input) => BG.run(String(input.command ?? "")), + check_background: (input) => BG.check(typeof input.task_id === "string" ? input.task_id : undefined), +}; + +const TOOLS = [ + { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } }, + { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } }, + { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } }, + { name: "edit_file", description: "Replace exact text in file.", input_schema: { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] } }, + { name: "background_run", description: "Run command in background. Returns task_id immediately.", input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } }, + { name: "check_background", description: "Check background task status. Omit task_id to list all.", input_schema: { type: "object", properties: { task_id: { type: "string" } } } }, +]; + +function assistantText(content: Array) { + return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n"); +} + +export async function agentLoop(messages: Message[]) { + while (true) { + const notifications = BG.drainNotifications(); + if (notifications.length && messages.length) { + const notifText = notifications.map((n) => `[bg:${n.task_id}] ${n.status}: ${n.result}`).join("\n"); + messages.push({ role: "user", content: `\n${notifText}\n` }); + messages.push({ role: "assistant", content: "Noted background results." }); + } + + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + messages.push({ role: "assistant", content: response.content as Array }); + if (response.stop_reason !== "tool_use") return; + + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + const handler = TOOL_HANDLERS[block.name as ToolName]; + let output = ""; + try { + output = handler ? handler(block.input as Record) : `Unknown tool: ${block.name}`; + } catch (error) { + output = `Error: ${error instanceof Error ? error.message : String(error)}`; + } + console.log(`> ${block.name}: ${output.slice(0, 200)}`); + results.push({ type: "tool_result", tool_use_id: block.id, content: output }); + } + messages.push({ role: "user", content: results }); + } +} + +async function main() { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const history: Message[] = []; + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms08 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) break; + history.push({ role: "user", content: query }); + await agentLoop(history); + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last as Array); + if (text) console.log(text); + } + console.log(); + } + rl.close(); +} + +void main(); diff --git a/agents-ts/s09_agent_teams.ts b/agents-ts/s09_agent_teams.ts new file mode 100644 index 000000000..0292b7773 --- /dev/null +++ b/agents-ts/s09_agent_teams.ts @@ -0,0 +1,318 @@ +#!/usr/bin/env node +/** + * s09_agent_teams.ts - Agent Teams + * + * Persistent teammates with JSONL inboxes. + */ + +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { createInterface } from "node:readline/promises"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type ToolName = + | "bash" | "read_file" | "write_file" | "edit_file" + | "spawn_teammate" | "list_teammates" | "send_message" | "read_inbox" | "broadcast"; +type MessageType = "message" | "broadcast" | "shutdown_request" | "shutdown_response" | "plan_approval_response"; +type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record }; +type TextBlock = { type: "text"; text: string }; +type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string }; +type Message = { role: "user" | "assistant"; content: string | Array }; +type TeamMember = { name: string; role: string; status: "working" | "idle" | "shutdown" }; +type TeamConfig = { team_name: string; members: TeamMember[] }; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); +const TEAM_DIR = resolve(WORKDIR, ".team"); +const INBOX_DIR = resolve(TEAM_DIR, "inbox"); +const VALID_MSG_TYPES: MessageType[] = ["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]; +const client = createAnthropicClient(); + +const SYSTEM = buildSystemPrompt(`You are a team lead at ${WORKDIR}. Spawn teammates and communicate via inboxes.`); + +function safePath(relativePath: string) { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) throw new Error(`Path escapes workspace: ${relativePath}`); + return filePath; +} + +function runBash(command: string): string { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked"; + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command]; + const result = spawnSync(shell, args, { cwd: WORKDIR, encoding: "utf8", timeout: 120_000 }); + if (result.error?.name === "TimeoutError") return "Error: Timeout (120s)"; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +function runRead(path: string, limit?: number): string { + try { + let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); + if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); + return lines.join("\n").slice(0, 50_000); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runWrite(path: string, content: string): string { + try { + const filePath = safePath(path); + mkdirSync(resolve(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content, "utf8"); + return `Wrote ${content.length} bytes`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runEdit(path: string, oldText: string, newText: string): string { + try { + const filePath = safePath(path); + const content = readFileSync(filePath, "utf8"); + if (!content.includes(oldText)) return `Error: Text not found in ${path}`; + writeFileSync(filePath, content.replace(oldText, newText), "utf8"); + return `Edited ${path}`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +class MessageBus { + constructor(private inboxDir: string) { + mkdirSync(inboxDir, { recursive: true }); + } + + send(sender: string, to: string, content: string, msgType: MessageType = "message", extra?: Record) { + if (!VALID_MSG_TYPES.includes(msgType)) return `Error: Invalid type '${msgType}'.`; + const payload = { type: msgType, from: sender, content, timestamp: Date.now() / 1000, ...(extra ?? {}) }; + appendFileSync(resolve(this.inboxDir, `${to}.jsonl`), `${JSON.stringify(payload)}\n`, "utf8"); + return `Sent ${msgType} to ${to}`; + } + + readInbox(name: string) { + const inboxPath = resolve(this.inboxDir, `${name}.jsonl`); + if (!existsSync(inboxPath)) return []; + const lines = readFileSync(inboxPath, "utf8").split(/\r?\n/).filter(Boolean); + writeFileSync(inboxPath, "", "utf8"); + return lines.map((line) => JSON.parse(line)); + } + + broadcast(sender: string, content: string, teammates: string[]) { + let count = 0; + for (const name of teammates) { + if (name === sender) continue; + this.send(sender, name, content, "broadcast"); + count += 1; + } + return `Broadcast to ${count} teammates`; + } +} + +const BUS = new MessageBus(INBOX_DIR); + +class TeammateManager { + private configPath: string; + private config: TeamConfig; + + constructor(private teamDir: string) { + mkdirSync(teamDir, { recursive: true }); + this.configPath = resolve(teamDir, "config.json"); + this.config = this.loadConfig(); + } + + private loadConfig(): TeamConfig { + if (existsSync(this.configPath)) return JSON.parse(readFileSync(this.configPath, "utf8")) as TeamConfig; + return { team_name: "default", members: [] }; + } + + private saveConfig() { + writeFileSync(this.configPath, `${JSON.stringify(this.config, null, 2)}\n`, "utf8"); + } + + private findMember(name: string) { + return this.config.members.find((member) => member.name === name); + } + + spawn(name: string, role: string, prompt: string) { + let member = this.findMember(name); + if (member) { + if (!["idle", "shutdown"].includes(member.status)) return `Error: '${name}' is currently ${member.status}`; + member.status = "working"; + member.role = role; + } else { + member = { name, role, status: "working" }; + this.config.members.push(member); + } + this.saveConfig(); + void this.teammateLoop(name, role, prompt); + return `Spawned '${name}' (role: ${role})`; + } + + private async teammateLoop(name: string, role: string, prompt: string) { + const sysPrompt = buildSystemPrompt(`You are '${name}', role: ${role}, at ${WORKDIR}. Use send_message to communicate. Complete your task.`); + const messages: Message[] = [{ role: "user", content: prompt }]; + + for (let attempt = 0; attempt < 50; attempt += 1) { + const inbox = BUS.readInbox(name); + for (const message of inbox) messages.push({ role: "user", content: JSON.stringify(message) }); + + const response = await client.messages.create({ + model: MODEL, + system: sysPrompt, + messages: messages as Anthropic.Messages.MessageParam[], + tools: this.teammateTools() as Anthropic.Messages.Tool[], + max_tokens: 8000, + }).catch(() => null); + if (!response) break; + + messages.push({ role: "assistant", content: response.content as Array }); + if (response.stop_reason !== "tool_use") break; + + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + const output = this.exec(name, block.name, block.input as Record); + console.log(` [${name}] ${block.name}: ${output.slice(0, 120)}`); + results.push({ type: "tool_result", tool_use_id: block.id, content: output }); + } + messages.push({ role: "user", content: results }); + } + + const member = this.findMember(name); + if (member && member.status !== "shutdown") { + member.status = "idle"; + this.saveConfig(); + } + } + + private teammateTools() { + return [ + { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } }, + { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } }, + { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } }, + { name: "edit_file", description: "Replace exact text in file.", input_schema: { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] } }, + { name: "send_message", description: "Send message to a teammate.", input_schema: { type: "object", properties: { to: { type: "string" }, content: { type: "string" }, msg_type: { type: "string", enum: VALID_MSG_TYPES } }, required: ["to", "content"] } }, + { name: "read_inbox", description: "Read and drain your inbox.", input_schema: { type: "object", properties: {} } }, + ]; + } + + private exec(sender: string, toolName: string, input: Record) { + if (toolName === "bash") return runBash(String(input.command ?? "")); + if (toolName === "read_file") return runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined); + if (toolName === "write_file") return runWrite(String(input.path ?? ""), String(input.content ?? "")); + if (toolName === "edit_file") return runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")); + if (toolName === "send_message") return BUS.send(sender, String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message"); + if (toolName === "read_inbox") return JSON.stringify(BUS.readInbox(sender), null, 2); + return `Unknown tool: ${toolName}`; + } + + listAll() { + if (!this.config.members.length) return "No teammates."; + return [`Team: ${this.config.team_name}`, ...this.config.members.map((m) => ` ${m.name} (${m.role}): ${m.status}`)].join("\n"); + } + + memberNames() { + return this.config.members.map((m) => m.name); + } +} + +const TEAM = new TeammateManager(TEAM_DIR); + +const TOOL_HANDLERS: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), + spawn_teammate: (input) => TEAM.spawn(String(input.name ?? ""), String(input.role ?? ""), String(input.prompt ?? "")), + list_teammates: () => TEAM.listAll(), + send_message: (input) => BUS.send("lead", String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message"), + read_inbox: () => JSON.stringify(BUS.readInbox("lead"), null, 2), + broadcast: (input) => BUS.broadcast("lead", String(input.content ?? ""), TEAM.memberNames()), +}; + +const TOOLS = [ + { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } }, + { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } }, + { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } }, + { name: "edit_file", description: "Replace exact text in file.", input_schema: { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] } }, + { name: "spawn_teammate", description: "Spawn a persistent teammate that runs in its own loop.", input_schema: { type: "object", properties: { name: { type: "string" }, role: { type: "string" }, prompt: { type: "string" } }, required: ["name", "role", "prompt"] } }, + { name: "list_teammates", description: "List all teammates with name, role, status.", input_schema: { type: "object", properties: {} } }, + { name: "send_message", description: "Send a message to a teammate inbox.", input_schema: { type: "object", properties: { to: { type: "string" }, content: { type: "string" }, msg_type: { type: "string", enum: VALID_MSG_TYPES } }, required: ["to", "content"] } }, + { name: "read_inbox", description: "Read and drain the lead inbox.", input_schema: { type: "object", properties: {} } }, + { name: "broadcast", description: "Send a message to all teammates.", input_schema: { type: "object", properties: { content: { type: "string" } }, required: ["content"] } }, +]; + +function assistantText(content: Array) { + return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n"); +} + +export async function agentLoop(messages: Message[]) { + while (true) { + const inbox = BUS.readInbox("lead"); + if (inbox.length) { + messages.push({ role: "user", content: `${JSON.stringify(inbox, null, 2)}` }); + messages.push({ role: "assistant", content: "Noted inbox messages." }); + } + + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + messages.push({ role: "assistant", content: response.content as Array }); + if (response.stop_reason !== "tool_use") return; + + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + const handler = TOOL_HANDLERS[block.name as ToolName]; + const output = handler ? handler(block.input as Record) : `Unknown tool: ${block.name}`; + console.log(`> ${block.name}: ${output.slice(0, 200)}`); + results.push({ type: "tool_result", tool_use_id: block.id, content: output }); + } + messages.push({ role: "user", content: results }); + } +} + +async function main() { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const history: Message[] = []; + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms09 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) break; + if (query.trim() === "/team") { console.log(TEAM.listAll()); continue; } + if (query.trim() === "/inbox") { console.log(JSON.stringify(BUS.readInbox("lead"), null, 2)); continue; } + history.push({ role: "user", content: query }); + await agentLoop(history); + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last as Array); + if (text) console.log(text); + } + console.log(); + } + rl.close(); +} + +void main(); diff --git a/agents-ts/s10_team_protocols.ts b/agents-ts/s10_team_protocols.ts new file mode 100644 index 000000000..c260f20d9 --- /dev/null +++ b/agents-ts/s10_team_protocols.ts @@ -0,0 +1,351 @@ +#!/usr/bin/env node +/** + * s10_team_protocols.ts - Team Protocols + * + * request_id based shutdown and plan approval protocols. + */ + +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import process from "node:process"; +import { randomUUID } from "node:crypto"; +import { createInterface } from "node:readline/promises"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type MessageType = "message" | "broadcast" | "shutdown_request" | "shutdown_response" | "plan_approval_response"; +type ToolName = + | "bash" | "read_file" | "write_file" | "edit_file" + | "spawn_teammate" | "list_teammates" | "send_message" | "read_inbox" | "broadcast" + | "shutdown_request" | "shutdown_response" | "plan_approval"; +type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record }; +type TextBlock = { type: "text"; text: string }; +type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string }; +type Message = { role: "user" | "assistant"; content: string | Array }; +type TeamMember = { name: string; role: string; status: "working" | "idle" | "shutdown" }; +type TeamConfig = { team_name: string; members: TeamMember[] }; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); +const TEAM_DIR = resolve(WORKDIR, ".team"); +const INBOX_DIR = resolve(TEAM_DIR, "inbox"); +const VALID_MSG_TYPES: MessageType[] = ["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]; +const shutdownRequests: Record = {}; +const planRequests: Record = {}; +const client = createAnthropicClient(); + +const SYSTEM = buildSystemPrompt(`You are a team lead at ${WORKDIR}. Manage teammates with shutdown and plan approval protocols.`); + +function safePath(relativePath: string) { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) throw new Error(`Path escapes workspace: ${relativePath}`); + return filePath; +} + +function runBash(command: string): string { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked"; + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command]; + const result = spawnSync(shell, args, { cwd: WORKDIR, encoding: "utf8", timeout: 120_000 }); + if (result.error?.name === "TimeoutError") return "Error: Timeout (120s)"; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +function runRead(path: string, limit?: number): string { + try { + let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); + if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); + return lines.join("\n").slice(0, 50_000); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runWrite(path: string, content: string): string { + try { + const filePath = safePath(path); + mkdirSync(resolve(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content, "utf8"); + return `Wrote ${content.length} bytes`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runEdit(path: string, oldText: string, newText: string): string { + try { + const filePath = safePath(path); + const content = readFileSync(filePath, "utf8"); + if (!content.includes(oldText)) return `Error: Text not found in ${path}`; + writeFileSync(filePath, content.replace(oldText, newText), "utf8"); + return `Edited ${path}`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +class MessageBus { + constructor(private inboxDir: string) { + mkdirSync(inboxDir, { recursive: true }); + } + + send(sender: string, to: string, content: string, msgType: MessageType = "message", extra?: Record) { + if (!VALID_MSG_TYPES.includes(msgType)) return `Error: Invalid type '${msgType}'.`; + const payload = { type: msgType, from: sender, content, timestamp: Date.now() / 1000, ...(extra ?? {}) }; + appendFileSync(resolve(this.inboxDir, `${to}.jsonl`), `${JSON.stringify(payload)}\n`, "utf8"); + return `Sent ${msgType} to ${to}`; + } + + readInbox(name: string) { + const inboxPath = resolve(this.inboxDir, `${name}.jsonl`); + if (!existsSync(inboxPath)) return []; + const lines = readFileSync(inboxPath, "utf8").split(/\r?\n/).filter(Boolean); + writeFileSync(inboxPath, "", "utf8"); + return lines.map((line) => JSON.parse(line)); + } + + broadcast(sender: string, content: string, teammates: string[]) { + let count = 0; + for (const name of teammates) { + if (name === sender) continue; + this.send(sender, name, content, "broadcast"); + count += 1; + } + return `Broadcast to ${count} teammates`; + } +} + +const BUS = new MessageBus(INBOX_DIR); + +class TeammateManager { + private configPath: string; + private config: TeamConfig; + + constructor(private teamDir: string) { + mkdirSync(teamDir, { recursive: true }); + this.configPath = resolve(teamDir, "config.json"); + this.config = this.loadConfig(); + } + + private loadConfig(): TeamConfig { + if (existsSync(this.configPath)) return JSON.parse(readFileSync(this.configPath, "utf8")) as TeamConfig; + return { team_name: "default", members: [] }; + } + + private saveConfig() { + writeFileSync(this.configPath, `${JSON.stringify(this.config, null, 2)}\n`, "utf8"); + } + + private findMember(name: string) { + return this.config.members.find((member) => member.name === name); + } + + spawn(name: string, role: string, prompt: string) { + let member = this.findMember(name); + if (member) { + if (!["idle", "shutdown"].includes(member.status)) return `Error: '${name}' is currently ${member.status}`; + member.status = "working"; + member.role = role; + } else { + member = { name, role, status: "working" }; + this.config.members.push(member); + } + this.saveConfig(); + void this.teammateLoop(name, role, prompt); + return `Spawned '${name}' (role: ${role})`; + } + + private async teammateLoop(name: string, role: string, prompt: string) { + const sysPrompt = buildSystemPrompt(`You are '${name}', role: ${role}, at ${WORKDIR}. Submit plans via plan_approval before major work. Respond to shutdown_request with shutdown_response.`); + const messages: Message[] = [{ role: "user", content: prompt }]; + let shouldExit = false; + for (let attempt = 0; attempt < 50; attempt += 1) { + for (const msg of BUS.readInbox(name)) messages.push({ role: "user", content: JSON.stringify(msg) }); + if (shouldExit) break; + const response = await client.messages.create({ + model: MODEL, + system: sysPrompt, + messages: messages as Anthropic.Messages.MessageParam[], + tools: this.tools() as Anthropic.Messages.Tool[], + max_tokens: 8000, + }).catch(() => null); + if (!response) break; + messages.push({ role: "assistant", content: response.content as Array }); + if (response.stop_reason !== "tool_use") break; + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + const output = this.exec(name, block.name, block.input as Record); + if (block.name === "shutdown_response" && block.input.approve) shouldExit = true; + console.log(` [${name}] ${block.name}: ${output.slice(0, 120)}`); + results.push({ type: "tool_result", tool_use_id: block.id, content: output }); + } + messages.push({ role: "user", content: results }); + } + const member = this.findMember(name); + if (member) { + member.status = shouldExit ? "shutdown" : "idle"; + this.saveConfig(); + } + } + + private tools() { + return [ + { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } }, + { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } }, + { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } }, + { name: "edit_file", description: "Replace exact text in file.", input_schema: { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] } }, + { name: "send_message", description: "Send message to a teammate.", input_schema: { type: "object", properties: { to: { type: "string" }, content: { type: "string" }, msg_type: { type: "string", enum: VALID_MSG_TYPES } }, required: ["to", "content"] } }, + { name: "read_inbox", description: "Read and drain your inbox.", input_schema: { type: "object", properties: {} } }, + { name: "shutdown_response", description: "Respond to a shutdown request.", input_schema: { type: "object", properties: { request_id: { type: "string" }, approve: { type: "boolean" }, reason: { type: "string" } }, required: ["request_id", "approve"] } }, + { name: "plan_approval", description: "Submit a plan for lead approval.", input_schema: { type: "object", properties: { plan: { type: "string" } }, required: ["plan"] } }, + ]; + } + + private exec(sender: string, toolName: string, input: Record) { + if (toolName === "bash") return runBash(String(input.command ?? "")); + if (toolName === "read_file") return runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined); + if (toolName === "write_file") return runWrite(String(input.path ?? ""), String(input.content ?? "")); + if (toolName === "edit_file") return runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")); + if (toolName === "send_message") return BUS.send(sender, String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message"); + if (toolName === "read_inbox") return JSON.stringify(BUS.readInbox(sender), null, 2); + if (toolName === "shutdown_response") { + const requestId = String(input.request_id ?? ""); + shutdownRequests[requestId] = { ...(shutdownRequests[requestId] ?? { target: sender }), status: input.approve ? "approved" : "rejected" }; + BUS.send(sender, "lead", String(input.reason ?? ""), "shutdown_response", { request_id: requestId, approve: Boolean(input.approve) }); + return `Shutdown ${input.approve ? "approved" : "rejected"}`; + } + if (toolName === "plan_approval") { + const requestId = randomUUID().slice(0, 8); + planRequests[requestId] = { from: sender, plan: String(input.plan ?? ""), status: "pending" }; + BUS.send(sender, "lead", String(input.plan ?? ""), "plan_approval_response", { request_id: requestId, plan: String(input.plan ?? "") }); + return `Plan submitted (request_id=${requestId}). Waiting for lead approval.`; + } + return `Unknown tool: ${toolName}`; + } + listAll() { + if (!this.config.members.length) return "No teammates."; + return [`Team: ${this.config.team_name}`, ...this.config.members.map((m) => ` ${m.name} (${m.role}): ${m.status}`)].join("\n"); + } + + memberNames() { + return this.config.members.map((m) => m.name); + } +} + +const TEAM = new TeammateManager(TEAM_DIR); + +function handleShutdownRequest(teammate: string) { + const requestId = randomUUID().slice(0, 8); + shutdownRequests[requestId] = { target: teammate, status: "pending" }; + BUS.send("lead", teammate, "Please shut down gracefully.", "shutdown_request", { request_id: requestId }); + return `Shutdown request ${requestId} sent to '${teammate}' (status: pending)`; +} + +function handlePlanReview(requestId: string, approve: boolean, feedback = "") { + const request = planRequests[requestId]; + if (!request) return `Error: Unknown plan request_id '${requestId}'`; + request.status = approve ? "approved" : "rejected"; + BUS.send("lead", request.from, feedback, "plan_approval_response", { request_id: requestId, approve, feedback }); + return `Plan ${request.status} for '${request.from}'`; +} + +const TOOL_HANDLERS: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), + spawn_teammate: (input) => TEAM.spawn(String(input.name ?? ""), String(input.role ?? ""), String(input.prompt ?? "")), + list_teammates: () => TEAM.listAll(), + send_message: (input) => BUS.send("lead", String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message"), + read_inbox: () => JSON.stringify(BUS.readInbox("lead"), null, 2), + broadcast: (input) => BUS.broadcast("lead", String(input.content ?? ""), TEAM.memberNames()), + shutdown_request: (input) => handleShutdownRequest(String(input.teammate ?? "")), + shutdown_response: (input) => JSON.stringify(shutdownRequests[String(input.request_id ?? "")] ?? { error: "not found" }), + plan_approval: (input) => handlePlanReview(String(input.request_id ?? ""), Boolean(input.approve), String(input.feedback ?? "")), +}; + +const TOOLS = [ + { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } }, + { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } }, + { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } }, + { name: "edit_file", description: "Replace exact text in file.", input_schema: { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] } }, + { name: "spawn_teammate", description: "Spawn a persistent teammate.", input_schema: { type: "object", properties: { name: { type: "string" }, role: { type: "string" }, prompt: { type: "string" } }, required: ["name", "role", "prompt"] } }, + { name: "list_teammates", description: "List all teammates.", input_schema: { type: "object", properties: {} } }, + { name: "send_message", description: "Send a message to a teammate.", input_schema: { type: "object", properties: { to: { type: "string" }, content: { type: "string" }, msg_type: { type: "string", enum: VALID_MSG_TYPES } }, required: ["to", "content"] } }, + { name: "read_inbox", description: "Read and drain the lead inbox.", input_schema: { type: "object", properties: {} } }, + { name: "broadcast", description: "Send a message to all teammates.", input_schema: { type: "object", properties: { content: { type: "string" } }, required: ["content"] } }, + { name: "shutdown_request", description: "Request a teammate to shut down gracefully.", input_schema: { type: "object", properties: { teammate: { type: "string" } }, required: ["teammate"] } }, + { name: "shutdown_response", description: "Check shutdown request status by request_id.", input_schema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"] } }, + { name: "plan_approval", description: "Approve or reject a teammate plan.", input_schema: { type: "object", properties: { request_id: { type: "string" }, approve: { type: "boolean" }, feedback: { type: "string" } }, required: ["request_id", "approve"] } }, +]; + +function assistantText(content: Array) { + return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n"); +} + +export async function agentLoop(messages: Message[]) { + while (true) { + const inbox = BUS.readInbox("lead"); + if (inbox.length) { + messages.push({ role: "user", content: `${JSON.stringify(inbox, null, 2)}` }); + messages.push({ role: "assistant", content: "Noted inbox messages." }); + } + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + messages.push({ role: "assistant", content: response.content as Array }); + if (response.stop_reason !== "tool_use") return; + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + const handler = TOOL_HANDLERS[block.name as ToolName]; + const output = handler ? handler(block.input as Record) : `Unknown tool: ${block.name}`; + console.log(`> ${block.name}: ${output.slice(0, 200)}`); + results.push({ type: "tool_result", tool_use_id: block.id, content: output }); + } + messages.push({ role: "user", content: results }); + } +} + +async function main() { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const history: Message[] = []; + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms10 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) break; + if (query.trim() === "/team") { console.log(TEAM.listAll()); continue; } + if (query.trim() === "/inbox") { console.log(JSON.stringify(BUS.readInbox("lead"), null, 2)); continue; } + history.push({ role: "user", content: query }); + await agentLoop(history); + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last as Array); + if (text) console.log(text); + } + console.log(); + } + rl.close(); +} + +void main(); diff --git a/agents-ts/s11_autonomous_agents.ts b/agents-ts/s11_autonomous_agents.ts new file mode 100644 index 000000000..f765c595c --- /dev/null +++ b/agents-ts/s11_autonomous_agents.ts @@ -0,0 +1,454 @@ +#!/usr/bin/env node +/** + * s11_autonomous_agents.ts - Autonomous Agents + * + * Idle polling + auto-claim task board + identity re-injection. + */ + +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import process from "node:process"; +import { randomUUID } from "node:crypto"; +import { createInterface } from "node:readline/promises"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type MessageType = "message" | "broadcast" | "shutdown_request" | "shutdown_response" | "plan_approval_response"; +type ToolName = + | "bash" | "read_file" | "write_file" | "edit_file" + | "spawn_teammate" | "list_teammates" | "send_message" | "read_inbox" | "broadcast" + | "shutdown_request" | "shutdown_response" | "plan_approval" | "idle" | "claim_task"; +type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record }; +type TextBlock = { type: "text"; text: string }; +type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string }; +type Message = { role: "user" | "assistant"; content: string | Array }; +type TeamMember = { name: string; role: string; status: "working" | "idle" | "shutdown" }; +type TeamConfig = { team_name: string; members: TeamMember[] }; +type TaskRecord = { id: number; subject: string; description?: string; status: string; owner?: string; blockedBy?: number[] }; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); +const TEAM_DIR = resolve(WORKDIR, ".team"); +const INBOX_DIR = resolve(TEAM_DIR, "inbox"); +const TASKS_DIR = resolve(WORKDIR, ".tasks"); +const POLL_INTERVAL = 5_000; +const IDLE_TIMEOUT = 60_000; +const VALID_MSG_TYPES: MessageType[] = ["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"]; +const shutdownRequests: Record = {}; +const planRequests: Record = {}; +const client = createAnthropicClient(); + +const SYSTEM = buildSystemPrompt(`You are a team lead at ${WORKDIR}. Teammates are autonomous -- they find work themselves.`); + +function sleep(ms: number) { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + +function safePath(relativePath: string) { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) throw new Error(`Path escapes workspace: ${relativePath}`); + return filePath; +} + +function runBash(command: string): string { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked"; + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command]; + const result = spawnSync(shell, args, { cwd: WORKDIR, encoding: "utf8", timeout: 120_000 }); + if (result.error?.name === "TimeoutError") return "Error: Timeout (120s)"; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +function runRead(path: string, limit?: number): string { + try { + let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); + if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); + return lines.join("\n").slice(0, 50_000); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runWrite(path: string, content: string): string { + try { + const filePath = safePath(path); + mkdirSync(resolve(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content, "utf8"); + return `Wrote ${content.length} bytes`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function runEdit(path: string, oldText: string, newText: string): string { + try { + const filePath = safePath(path); + const content = readFileSync(filePath, "utf8"); + if (!content.includes(oldText)) return `Error: Text not found in ${path}`; + writeFileSync(filePath, content.replace(oldText, newText), "utf8"); + return `Edited ${path}`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +function scanUnclaimedTasks() { + mkdirSync(TASKS_DIR, { recursive: true }); + const tasks: TaskRecord[] = []; + for (const entry of readdirSync(TASKS_DIR, { withFileTypes: true })) { + if (!entry.isFile() || !/^task_\d+\.json$/.test(entry.name)) continue; + const task = JSON.parse(readFileSync(resolve(TASKS_DIR, entry.name), "utf8")) as TaskRecord; + if (task.status === "pending" && !task.owner && !(task.blockedBy?.length)) tasks.push(task); + } + return tasks.sort((a, b) => a.id - b.id); +} + +function claimTask(taskId: number, owner: string) { + const path = resolve(TASKS_DIR, `task_${taskId}.json`); + if (!existsSync(path)) return `Error: Task ${taskId} not found`; + const task = JSON.parse(readFileSync(path, "utf8")) as TaskRecord; + task.owner = owner; + task.status = "in_progress"; + writeFileSync(path, `${JSON.stringify(task, null, 2)}\n`, "utf8"); + return `Claimed task #${taskId} for ${owner}`; +} + +function makeIdentityBlock(name: string, role: string, teamName: string): Message { + return { role: "user", content: `You are '${name}', role: ${role}, team: ${teamName}. Continue your work.` }; +} + +class MessageBus { + constructor(private inboxDir: string) { + mkdirSync(inboxDir, { recursive: true }); + } + + send(sender: string, to: string, content: string, msgType: MessageType = "message", extra?: Record) { + if (!VALID_MSG_TYPES.includes(msgType)) return `Error: Invalid type '${msgType}'.`; + const payload = { type: msgType, from: sender, content, timestamp: Date.now() / 1000, ...(extra ?? {}) }; + appendFileSync(resolve(this.inboxDir, `${to}.jsonl`), `${JSON.stringify(payload)}\n`, "utf8"); + return `Sent ${msgType} to ${to}`; + } + + readInbox(name: string) { + const inboxPath = resolve(this.inboxDir, `${name}.jsonl`); + if (!existsSync(inboxPath)) return []; + const lines = readFileSync(inboxPath, "utf8").split(/\r?\n/).filter(Boolean); + writeFileSync(inboxPath, "", "utf8"); + return lines.map((line) => JSON.parse(line)); + } + + broadcast(sender: string, content: string, teammates: string[]) { + let count = 0; + for (const name of teammates) { + if (name === sender) continue; + this.send(sender, name, content, "broadcast"); + count += 1; + } + return `Broadcast to ${count} teammates`; + } +} + +const BUS = new MessageBus(INBOX_DIR); + +class TeammateManager { + private configPath: string; + private config: TeamConfig; + + constructor(private teamDir: string) { + mkdirSync(teamDir, { recursive: true }); + this.configPath = resolve(teamDir, "config.json"); + this.config = this.loadConfig(); + } + + private loadConfig(): TeamConfig { + if (existsSync(this.configPath)) return JSON.parse(readFileSync(this.configPath, "utf8")) as TeamConfig; + return { team_name: "default", members: [] }; + } + + private saveConfig() { + writeFileSync(this.configPath, `${JSON.stringify(this.config, null, 2)}\n`, "utf8"); + } + + private findMember(name: string) { + return this.config.members.find((member) => member.name === name); + } + + private setStatus(name: string, status: TeamMember["status"]) { + const member = this.findMember(name); + if (member) { + member.status = status; + this.saveConfig(); + } + } + + spawn(name: string, role: string, prompt: string) { + let member = this.findMember(name); + if (member) { + if (!["idle", "shutdown"].includes(member.status)) return `Error: '${name}' is currently ${member.status}`; + member.status = "working"; + member.role = role; + } else { + member = { name, role, status: "working" }; + this.config.members.push(member); + } + this.saveConfig(); + void this.loop(name, role, prompt); + return `Spawned '${name}' (role: ${role})`; + } + + private async loop(name: string, role: string, prompt: string) { + const teamName = this.config.team_name; + const sysPrompt = buildSystemPrompt(`You are '${name}', role: ${role}, team: ${teamName}, at ${WORKDIR}. Use idle when you have no more work. You will auto-claim new tasks.`); + const messages: Message[] = [{ role: "user", content: prompt }]; + while (true) { + let idleRequested = false; + for (let attempt = 0; attempt < 50; attempt += 1) { + for (const msg of BUS.readInbox(name)) { + if (msg.type === "shutdown_request") { + this.setStatus(name, "shutdown"); + return; + } + messages.push({ role: "user", content: JSON.stringify(msg) }); + } + const response = await client.messages.create({ + model: MODEL, + system: sysPrompt, + messages: messages as Anthropic.Messages.MessageParam[], + tools: this.tools() as Anthropic.Messages.Tool[], + max_tokens: 8000, + }).catch(() => null); + if (!response) { + this.setStatus(name, "idle"); + return; + } + messages.push({ role: "assistant", content: response.content as Array }); + if (response.stop_reason !== "tool_use") break; + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + let output = ""; + if (block.name === "idle") { + idleRequested = true; + output = "Entering idle phase. Will poll for new tasks."; + } else { + output = this.exec(name, block.name, block.input as Record); + } + console.log(` [${name}] ${block.name}: ${output.slice(0, 120)}`); + results.push({ type: "tool_result", tool_use_id: block.id, content: output }); + } + messages.push({ role: "user", content: results }); + if (idleRequested) break; + } + + this.setStatus(name, "idle"); + let resume = false; + const start = Date.now(); + while (Date.now() - start < IDLE_TIMEOUT) { + await sleep(POLL_INTERVAL); + const inbox = BUS.readInbox(name); + if (inbox.length) { + for (const msg of inbox) { + if (msg.type === "shutdown_request") { + this.setStatus(name, "shutdown"); + return; + } + messages.push({ role: "user", content: JSON.stringify(msg) }); + } + resume = true; + break; + } + const unclaimed = scanUnclaimedTasks(); + if (unclaimed.length) { + const task = unclaimed[0]; + claimTask(task.id, name); + if (messages.length <= 3) { + messages.unshift({ role: "assistant", content: `I am ${name}. Continuing.` }); + messages.unshift(makeIdentityBlock(name, role, teamName)); + } + messages.push({ role: "user", content: `Task #${task.id}: ${task.subject}\n${task.description ?? ""}` }); + messages.push({ role: "assistant", content: `Claimed task #${task.id}. Working on it.` }); + resume = true; + break; + } + } + + if (!resume) { + this.setStatus(name, "shutdown"); + return; + } + this.setStatus(name, "working"); + } + } + + private tools() { + return [ + { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } }, + { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } }, + { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } }, + { name: "edit_file", description: "Replace exact text in file.", input_schema: { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] } }, + { name: "send_message", description: "Send message to a teammate.", input_schema: { type: "object", properties: { to: { type: "string" }, content: { type: "string" }, msg_type: { type: "string", enum: VALID_MSG_TYPES } }, required: ["to", "content"] } }, + { name: "read_inbox", description: "Read and drain your inbox.", input_schema: { type: "object", properties: {} } }, + { name: "shutdown_response", description: "Respond to a shutdown request.", input_schema: { type: "object", properties: { request_id: { type: "string" }, approve: { type: "boolean" }, reason: { type: "string" } }, required: ["request_id", "approve"] } }, + { name: "plan_approval", description: "Submit a plan for lead approval.", input_schema: { type: "object", properties: { plan: { type: "string" } }, required: ["plan"] } }, + { name: "idle", description: "Signal that you have no more work.", input_schema: { type: "object", properties: {} } }, + { name: "claim_task", description: "Claim a task by ID.", input_schema: { type: "object", properties: { task_id: { type: "integer" } }, required: ["task_id"] } }, + ]; + } + + private exec(sender: string, toolName: string, input: Record) { + if (toolName === "bash") return runBash(String(input.command ?? "")); + if (toolName === "read_file") return runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined); + if (toolName === "write_file") return runWrite(String(input.path ?? ""), String(input.content ?? "")); + if (toolName === "edit_file") return runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")); + if (toolName === "send_message") return BUS.send(sender, String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message"); + if (toolName === "read_inbox") return JSON.stringify(BUS.readInbox(sender), null, 2); + if (toolName === "shutdown_response") { + const requestId = String(input.request_id ?? ""); + shutdownRequests[requestId] = { ...(shutdownRequests[requestId] ?? { target: sender }), status: input.approve ? "approved" : "rejected" }; + BUS.send(sender, "lead", String(input.reason ?? ""), "shutdown_response", { request_id: requestId, approve: Boolean(input.approve) }); + return `Shutdown ${input.approve ? "approved" : "rejected"}`; + } + if (toolName === "plan_approval") { + const requestId = randomUUID().slice(0, 8); + planRequests[requestId] = { from: sender, plan: String(input.plan ?? ""), status: "pending" }; + BUS.send(sender, "lead", String(input.plan ?? ""), "plan_approval_response", { request_id: requestId, plan: String(input.plan ?? "") }); + return `Plan submitted (request_id=${requestId}). Waiting for lead approval.`; + } + if (toolName === "claim_task") return claimTask(Number(input.task_id ?? 0), sender); + return `Unknown tool: ${toolName}`; + } + listAll() { + if (!this.config.members.length) return "No teammates."; + return [`Team: ${this.config.team_name}`, ...this.config.members.map((m) => ` ${m.name} (${m.role}): ${m.status}`)].join("\n"); + } + + memberNames() { + return this.config.members.map((m) => m.name); + } +} + +const TEAM = new TeammateManager(TEAM_DIR); + +function handleShutdownRequest(teammate: string) { + const requestId = randomUUID().slice(0, 8); + shutdownRequests[requestId] = { target: teammate, status: "pending" }; + BUS.send("lead", teammate, "Please shut down gracefully.", "shutdown_request", { request_id: requestId }); + return `Shutdown request ${requestId} sent to '${teammate}' (status: pending)`; +} + +function handlePlanReview(requestId: string, approve: boolean, feedback = "") { + const request = planRequests[requestId]; + if (!request) return `Error: Unknown plan request_id '${requestId}'`; + request.status = approve ? "approved" : "rejected"; + BUS.send("lead", request.from, feedback, "plan_approval_response", { request_id: requestId, approve, feedback }); + return `Plan ${request.status} for '${request.from}'`; +} + +const TOOL_HANDLERS: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), + spawn_teammate: (input) => TEAM.spawn(String(input.name ?? ""), String(input.role ?? ""), String(input.prompt ?? "")), + list_teammates: () => TEAM.listAll(), + send_message: (input) => BUS.send("lead", String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message"), + read_inbox: () => JSON.stringify(BUS.readInbox("lead"), null, 2), + broadcast: (input) => BUS.broadcast("lead", String(input.content ?? ""), TEAM.memberNames()), + shutdown_request: (input) => handleShutdownRequest(String(input.teammate ?? "")), + shutdown_response: (input) => JSON.stringify(shutdownRequests[String(input.request_id ?? "")] ?? { error: "not found" }), + plan_approval: (input) => handlePlanReview(String(input.request_id ?? ""), Boolean(input.approve), String(input.feedback ?? "")), + idle: () => "Lead does not idle.", + claim_task: (input) => claimTask(Number(input.task_id ?? 0), "lead"), +}; + +const TOOLS = [ + { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } }, + { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } }, + { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } }, + { name: "edit_file", description: "Replace exact text in file.", input_schema: { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] } }, + { name: "spawn_teammate", description: "Spawn an autonomous teammate.", input_schema: { type: "object", properties: { name: { type: "string" }, role: { type: "string" }, prompt: { type: "string" } }, required: ["name", "role", "prompt"] } }, + { name: "list_teammates", description: "List all teammates.", input_schema: { type: "object", properties: {} } }, + { name: "send_message", description: "Send a message to a teammate.", input_schema: { type: "object", properties: { to: { type: "string" }, content: { type: "string" }, msg_type: { type: "string", enum: VALID_MSG_TYPES } }, required: ["to", "content"] } }, + { name: "read_inbox", description: "Read and drain the lead inbox.", input_schema: { type: "object", properties: {} } }, + { name: "broadcast", description: "Send a message to all teammates.", input_schema: { type: "object", properties: { content: { type: "string" } }, required: ["content"] } }, + { name: "shutdown_request", description: "Request a teammate to shut down.", input_schema: { type: "object", properties: { teammate: { type: "string" } }, required: ["teammate"] } }, + { name: "shutdown_response", description: "Check shutdown request status.", input_schema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"] } }, + { name: "plan_approval", description: "Approve or reject a teammate plan.", input_schema: { type: "object", properties: { request_id: { type: "string" }, approve: { type: "boolean" }, feedback: { type: "string" } }, required: ["request_id", "approve"] } }, + { name: "idle", description: "Enter idle state.", input_schema: { type: "object", properties: {} } }, + { name: "claim_task", description: "Claim a task from the board by ID.", input_schema: { type: "object", properties: { task_id: { type: "integer" } }, required: ["task_id"] } }, +]; + +function assistantText(content: Array) { + return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n"); +} + +export async function agentLoop(messages: Message[]) { + while (true) { + const inbox = BUS.readInbox("lead"); + if (inbox.length) { + messages.push({ role: "user", content: `${JSON.stringify(inbox, null, 2)}` }); + messages.push({ role: "assistant", content: "Noted inbox messages." }); + } + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + messages.push({ role: "assistant", content: response.content as Array }); + if (response.stop_reason !== "tool_use") return; + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + const handler = TOOL_HANDLERS[block.name as ToolName]; + const output = handler ? handler(block.input as Record) : `Unknown tool: ${block.name}`; + console.log(`> ${block.name}: ${output.slice(0, 200)}`); + results.push({ type: "tool_result", tool_use_id: block.id, content: output }); + } + messages.push({ role: "user", content: results }); + } +} + +async function main() { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const history: Message[] = []; + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms11 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) break; + if (query.trim() === "/team") { console.log(TEAM.listAll()); continue; } + if (query.trim() === "/inbox") { console.log(JSON.stringify(BUS.readInbox("lead"), null, 2)); continue; } + if (query.trim() === "/tasks") { + mkdirSync(TASKS_DIR, { recursive: true }); + for (const task of scanUnclaimedTasks()) console.log(` [ ] #${task.id}: ${task.subject}`); + continue; + } + history.push({ role: "user", content: query }); + await agentLoop(history); + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last as Array); + if (text) console.log(text); + } + console.log(); + } + rl.close(); +} + +void main(); diff --git a/agents-ts/s12_worktree_task_isolation.ts b/agents-ts/s12_worktree_task_isolation.ts new file mode 100644 index 000000000..726df28f9 --- /dev/null +++ b/agents-ts/s12_worktree_task_isolation.ts @@ -0,0 +1,439 @@ +#!/usr/bin/env node +/** + * s12_worktree_task_isolation.ts - Worktree + Task Isolation + * + * Task board as control plane, git worktrees as execution plane. + */ + +import { spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; +import { createInterface } from "node:readline/promises"; +import type Anthropic from "@anthropic-ai/sdk"; +import "dotenv/config"; +import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; + +type TaskStatus = "pending" | "in_progress" | "completed"; +type ToolName = + | "bash" | "read_file" | "write_file" | "edit_file" + | "task_create" | "task_list" | "task_get" | "task_update" | "task_bind_worktree" + | "worktree_create" | "worktree_list" | "worktree_status" | "worktree_run" | "worktree_keep" | "worktree_remove" | "worktree_events"; +type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record }; +type TextBlock = { type: "text"; text: string }; +type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string }; +type Message = { role: "user" | "assistant"; content: string | Array }; +type TaskRecord = { + id: number; + subject: string; + description: string; + status: TaskStatus; + owner: string; + worktree: string; + blockedBy: number[]; + created_at: number; + updated_at: number; +}; +type WorktreeRecord = { + name: string; + path: string; + branch: string; + task_id?: number; + status: string; + created_at: number; + removed_at?: number; + kept_at?: number; +}; + +const WORKDIR = process.cwd(); +const MODEL = resolveModel(); + +function detectRepoRoot(cwd: string) { + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", timeout: 10_000 }); + if (result.status !== 0) return cwd; + return result.stdout.trim() || cwd; +} + +const REPO_ROOT = detectRepoRoot(WORKDIR); +const TASKS_DIR = resolve(REPO_ROOT, ".tasks"); +const WORKTREES_DIR = resolve(REPO_ROOT, ".worktrees"); +const EVENTS_PATH = resolve(WORKTREES_DIR, "events.jsonl"); +const INDEX_PATH = resolve(WORKTREES_DIR, "index.json"); + +const client = createAnthropicClient(); + +const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use task + worktree tools for multi-task work.`); + +function safePath(relativePath: string) { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) throw new Error(`Path escapes workspace: ${relativePath}`); + return filePath; +} + +function runCommand(command: string, cwd: string, timeout = 120_000) { + const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; + if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked"; + const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; + const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command]; + const result = spawnSync(shell, args, { cwd, encoding: "utf8", timeout }); + if (result.error?.name === "TimeoutError") return `Error: Timeout (${Math.floor(timeout / 1000)}s)`; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return output.slice(0, 50_000) || "(no output)"; +} + +function runBash(command: string) { return runCommand(command, WORKDIR, 120_000); } +function runRead(path: string, limit?: number) { + try { + let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); + if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); + return lines.join("\n").slice(0, 50_000); + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} +function runWrite(path: string, content: string) { + try { + const filePath = safePath(path); + mkdirSync(resolve(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content, "utf8"); + return `Wrote ${content.length} bytes`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} +function runEdit(path: string, oldText: string, newText: string) { + try { + const filePath = safePath(path); + const content = readFileSync(filePath, "utf8"); + if (!content.includes(oldText)) return `Error: Text not found in ${path}`; + writeFileSync(filePath, content.replace(oldText, newText), "utf8"); + return `Edited ${path}`; + } catch (error) { + return `Error: ${error instanceof Error ? error.message : String(error)}`; + } +} + +class EventBus { + constructor(private eventLogPath: string) { + mkdirSync(resolve(eventLogPath, ".."), { recursive: true }); + if (!existsSync(eventLogPath)) writeFileSync(eventLogPath, "", "utf8"); + } + + emit(event: string, task: Record = {}, worktree: Record = {}, error?: string) { + const payload = { event, ts: Date.now() / 1000, task, worktree, ...(error ? { error } : {}) }; + appendFileSync(this.eventLogPath, `${JSON.stringify(payload)}\n`, "utf8"); + } + + listRecent(limit = 20) { + const count = Math.max(1, Math.min(limit, 200)); + const lines = readFileSync(this.eventLogPath, "utf8").split(/\r?\n/).filter(Boolean).slice(-count); + return JSON.stringify(lines.map((line) => JSON.parse(line)), null, 2); + } +} + +class TaskManager { + private nextId: number; + + constructor(private tasksDir: string) { + mkdirSync(tasksDir, { recursive: true }); + this.nextId = this.maxId() + 1; + } + + private maxId(): number { + return readdirSync(this.tasksDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /^task_\d+\.json$/.test(entry.name)) + .map((entry) => Number(entry.name.match(/\d+/)?.[0] ?? 0)) + .reduce((max, id) => Math.max(max, id), 0); + } + + private filePath(taskId: number) { + return resolve(this.tasksDir, `task_${taskId}.json`); + } + + exists(taskId: number) { + return existsSync(this.filePath(taskId)); + } + + private load(taskId: number): TaskRecord { + if (!this.exists(taskId)) throw new Error(`Task ${taskId} not found`); + return JSON.parse(readFileSync(this.filePath(taskId), "utf8")) as TaskRecord; + } + + private save(task: TaskRecord) { + writeFileSync(this.filePath(task.id), `${JSON.stringify(task, null, 2)}\n`, "utf8"); + } + + create(subject: string, description = "") { + const task: TaskRecord = { + id: this.nextId, + subject, + description, + status: "pending", + owner: "", + worktree: "", + blockedBy: [], + created_at: Date.now() / 1000, + updated_at: Date.now() / 1000, + }; + this.save(task); + this.nextId += 1; + return JSON.stringify(task, null, 2); + } + + get(taskId: number) { + return JSON.stringify(this.load(taskId), null, 2); + } + + update(taskId: number, status?: string, owner?: string) { + const task = this.load(taskId); + if (status) task.status = status as TaskStatus; + if (typeof owner === "string") task.owner = owner; + task.updated_at = Date.now() / 1000; + this.save(task); + return JSON.stringify(task, null, 2); + } + + bindWorktree(taskId: number, worktree: string, owner = "") { + const task = this.load(taskId); + task.worktree = worktree; + if (owner) task.owner = owner; + if (task.status === "pending") task.status = "in_progress"; + task.updated_at = Date.now() / 1000; + this.save(task); + return JSON.stringify(task, null, 2); + } + + unbindWorktree(taskId: number) { + const task = this.load(taskId); + task.worktree = ""; + task.updated_at = Date.now() / 1000; + this.save(task); + return JSON.stringify(task, null, 2); + } + listAll() { + const tasks = readdirSync(this.tasksDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /^task_\d+\.json$/.test(entry.name)) + .map((entry) => JSON.parse(readFileSync(resolve(this.tasksDir, entry.name), "utf8")) as TaskRecord) + .sort((a, b) => a.id - b.id); + if (!tasks.length) return "No tasks."; + return tasks + .map((task) => { + const marker = { pending: "[ ]", in_progress: "[>]", completed: "[x]" }[task.status] ?? "[?]"; + const owner = task.owner ? ` owner=${task.owner}` : ""; + const wt = task.worktree ? ` wt=${task.worktree}` : ""; + return `${marker} #${task.id}: ${task.subject}${owner}${wt}`; + }) + .join("\n"); + } +} + +class WorktreeManager { + constructor(private repoRoot: string, private tasks: TaskManager, private events: EventBus) { + mkdirSync(WORKTREES_DIR, { recursive: true }); + if (!existsSync(INDEX_PATH)) writeFileSync(INDEX_PATH, `${JSON.stringify({ worktrees: [] }, null, 2)}\n`, "utf8"); + } + + get gitAvailable() { + const result = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: this.repoRoot, encoding: "utf8", timeout: 10_000 }); + return result.status === 0; + } + + private loadIndex(): { worktrees: WorktreeRecord[] } { + return JSON.parse(readFileSync(INDEX_PATH, "utf8")) as { worktrees: WorktreeRecord[] }; + } + + private saveIndex(index: { worktrees: WorktreeRecord[] }) { + writeFileSync(INDEX_PATH, `${JSON.stringify(index, null, 2)}\n`, "utf8"); + } + + private find(name: string) { + return this.loadIndex().worktrees.find((worktree) => worktree.name === name); + } + + create(name: string, taskId?: number, baseRef = "HEAD") { + if (!this.gitAvailable) throw new Error("Not in a git repository. worktree tools require git."); + if (this.find(name)) throw new Error(`Worktree '${name}' already exists`); + if (taskId && !this.tasks.exists(taskId)) throw new Error(`Task ${taskId} not found`); + const path = resolve(WORKTREES_DIR, name); + const branch = `wt/${name}`; + this.events.emit("worktree.create.before", taskId ? { id: taskId } : {}, { name, base_ref: baseRef }); + const result = spawnSync("git", ["worktree", "add", "-b", branch, path, baseRef], { cwd: this.repoRoot, encoding: "utf8", timeout: 120_000 }); + if (result.status !== 0) { + const message = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() || "git worktree add failed"; + this.events.emit("worktree.create.failed", taskId ? { id: taskId } : {}, { name, base_ref: baseRef }, message); + throw new Error(message); + } + const entry: WorktreeRecord = { name, path, branch, task_id: taskId, status: "active", created_at: Date.now() / 1000 }; + const index = this.loadIndex(); + index.worktrees.push(entry); + this.saveIndex(index); + if (taskId) this.tasks.bindWorktree(taskId, name); + this.events.emit("worktree.create.after", taskId ? { id: taskId } : {}, { name, path, branch, status: "active" }); + return JSON.stringify(entry, null, 2); + } + + listAll() { + const worktrees = this.loadIndex().worktrees; + if (!worktrees.length) return "No worktrees in index."; + return worktrees.map((wt) => `[${wt.status}] ${wt.name} -> ${wt.path} (${wt.branch})${wt.task_id ? ` task=${wt.task_id}` : ""}`).join("\n"); + } + + status(name: string) { + const wt = this.find(name); + if (!wt) return `Error: Unknown worktree '${name}'`; + return runCommand("git status --short --branch", wt.path, 60_000); + } + + run(name: string, command: string) { + const wt = this.find(name); + if (!wt) return `Error: Unknown worktree '${name}'`; + return runCommand(command, wt.path, 300_000); + } + + keep(name: string) { + const index = this.loadIndex(); + const worktree = index.worktrees.find((item) => item.name === name); + if (!worktree) return `Error: Unknown worktree '${name}'`; + worktree.status = "kept"; + worktree.kept_at = Date.now() / 1000; + this.saveIndex(index); + this.events.emit("worktree.keep", worktree.task_id ? { id: worktree.task_id } : {}, { name, path: worktree.path, status: "kept" }); + return JSON.stringify(worktree, null, 2); + } + + remove(name: string, force = false, completeTask = false) { + const worktree = this.find(name); + if (!worktree) return `Error: Unknown worktree '${name}'`; + this.events.emit("worktree.remove.before", worktree.task_id ? { id: worktree.task_id } : {}, { name, path: worktree.path }); + const args = ["worktree", "remove", ...(force ? ["--force"] : []), worktree.path]; + const result = spawnSync("git", args, { cwd: this.repoRoot, encoding: "utf8", timeout: 120_000 }); + if (result.status !== 0) { + const message = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() || "git worktree remove failed"; + this.events.emit("worktree.remove.failed", worktree.task_id ? { id: worktree.task_id } : {}, { name, path: worktree.path }, message); + throw new Error(message); + } + if (completeTask && worktree.task_id) { + const before = JSON.parse(this.tasks.get(worktree.task_id)) as TaskRecord; + this.tasks.update(worktree.task_id, "completed"); + this.tasks.unbindWorktree(worktree.task_id); + this.events.emit("task.completed", { id: worktree.task_id, subject: before.subject, status: "completed" }, { name }); + } + const index = this.loadIndex(); + const record = index.worktrees.find((item) => item.name === name); + if (record) { + record.status = "removed"; + record.removed_at = Date.now() / 1000; + } + this.saveIndex(index); + this.events.emit("worktree.remove.after", worktree.task_id ? { id: worktree.task_id } : {}, { name, path: worktree.path, status: "removed" }); + return `Removed worktree '${name}'`; + } +} + +const EVENTS = new EventBus(EVENTS_PATH); +const TASKS = new TaskManager(TASKS_DIR); +const WORKTREES = new WorktreeManager(REPO_ROOT, TASKS, EVENTS); + +const TOOL_HANDLERS: Record) => string> = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), + task_create: (input) => TASKS.create(String(input.subject ?? ""), String(input.description ?? "")), + task_list: () => TASKS.listAll(), + task_get: (input) => TASKS.get(Number(input.task_id ?? 0)), + task_update: (input) => TASKS.update(Number(input.task_id ?? 0), typeof input.status === "string" ? input.status : undefined, typeof input.owner === "string" ? input.owner : undefined), + task_bind_worktree: (input) => TASKS.bindWorktree(Number(input.task_id ?? 0), String(input.worktree ?? ""), String(input.owner ?? "")), + worktree_create: (input) => WORKTREES.create(String(input.name ?? ""), typeof input.task_id === "number" ? input.task_id : undefined, String(input.base_ref ?? "HEAD")), + worktree_list: () => WORKTREES.listAll(), + worktree_status: (input) => WORKTREES.status(String(input.name ?? "")), + worktree_run: (input) => WORKTREES.run(String(input.name ?? ""), String(input.command ?? "")), + worktree_keep: (input) => WORKTREES.keep(String(input.name ?? "")), + worktree_remove: (input) => WORKTREES.remove(String(input.name ?? ""), Boolean(input.force), Boolean(input.complete_task)), + worktree_events: (input) => EVENTS.listRecent(Number(input.limit ?? 20)), +}; + +const TOOLS = [ + { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } }, + { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } }, + { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } }, + { name: "edit_file", description: "Replace exact text in file.", input_schema: { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] } }, + { name: "task_create", description: "Create a new task on the shared task board.", input_schema: { type: "object", properties: { subject: { type: "string" }, description: { type: "string" } }, required: ["subject"] } }, + { name: "task_list", description: "List all tasks.", input_schema: { type: "object", properties: {} } }, + { name: "task_get", description: "Get task details by ID.", input_schema: { type: "object", properties: { task_id: { type: "integer" } }, required: ["task_id"] } }, + { name: "task_update", description: "Update task status or owner.", input_schema: { type: "object", properties: { task_id: { type: "integer" }, status: { type: "string", enum: ["pending", "in_progress", "completed"] }, owner: { type: "string" } }, required: ["task_id"] } }, + { name: "task_bind_worktree", description: "Bind a task to a worktree name.", input_schema: { type: "object", properties: { task_id: { type: "integer" }, worktree: { type: "string" }, owner: { type: "string" } }, required: ["task_id", "worktree"] } }, + { name: "worktree_create", description: "Create a git worktree and optionally bind it to a task.", input_schema: { type: "object", properties: { name: { type: "string" }, task_id: { type: "integer" }, base_ref: { type: "string" } }, required: ["name"] } }, + { name: "worktree_list", description: "List worktrees tracked in index.", input_schema: { type: "object", properties: {} } }, + { name: "worktree_status", description: "Show git status for one worktree.", input_schema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } }, + { name: "worktree_run", description: shellToolDescription("a named worktree"), input_schema: { type: "object", properties: { name: { type: "string" }, command: { type: "string" } }, required: ["name", "command"] } }, + { name: "worktree_remove", description: "Remove a worktree and optionally mark its task completed.", input_schema: { type: "object", properties: { name: { type: "string" }, force: { type: "boolean" }, complete_task: { type: "boolean" } }, required: ["name"] } }, + { name: "worktree_keep", description: "Mark a worktree as kept without removing it.", input_schema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } }, + { name: "worktree_events", description: "List recent worktree/task lifecycle events.", input_schema: { type: "object", properties: { limit: { type: "integer" } } } }, +]; + +function assistantText(content: Array) { + return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n"); +} + +export async function agentLoop(messages: Message[]) { + while (true) { + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: messages as Anthropic.Messages.MessageParam[], + tools: TOOLS as Anthropic.Messages.Tool[], + max_tokens: 8000, + }); + messages.push({ role: "assistant", content: response.content as Array }); + if (response.stop_reason !== "tool_use") return; + const results: ToolResultBlock[] = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + const handler = TOOL_HANDLERS[block.name as ToolName]; + let output = ""; + try { + output = handler ? handler(block.input as Record) : `Unknown tool: ${block.name}`; + } catch (error) { + output = `Error: ${error instanceof Error ? error.message : String(error)}`; + } + console.log(`> ${block.name}: ${output.slice(0, 200)}`); + results.push({ type: "tool_result", tool_use_id: block.id, content: output }); + } + messages.push({ role: "user", content: results }); + } +} + +async function main() { + console.log(`Repo root for s12: ${REPO_ROOT}`); + if (!WORKTREES.gitAvailable) console.log("Note: Not in a git repo. worktree_* tools will return errors."); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const history: Message[] = []; + while (true) { + let query = ""; + try { + query = await rl.question("\x1b[36ms12 >> \x1b[0m"); + } catch (error) { + if ( + error instanceof Error && + (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") + ) { + break; + } + throw error; + } + if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) break; + history.push({ role: "user", content: query }); + await agentLoop(history); + const last = history[history.length - 1]?.content; + if (Array.isArray(last)) { + const text = assistantText(last as Array); + if (text) console.log(text); + } + console.log(); + } + rl.close(); +} + +void main(); diff --git a/agents-ts/shared.ts b/agents-ts/shared.ts new file mode 100644 index 000000000..c6fc1630e --- /dev/null +++ b/agents-ts/shared.ts @@ -0,0 +1,63 @@ +import Anthropic from "@anthropic-ai/sdk"; +import process from "node:process"; + +export const DEFAULT_BASE_URL: string | undefined = undefined; +export const DEFAULT_MODEL = "claude-sonnet-4-6"; +export const SHELL_NAME = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; +export const PLATFORM_LABEL = process.platform === "win32" ? "Windows" : "macOS/Linux"; + +export function platformGuidance(extra?: string): string { + const lines = [ + `Runtime platform: ${PLATFORM_LABEL}. Shell commands run through ${SHELL_NAME}.`, + "Prefer structured tools such as read_file, write_file, edit_file, task, and worktree tools over shell when possible.", + "Do not use shell redirection, heredocs, node -e, or temporary script files for file edits when structured file tools are available.", + "If you use shell, write commands for the current platform only.", + "On Windows, do not assume Unix commands like ls, cat, grep, sleep, pwd, or shell redirection tricks will work.", + "When possible, prefer Node.js and git commands that are portable across Windows and macOS.", + ]; + + if (extra) { + lines.push(extra); + } + + return lines.join("\n"); +} + +export function buildSystemPrompt(base: string, extra?: string): string { + return `${base}\n\n${platformGuidance(extra)}`; +} + +export function shellToolDescription(scope = "the current workspace"): string { + return `Run a shell command in ${scope} using ${SHELL_NAME}. Commands must match the current platform; avoid Unix-only commands on Windows and avoid cmd.exe-only syntax on macOS/Linux.`; +} + +export function resolveModel(): string { + return ( + process.env.MODEL_ID ?? + process.env.ANTHROPIC_MODEL ?? + DEFAULT_MODEL + ); +} + +export function resolveCredentials() { + const authToken = process.env.ANTHROPIC_AUTH_TOKEN ?? null; + const apiKey = process.env.ANTHROPIC_API_KEY ?? null; + + if (!authToken && !apiKey) { + throw new Error( + "Missing API credential. Set ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY." + ); + } + + return { authToken, apiKey }; +} + +export function createAnthropicClient(): Anthropic { + const { authToken, apiKey } = resolveCredentials(); + + return new Anthropic({ + baseURL: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_BASE_URL, + authToken, + apiKey, + }); +} diff --git a/agents-ts/tsconfig.json b/agents-ts/tsconfig.json new file mode 100644 index 000000000..cfa9c8e37 --- /dev/null +++ b/agents-ts/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noImplicitAny": false, + "types": ["node"], + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["./*.ts"] +} diff --git a/docs/en/s01-the-agent-loop.md b/docs/en/s01-the-agent-loop.md index 405646869..3a10c8c66 100644 --- a/docs/en/s01-the-agent-loop.md +++ b/docs/en/s01-the-agent-loop.md @@ -12,7 +12,7 @@ A language model can reason about code, but it can't *touch* the real world -- c ## Solution -``` +```text +--------+ +-------+ +---------+ | User | ---> | LLM | ---> | Tool | | prompt | | | | execute | @@ -29,12 +29,26 @@ One exit condition controls the entire flow. The loop runs until the model stops 1. User prompt becomes the first message. + + ```python messages.append({"role": "user", "content": query}) ``` + + + + +```ts +history.push({ role: "user", content: query }); +``` + + + 2. Send messages + tool definitions to the LLM. + + ```python response = client.messages.create( model=MODEL, system=SYSTEM, messages=messages, @@ -42,16 +56,53 @@ response = client.messages.create( ) ``` + + + + +```ts +const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: history, + tools: TOOLS, + max_tokens: 8000, +}); +``` + + + 3. Append the assistant response. Check `stop_reason` -- if the model didn't call a tool, we're done. + + ```python messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": return ``` + + + + +```ts +history.push({ + role: "assistant", + content: response.content, +}); + +if (response.stop_reason !== "tool_use") { + return; +} +``` + + + 4. Execute each tool call, collect results, append as a user message. Loop back to step 2. + + ```python results = [] for block in response.content: @@ -65,8 +116,28 @@ for block in response.content: messages.append({"role": "user", "content": results}) ``` + + + + +```ts +const results = response.content + .filter((block) => block.type === "tool_use") + .map((block) => ({ + type: "tool_result" as const, + tool_use_id: block.id, + content: runBash(block.input.command), + })); + +history.push({ role: "user", content: results }); +``` + + + Assembled into one function: + + ```python def agent_loop(query): messages = [{"role": "user", "content": query}] @@ -92,6 +163,42 @@ def agent_loop(query): messages.append({"role": "user", "content": results}) ``` + + + + +```ts +export async function agentLoop(history: Message[]) { + while (true) { + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: history, + tools: TOOLS, + max_tokens: 8000, + }); + + history.push({ role: "assistant", content: response.content }); + + if (response.stop_reason !== "tool_use") { + return; + } + + const results = response.content + .filter((block) => block.type === "tool_use") + .map((block) => ({ + type: "tool_result" as const, + tool_use_id: block.id, + content: runBash(block.input.command), + })); + + history.push({ role: "user", content: results }); + } +} +``` + + + That's the entire agent in under 30 lines. Everything else in this course layers on top -- without changing the loop. ## What Changed @@ -107,6 +214,11 @@ That's the entire agent in under 30 lines. Everything else in this course layers ```sh cd learn-claude-code +``` + + + +```sh python agents/s01_agent_loop.py ``` @@ -114,3 +226,20 @@ python agents/s01_agent_loop.py 2. `List all Python files in this directory` 3. `What is the current git branch?` 4. `Create a directory called test_output and write 3 files in it` + + + + + +```sh +cd agents-ts +npm install +npm run s01 +``` + +1. `Create a file called hello.ts that logs "Hello, World!"` +2. `List all TypeScript files in this directory` +3. `What is the current git branch?` +4. `Create a directory called test_output and write 3 files in it` + + diff --git a/docs/en/s02-tool-use.md b/docs/en/s02-tool-use.md index 279774b82..d44df6574 100644 --- a/docs/en/s02-tool-use.md +++ b/docs/en/s02-tool-use.md @@ -14,7 +14,7 @@ The key insight: adding tools does not require changing the loop. ## Solution -``` +```text +--------+ +-------+ +------------------+ | User | ---> | LLM | ---> | Tool Dispatch | | prompt | | | | { | @@ -33,6 +33,8 @@ One lookup replaces any if/elif chain. 1. Each tool gets a handler function. Path sandboxing prevents workspace escape. + + ```python def safe_path(p: str) -> Path: path = (WORKDIR / p).resolve() @@ -48,8 +50,27 @@ def run_read(path: str, limit: int = None) -> str: return "\n".join(lines)[:50000] ``` + + + + +```ts +function safePath(relativePath: string): string { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) { + throw new Error(`Path escapes workspace: ${relativePath}`); + } + return filePath; +} +``` + + + 2. The dispatch map links tool names to handlers. + + ```python TOOL_HANDLERS = { "bash": lambda **kw: run_bash(kw["command"]), @@ -60,8 +81,26 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const TOOL_HANDLERS = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => + runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), +}; +``` + + + 3. In the loop, look up the handler by name. The loop body itself is unchanged from s01. + + ```python for block in response.content: if block.type == "tool_use": @@ -75,6 +114,29 @@ for block in response.content: }) ``` + + + + +```ts +for (const block of response.content) { + if (block.type !== "tool_use") continue; + + const handler = TOOL_HANDLERS[block.name as ToolUseName]; + const output = handler + ? handler(block.input as Record) + : `Unknown tool: ${block.name}`; + + results.push({ + type: "tool_result", + tool_use_id: block.id, + content: output, + }); +} +``` + + + Add a tool = add a handler + add a schema entry. The loop never changes. ## What Changed From s01 @@ -90,6 +152,11 @@ Add a tool = add a handler + add a schema entry. The loop never changes. ```sh cd learn-claude-code +``` + + + +```sh python agents/s02_tool_use.py ``` @@ -97,3 +164,20 @@ python agents/s02_tool_use.py 2. `Create a file called greet.py with a greet(name) function` 3. `Edit greet.py to add a docstring to the function` 4. `Read greet.py to verify the edit worked` + + + + + +```sh +cd agents-ts +npm install +npm run s02 +``` + +1. `Read the file package.json` +2. `Create a file called greet.ts with a greet(name: string) function` +3. `Edit greet.ts to add a JSDoc comment` +4. `Read greet.ts to verify the edit worked` + + diff --git a/docs/en/s03-todo-write.md b/docs/en/s03-todo-write.md index e44611475..c60acf460 100644 --- a/docs/en/s03-todo-write.md +++ b/docs/en/s03-todo-write.md @@ -12,7 +12,7 @@ On multi-step tasks, the model loses track. It repeats work, skips steps, or wan ## Solution -``` +```text +--------+ +-------+ +---------+ | User | ---> | LLM | ---> | Tools | | prompt | | | | + todo | @@ -36,6 +36,8 @@ On multi-step tasks, the model loses track. It repeats work, skips steps, or wan 1. TodoManager stores items with statuses. Only one item can be `in_progress` at a time. + + ```python class TodoManager: def update(self, items: list) -> str: @@ -52,8 +54,46 @@ class TodoManager: return self.render() ``` + + + + +```ts +class TodoManager { + private items: TodoItem[] = []; + + update(items: unknown): string { + if (!Array.isArray(items)) { + throw new Error("items must be an array"); + } + + let inProgressCount = 0; + const validated = items.map((item, index) => { + const record = (item ?? {}) as Record; + const text = String(record.text ?? "").trim(); + const status = String(record.status ?? "pending").toLowerCase() as TodoStatus; + const id = String(record.id ?? index + 1); + + if (status === "in_progress") inProgressCount += 1; + return { id, text, status }; + }); + + if (inProgressCount > 1) { + throw new Error("Only one task can be in_progress at a time"); + } + + this.items = validated; + return this.render(); + } +} +``` + + + 2. The `todo` tool goes into the dispatch map like any other tool. + + ```python TOOL_HANDLERS = { # ...base tools... @@ -61,8 +101,23 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const TOOL_HANDLERS = { + // ...base tools... + todo: (input) => TODO.update(input.items), +}; +``` + + + 3. A nag reminder injects a nudge if the model goes 3+ rounds without calling `todo`. + + ```python if rounds_since_todo >= 3 and messages: last = messages[-1] @@ -73,6 +128,21 @@ if rounds_since_todo >= 3 and messages: }) ``` + + + + +```ts +if (roundsSinceTodo >= 3) { + results.unshift({ + type: "text", + text: "Update your todos.", + }); +} +``` + + + The "one in_progress at a time" constraint forces sequential focus. The nag reminder creates accountability. ## What Changed From s02 @@ -88,9 +158,30 @@ The "one in_progress at a time" constraint forces sequential focus. The nag remi ```sh cd learn-claude-code +``` + + + +```sh python agents/s03_todo_write.py ``` 1. `Refactor the file hello.py: add type hints, docstrings, and a main guard` 2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py` 3. `Review all Python files and fix any style issues` + + + + + +```sh +cd agents-ts +npm install +npm run s03 +``` + +1. `Refactor the file hello.ts: add type annotations, comments, and a small CLI entry` +2. `Create a TypeScript package with index.ts, utils.ts, and tests/utils.test.ts` +3. `Review all TypeScript files and fix any style issues` + + diff --git a/docs/en/s04-subagent.md b/docs/en/s04-subagent.md index 8a6ff2a6e..50ca1daf8 100644 --- a/docs/en/s04-subagent.md +++ b/docs/en/s04-subagent.md @@ -12,7 +12,7 @@ As the agent works, its messages array grows. Every file read, every bash output ## Solution -``` +```text Parent agent Subagent +------------------+ +------------------+ | messages=[...] | | messages=[] | <-- fresh @@ -30,6 +30,8 @@ Parent context stays clean. Subagent context is discarded. 1. The parent gets a `task` tool. The child gets all base tools except `task` (no recursive spawning). + + ```python PARENT_TOOLS = CHILD_TOOLS + [ {"name": "task", @@ -42,8 +44,34 @@ PARENT_TOOLS = CHILD_TOOLS + [ ] ``` + + + + +```ts +const PARENT_TOOLS = [ + ...CHILD_TOOLS, + { + name: "task", + description: "Spawn a subagent with fresh context.", + input_schema: { + type: "object", + properties: { + prompt: { type: "string" }, + description: { type: "string" }, + }, + required: ["prompt"], + }, + }, +]; +``` + + + 2. The subagent starts with `messages=[]` and runs its own loop. Only the final text returns to the parent. + + ```python def run_subagent(prompt: str) -> str: sub_messages = [{"role": "user", "content": prompt}] @@ -71,6 +99,52 @@ def run_subagent(prompt: str) -> str: ) or "(no summary)" ``` + + + + +```ts +async function runSubagent(prompt: string): Promise { + const subMessages: Message[] = [{ role: "user", content: prompt }]; + + for (let attempt = 0; attempt < 30; attempt += 1) { + const response = await client.messages.create({ + model: MODEL, + system: SUBAGENT_SYSTEM, + messages: subMessages, + tools: CHILD_TOOLS, + max_tokens: 8000, + }); + + subMessages.push({ role: "assistant", content: response.content }); + if (response.stop_reason !== "tool_use") { + return response.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("") || "(no summary)"; + } + + const results = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + const handler = TOOL_HANDLERS[block.name as ToolUseName]; + const output = handler(block.input as Record); + results.push({ + type: "tool_result" as const, + tool_use_id: block.id, + content: String(output).slice(0, 50000), + }); + } + + subMessages.push({ role: "user", content: results }); + } + + return "(no summary)"; +} +``` + + + The child's entire message history (possibly 30+ tool calls) is discarded. The parent receives a one-paragraph summary as a normal `tool_result`. ## What Changed From s03 @@ -86,9 +160,30 @@ The child's entire message history (possibly 30+ tool calls) is discarded. The p ```sh cd learn-claude-code +``` + + + +```sh python agents/s04_subagent.py ``` 1. `Use a subtask to find what testing framework this project uses` 2. `Delegate: read all .py files and summarize what each one does` 3. `Use a task to create a new module, then verify it from here` + + + + + +```sh +cd agents-ts +npm install +npm run s04 +``` + +1. `Use a subtask to find what testing framework this project uses` +2. `Delegate: read all .ts files and summarize what each one does` +3. `Use a task to create a new module, then verify it from here` + + diff --git a/docs/en/s05-skill-loading.md b/docs/en/s05-skill-loading.md index 0cf193850..ad0b22a56 100644 --- a/docs/en/s05-skill-loading.md +++ b/docs/en/s05-skill-loading.md @@ -45,7 +45,9 @@ skills/ SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ... ``` -2. SkillLoader scans for `SKILL.md` files, uses the directory name as the skill identifier. +2. SkillLoader scans for `SKILL.md` files and uses the directory name as the fallback skill identifier. + + ```python class SkillLoader: @@ -71,8 +73,44 @@ class SkillLoader: return f"\n{skill['body']}\n" ``` + + + + +```ts +class SkillLoader { + skills: Record = {}; + + constructor(private skillsDir: string) { + this.loadAll(); + } + + private loadAll() { + for (const filePath of collectSkillFiles(this.skillsDir)) { + const text = readFileSync(filePath, "utf8"); + const { meta, body } = parseFrontmatter(text); + const fallbackName = filePath.replace(/\\/g, "/").split("/").slice(-2, -1)[0] ?? "unknown"; + const name = meta.name || fallbackName; + this.skills[name] = { meta, body, path: filePath }; + } + } + + getContent(name: string): string { + const skill = this.skills[name]; + if (!skill) { + return `Error: Unknown skill '${name}'.`; + } + return `\n${skill.body}\n`; + } +} +``` + + + 3. Layer 1 goes into the system prompt. Layer 2 is just another tool handler. + + ```python SYSTEM = f"""You are a coding agent at {WORKDIR}. Skills available: @@ -84,6 +122,25 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const SYSTEM = `You are a coding agent at ${WORKDIR}. +Use load_skill to access specialized knowledge before tackling unfamiliar topics. + +Skills available: +${skillLoader.getDescriptions()}`; + +const TOOL_HANDLERS = { + // ...base tools... + load_skill: (input) => skillLoader.getContent(String(input.name ?? "")), +}; +``` + + + The model learns what skills exist (cheap) and loads them when relevant (expensive). ## What Changed From s04 @@ -99,6 +156,11 @@ The model learns what skills exist (cheap) and loads them when relevant (expensi ```sh cd learn-claude-code +``` + + + +```sh python agents/s05_skill_loading.py ``` @@ -106,3 +168,21 @@ python agents/s05_skill_loading.py 2. `Load the agent-builder skill and follow its instructions` 3. `I need to do a code review -- load the relevant skill first` 4. `Build an MCP server using the mcp-builder skill` + + + + + +```sh +cd agents-ts +npm install +npm run s05 +``` + +1. `What skills are available?` +2. `Load the agent-builder skill and follow its instructions` +3. `I need to do a code review -- load the relevant skill first` +4. `Build an MCP server using the mcp-builder skill` + + + diff --git a/docs/en/s06-context-compact.md b/docs/en/s06-context-compact.md index d3e2d4661..f01e6ed73 100644 --- a/docs/en/s06-context-compact.md +++ b/docs/en/s06-context-compact.md @@ -46,6 +46,8 @@ continue [Layer 2: auto_compact] 1. **Layer 1 -- micro_compact**: Before each LLM call, replace old tool results with placeholders. + + ```python def micro_compact(messages: list) -> list: tool_results = [] @@ -62,8 +64,41 @@ def micro_compact(messages: list) -> list: return messages ``` + + + + +```ts +function microCompact(messages: Message[]): Message[] { + const toolResults: ToolResultBlock[] = []; + + for (const message of messages) { + if (message.role !== "user" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (isToolResultBlock(part)) { + toolResults.push(part); + } + } + } + + if (toolResults.length <= KEEP_RECENT) { + return messages; + } + + for (const result of toolResults.slice(0, -KEEP_RECENT)) { + result.content = `[Previous: used ${toolName}]`; + } + + return messages; +} +``` + + + 2. **Layer 2 -- auto_compact**: When tokens exceed threshold, save full transcript to disk, then ask the LLM to summarize. + + ```python def auto_compact(messages: list) -> list: # Save transcript for recovery @@ -85,10 +120,42 @@ def auto_compact(messages: list) -> list: ] ``` + + + + +```ts +async function autoCompact(messages: Message[]): Promise { + const transcriptPath = resolve(TRANSCRIPT_DIR, `transcript_${Date.now()}.jsonl`); + for (const message of messages) { + appendFileSync(transcriptPath, `${JSON.stringify(message)}\n`, "utf8"); + } + + const response = await client.messages.create({ + model: MODEL, + messages: [{ + role: "user", + content: "Summarize this conversation for continuity...\n\n" + + JSON.stringify(messages).slice(0, 80_000), + }], + max_tokens: 2000, + }); + + return [ + { role: "user", content: `[Conversation compressed. Transcript: ${transcriptPath}]` }, + { role: "assistant", content: "Understood. I have the context from the summary. Continuing." }, + ]; +} +``` + + + 3. **Layer 3 -- manual compact**: The `compact` tool triggers the same summarization on demand. 4. The loop integrates all three: + + ```python def agent_loop(messages: list): while True: @@ -101,6 +168,31 @@ def agent_loop(messages: list): messages[:] = auto_compact(messages) # Layer 3 ``` + + + + +```ts +export async function agentLoop(messages: Message[]) { + while (true) { + microCompact(messages); + + if (estimateTokens(messages) > THRESHOLD) { + messages.splice(0, messages.length, ...(await autoCompact(messages))); + } + + const response = await client.messages.create(...); + // ... tool execution ... + + if (manualCompact) { + messages.splice(0, messages.length, ...(await autoCompact(messages))); + } + } +} +``` + + + Transcripts preserve full history on disk. Nothing is truly lost -- just moved out of active context. ## What Changed From s05 @@ -117,9 +209,31 @@ Transcripts preserve full history on disk. Nothing is truly lost -- just moved o ```sh cd learn-claude-code +``` + + + +```sh python agents/s06_context_compact.py ``` 1. `Read every Python file in the agents/ directory one by one` (watch micro-compact replace old results) 2. `Keep reading files until compression triggers automatically` 3. `Use the compact tool to manually compress the conversation` + + + + + +```sh +cd agents-ts +npm install +npm run s06 +``` + +1. `Read every TypeScript file in the agents-ts directory one by one` (watch micro-compact replace old results) +2. `Keep reading files until compression triggers automatically` +3. `Use the compact tool to manually compress the conversation` + + + diff --git a/docs/en/s07-task-system.md b/docs/en/s07-task-system.md index 0eece3933..15cd363f7 100644 --- a/docs/en/s07-task-system.md +++ b/docs/en/s07-task-system.md @@ -50,6 +50,8 @@ This task graph becomes the coordination backbone for everything after s07: back 1. **TaskManager**: one JSON file per task, CRUD with dependency graph. + + ```python class TaskManager: def __init__(self, tasks_dir: Path): @@ -66,8 +68,35 @@ class TaskManager: return json.dumps(task, indent=2) ``` + + + + +```ts +class TaskManager { + create(subject: string, description = "") { + const task = { + id: this.nextId, + subject, + description, + status: "pending", + blockedBy: [], + blocks: [], + owner: "", + }; + this.save(task); + this.nextId += 1; + return JSON.stringify(task, null, 2); + } +} +``` + + + 2. **Dependency resolution**: completing a task clears its ID from every other task's `blockedBy` list, automatically unblocking dependents. + + ```python def _clear_dependency(self, completed_id): for f in self.dir.glob("task_*.json"): @@ -77,8 +106,27 @@ def _clear_dependency(self, completed_id): self._save(task) ``` + + + + +```ts +private clearDependency(completedId: number) { + for (const task of this.loadAll()) { + if (task.blockedBy.includes(completedId)) { + task.blockedBy = task.blockedBy.filter((id) => id !== completedId); + this.save(task); + } + } +} +``` + + + 3. **Status + dependency wiring**: `update` handles transitions and dependency edges. + + ```python def update(self, task_id, status=None, add_blocked_by=None, add_blocks=None): @@ -90,8 +138,27 @@ def update(self, task_id, status=None, self._save(task) ``` + + + + +```ts +update(taskId: number, status?: string, addBlockedBy?: number[], addBlocks?: number[]) { + const task = this.load(taskId); + if (status === "completed") { + task.status = "completed"; + this.clearDependency(taskId); + } + this.save(task); +} +``` + + + 4. Four task tools go into the dispatch map. + + ```python TOOL_HANDLERS = { # ...base tools... @@ -102,6 +169,21 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const TOOL_HANDLERS = { + task_create: (input) => TASKS.create(String(input.subject ?? "")), + task_update: (input) => TASKS.update(Number(input.task_id ?? 0), input.status as string), + task_list: () => TASKS.listAll(), + task_get: (input) => TASKS.get(Number(input.task_id ?? 0)), +}; +``` + + + From s07 onward, the task graph is the default for multi-step work. s03's Todo remains for quick single-session checklists. ## What Changed From s06 @@ -118,10 +200,28 @@ From s07 onward, the task graph is the default for multi-step work. s03's Todo r ```sh cd learn-claude-code +``` + + + +```sh python agents/s07_task_system.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s07 +``` + + + 1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.` 2. `List all tasks and show the dependency graph` 3. `Complete task 1 and then list tasks to see task 2 unblocked` 4. `Create a task board for refactoring: parse -> transform -> emit -> test, where transform and emit can run in parallel after parse` + diff --git a/docs/en/s08-background-tasks.md b/docs/en/s08-background-tasks.md index ffd933378..8a2cb2b2d 100644 --- a/docs/en/s08-background-tasks.md +++ b/docs/en/s08-background-tasks.md @@ -34,6 +34,8 @@ Agent --[spawn A]--[spawn B]--[other work]---- 1. BackgroundManager tracks tasks with a thread-safe notification queue. + + ```python class BackgroundManager: def __init__(self): @@ -42,8 +44,23 @@ class BackgroundManager: self._lock = threading.Lock() ``` + + + + +```ts +class BackgroundManager { + tasks: Record = {}; + private notificationQueue: Array<{ task_id: string; status: string; result: string }> = []; +} +``` + + + 2. `run()` starts a daemon thread and returns immediately. + + ```python def run(self, command: str) -> str: task_id = str(uuid.uuid4())[:8] @@ -54,8 +71,25 @@ def run(self, command: str) -> str: return f"Background task {task_id} started" ``` + + + + +```ts +run(command: string) { + const taskId = randomUUID().slice(0, 8); + this.tasks[taskId] = { status: "running", result: null, command }; + const child = spawn(shell, args, { cwd: WORKDIR }); + return `Background task ${taskId} started`; +} +``` + + + 3. When the subprocess finishes, its result goes into the notification queue. + + ```python def _execute(self, task_id, command): try: @@ -69,8 +103,26 @@ def _execute(self, task_id, command): "task_id": task_id, "result": output[:500]}) ``` + + + + +```ts +child.on("close", () => { + this.notificationQueue.push({ + task_id: taskId, + status: "completed", + result: result.slice(0, 500), + }); +}); +``` + + + 4. The agent loop drains notifications before each LLM call. + + ```python def agent_loop(messages: list): while True: @@ -86,6 +138,22 @@ def agent_loop(messages: list): response = client.messages.create(...) ``` + + + + +```ts +const notifications = BG.drainNotifications(); +if (notifications.length) { + messages.push({ + role: "user", + content: `\n${notifText}\n`, + }); +} +``` + + + The loop stays single-threaded. Only subprocess I/O is parallelized. ## What Changed From s07 @@ -101,9 +169,27 @@ The loop stays single-threaded. Only subprocess I/O is parallelized. ```sh cd learn-claude-code +``` + + + +```sh python agents/s08_background_tasks.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s08 +``` + + + 1. `Run "sleep 5 && echo done" in the background, then create a file while it runs` 2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.` 3. `Run pytest in the background and keep working on other things` + diff --git a/docs/en/s09-agent-teams.md b/docs/en/s09-agent-teams.md index 34b54b3df..f4042ed31 100644 --- a/docs/en/s09-agent-teams.md +++ b/docs/en/s09-agent-teams.md @@ -39,6 +39,8 @@ Communication: 1. TeammateManager maintains config.json with the team roster. + + ```python class TeammateManager: def __init__(self, team_dir: Path): @@ -49,8 +51,23 @@ class TeammateManager: self.threads = {} ``` + + + + +```ts +class TeammateManager { + private configPath = resolve(teamDir, "config.json"); + private config: TeamConfig = this.loadConfig(); +} +``` + + + 2. `spawn()` creates a teammate and starts its agent loop in a thread. + + ```python def spawn(self, name: str, role: str, prompt: str) -> str: member = {"name": name, "role": role, "status": "working"} @@ -63,8 +80,25 @@ def spawn(self, name: str, role: str, prompt: str) -> str: return f"Spawned teammate '{name}' (role: {role})" ``` + + + + +```ts +spawn(name: string, role: string, prompt: string) { + this.config.members.push({ name, role, status: "working" }); + this.saveConfig(); + void this.teammateLoop(name, role, prompt); + return `Spawned '${name}' (role: ${role})`; +} +``` + + + 3. MessageBus: append-only JSONL inboxes. `send()` appends a JSON line; `read_inbox()` reads all and drains. + + ```python class MessageBus: def send(self, sender, to, content, msg_type="message", extra=None): @@ -83,8 +117,30 @@ class MessageBus: return json.dumps(msgs, indent=2) ``` + + + + +```ts +class MessageBus { + send(sender: string, to: string, content: string) { + appendFileSync(resolve(this.inboxDir, `${to}.jsonl`), `${JSON.stringify({ from: sender, content })}\n`); + } + + readInbox(name: string) { + const lines = readFileSync(resolve(this.inboxDir, `${name}.jsonl`), "utf8").split(/\r?\n/).filter(Boolean); + writeFileSync(resolve(this.inboxDir, `${name}.jsonl`), "", "utf8"); + return lines.map((line) => JSON.parse(line)); + } +} +``` + + + 4. Each teammate checks its inbox before every LLM call, injecting received messages into context. + + ```python def _teammate_loop(self, name, role, prompt): messages = [{"role": "user", "content": prompt}] @@ -102,6 +158,19 @@ def _teammate_loop(self, name, role, prompt): self._find_member(name)["status"] = "idle" ``` + + + + +```ts +for (const message of BUS.readInbox(name)) { + messages.push({ role: "user", content: JSON.stringify(message) }); +} +const response = await client.messages.create(...); +``` + + + ## What Changed From s08 | Component | Before (s08) | After (s09) | @@ -117,11 +186,29 @@ def _teammate_loop(self, name, role, prompt): ```sh cd learn-claude-code +``` + + + +```sh python agents/s09_agent_teams.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s09 +``` + + + 1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.` 2. `Broadcast "status update: phase 1 complete" to all teammates` 3. `Check the lead inbox for any messages` 4. Type `/team` to see the team roster with statuses 5. Type `/inbox` to manually check the lead's inbox + diff --git a/docs/en/s10-team-protocols.md b/docs/en/s10-team-protocols.md index e784e5ee0..57d1b0cf8 100644 --- a/docs/en/s10-team-protocols.md +++ b/docs/en/s10-team-protocols.md @@ -44,6 +44,8 @@ Trackers: 1. The lead initiates shutdown by generating a request_id and sending through the inbox. + + ```python shutdown_requests = {} @@ -55,8 +57,24 @@ def handle_shutdown_request(teammate: str) -> str: return f"Shutdown request {req_id} sent (status: pending)" ``` + + + + +```ts +function handleShutdownRequest(teammate: string) { + const requestId = randomUUID().slice(0, 8); + shutdownRequests[requestId] = { target: teammate, status: "pending" }; + BUS.send("lead", teammate, "Please shut down gracefully.", "shutdown_request", { request_id: requestId }); +} +``` + + + 2. The teammate receives the request and responds with approve/reject. + + ```python if tool_name == "shutdown_response": req_id = args["request_id"] @@ -67,8 +85,26 @@ if tool_name == "shutdown_response": {"request_id": req_id, "approve": approve}) ``` + + + + +```ts +if (toolName === "shutdown_response") { + shutdownRequests[requestId].status = input.approve ? "approved" : "rejected"; + BUS.send(sender, "lead", String(input.reason ?? ""), "shutdown_response", { + request_id: requestId, + approve: Boolean(input.approve), + }); +} +``` + + + 3. Plan approval follows the identical pattern. The teammate submits a plan (generating a request_id), the lead reviews (referencing the same request_id). + + ```python plan_requests = {} @@ -80,6 +116,23 @@ def handle_plan_review(request_id, approve, feedback=""): {"request_id": request_id, "approve": approve}) ``` + + + + +```ts +function handlePlanReview(requestId: string, approve: boolean, feedback = "") { + const request = planRequests[requestId]; + request.status = approve ? "approved" : "rejected"; + BUS.send("lead", request.from, feedback, "plan_approval_response", { + request_id: requestId, + approve, + }); +} +``` + + + One FSM, two applications. The same `pending -> approved | rejected` state machine handles any request-response protocol. ## What Changed From s09 @@ -96,11 +149,29 @@ One FSM, two applications. The same `pending -> approved | rejected` state machi ```sh cd learn-claude-code +``` + + + +```sh python agents/s10_team_protocols.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s10 +``` + + + 1. `Spawn alice as a coder. Then request her shutdown.` 2. `List teammates to see alice's status after shutdown approval` 3. `Spawn bob with a risky refactoring task. Review and reject his plan.` 4. `Spawn charlie, have him submit a plan, then approve it.` 5. Type `/team` to monitor statuses + diff --git a/docs/en/s11-autonomous-agents.md b/docs/en/s11-autonomous-agents.md index a3c283675..3a64b9482 100644 --- a/docs/en/s11-autonomous-agents.md +++ b/docs/en/s11-autonomous-agents.md @@ -49,6 +49,8 @@ Identity re-injection after compression: 1. The teammate loop has two phases: WORK and IDLE. When the LLM stops calling tools (or calls `idle`), the teammate enters IDLE. + + ```python def _loop(self, name, role, prompt): while True: @@ -71,8 +73,27 @@ def _loop(self, name, role, prompt): self._set_status(name, "working") ``` + + + + +```ts +async loop(name: string, role: string, prompt: string) { + while (true) { + // WORK phase + if (idleRequested) break; + // IDLE phase + if (!resume) return; + } +} +``` + + + 2. The idle phase polls inbox and task board in a loop. + + ```python def _idle_poll(self, name, messages): for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12 @@ -92,8 +113,26 @@ def _idle_poll(self, name, messages): return False # timeout -> shutdown ``` + + + + +```ts +while (Date.now() - start < IDLE_TIMEOUT) { + await sleep(POLL_INTERVAL); + const inbox = BUS.readInbox(name); + const unclaimed = scanUnclaimedTasks(); + if (inbox.length || unclaimed.length) return true; +} +return false; +``` + + + 3. Task board scanning: find pending, unowned, unblocked tasks. + + ```python def scan_unclaimed_tasks() -> list: unclaimed = [] @@ -106,8 +145,24 @@ def scan_unclaimed_tasks() -> list: return unclaimed ``` + + + + +```ts +function scanUnclaimedTasks() { + return loadTasks().filter((task) => + task.status === "pending" && !task.owner && !(task.blockedBy?.length), + ); +} +``` + + + 4. Identity re-injection: when context is too short (compression happened), insert an identity block. + + ```python if len(messages) <= 3: messages.insert(0, {"role": "user", @@ -117,6 +172,19 @@ if len(messages) <= 3: "content": f"I am {name}. Continuing."}) ``` + + + + +```ts +if (messages.length <= 3) { + messages.unshift({ role: "assistant", content: `I am ${name}. Continuing.` }); + messages.unshift(makeIdentityBlock(name, role, teamName)); +} +``` + + + ## What Changed From s10 | Component | Before (s10) | After (s11) | @@ -132,11 +200,29 @@ if len(messages) <= 3: ```sh cd learn-claude-code +``` + + + +```sh python agents/s11_autonomous_agents.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s11 +``` + + + 1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.` 2. `Spawn a coder teammate and let it find work from the task board itself` 3. `Create tasks with dependencies. Watch teammates respect the blocked order.` 4. Type `/tasks` to see the task board with owners 5. Type `/team` to monitor who is working vs idle + diff --git a/docs/en/s12-worktree-task-isolation.md b/docs/en/s12-worktree-task-isolation.md index a54282aca..8e45fcae5 100644 --- a/docs/en/s12-worktree-task-isolation.md +++ b/docs/en/s12-worktree-task-isolation.md @@ -38,21 +38,49 @@ State machines: 1. **Create a task.** Persist the goal first. + + ```python TASKS.create("Implement auth refactor") # -> .tasks/task_1.json status=pending worktree="" ``` + + + + +```ts +TASKS.create("Implement auth refactor"); +// -> .tasks/task_1.json status=pending worktree="" +``` + + + 2. **Create a worktree and bind to the task.** Passing `task_id` auto-advances the task to `in_progress`. + + ```python WORKTREES.create("auth-refactor", task_id=1) # -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD # -> index.json gets new entry, task_1.json gets worktree="auth-refactor" ``` + + + + +```ts +WORKTREES.create("auth-refactor", 1); +// -> index.json gets new entry, task_1.json gets worktree="auth-refactor" +``` + + + The binding writes state to both sides: + + ```python def bind_worktree(self, task_id, worktree): task = self._load(task_id) @@ -62,17 +90,46 @@ def bind_worktree(self, task_id, worktree): self._save(task) ``` + + + + +```ts +bindWorktree(taskId: number, worktree: string) { + const task = this.load(taskId); + task.worktree = worktree; + if (task.status === "pending") task.status = "in_progress"; + this.save(task); +} +``` + + + 3. **Run commands in the worktree.** `cwd` points to the isolated directory. + + ```python subprocess.run(command, shell=True, cwd=worktree_path, capture_output=True, text=True, timeout=300) ``` + + + + +```ts +runCommand(command, worktree.path, 300_000); +``` + + + 4. **Close out.** Two choices: - `worktree_keep(name)` -- preserve the directory for later. - `worktree_remove(name, complete_task=True)` -- remove directory, complete the bound task, emit event. One call handles teardown + completion. + + ```python def remove(self, name, force=False, complete_task=False): self._run_git(["worktree", "remove", wt["path"]]) @@ -82,6 +139,21 @@ def remove(self, name, force=False, complete_task=False): self.events.emit("task.completed", ...) ``` + + + + +```ts +remove(name: string, force = false, completeTask = false) { + if (completeTask && worktree.task_id) { + this.tasks.update(worktree.task_id, "completed"); + this.tasks.unbindWorktree(worktree.task_id); + } +} +``` + + + 5. **Event stream.** Every lifecycle step emits to `.worktrees/events.jsonl`: ```json @@ -111,11 +183,29 @@ After a crash, state reconstructs from `.tasks/` + `.worktrees/index.json` on di ```sh cd learn-claude-code +``` + + + +```sh python agents/s12_worktree_task_isolation.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s12 +``` + + + 1. `Create tasks for backend auth and frontend login page, then list tasks.` 2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".` 3. `Run "git status --short" in worktree "auth-refactor".` 4. `Keep worktree "ui-login", then list worktrees and inspect events.` 5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.` + diff --git a/docs/ja/s01-the-agent-loop.md b/docs/ja/s01-the-agent-loop.md index ddb54b973..1d3214658 100644 --- a/docs/ja/s01-the-agent-loop.md +++ b/docs/ja/s01-the-agent-loop.md @@ -12,7 +12,7 @@ ## 解決策 -``` +```text +--------+ +-------+ +---------+ | User | ---> | LLM | ---> | Tool | | prompt | | | | execute | @@ -29,12 +29,26 @@ 1. ユーザーのプロンプトが最初のメッセージになる。 + + ```python messages.append({"role": "user", "content": query}) ``` + + + + +```ts +history.push({ role: "user", content: query }); +``` + + + 2. メッセージとツール定義をLLMに送信する。 + + ```python response = client.messages.create( model=MODEL, system=SYSTEM, messages=messages, @@ -42,16 +56,53 @@ response = client.messages.create( ) ``` + + + + +```ts +const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: history, + tools: TOOLS, + max_tokens: 8000, +}); +``` + + + 3. アシスタントのレスポンスを追加し、`stop_reason`を確認する。ツールが呼ばれなければ終了。 + + ```python messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": return ``` + + + + +```ts +history.push({ + role: "assistant", + content: response.content, +}); + +if (response.stop_reason !== "tool_use") { + return; +} +``` + + + 4. 各ツール呼び出しを実行し、結果を収集してuserメッセージとして追加。ステップ2に戻る。 + + ```python results = [] for block in response.content: @@ -65,8 +116,28 @@ for block in response.content: messages.append({"role": "user", "content": results}) ``` + + + + +```ts +const results = response.content + .filter((block) => block.type === "tool_use") + .map((block) => ({ + type: "tool_result" as const, + tool_use_id: block.id, + content: runBash(block.input.command), + })); + +history.push({ role: "user", content: results }); +``` + + + 1つの関数にまとめると: + + ```python def agent_loop(query): messages = [{"role": "user", "content": query}] @@ -92,6 +163,42 @@ def agent_loop(query): messages.append({"role": "user", "content": results}) ``` + + + + +```ts +export async function agentLoop(history: Message[]) { + while (true) { + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: history, + tools: TOOLS, + max_tokens: 8000, + }); + + history.push({ role: "assistant", content: response.content }); + + if (response.stop_reason !== "tool_use") { + return; + } + + const results = response.content + .filter((block) => block.type === "tool_use") + .map((block) => ({ + type: "tool_result" as const, + tool_use_id: block.id, + content: runBash(block.input.command), + })); + + history.push({ role: "user", content: results }); + } +} +``` + + + これでエージェント全体が30行未満に収まる。本コースの残りはすべてこのループの上に積み重なる -- ループ自体は変わらない。 ## 変更点 @@ -107,6 +214,11 @@ def agent_loop(query): ```sh cd learn-claude-code +``` + + + +```sh python agents/s01_agent_loop.py ``` @@ -114,3 +226,20 @@ python agents/s01_agent_loop.py 2. `List all Python files in this directory` 3. `What is the current git branch?` 4. `Create a directory called test_output and write 3 files in it` + + + + + +```sh +cd agents-ts +npm install +npm run s01 +``` + +1. `Create a file called hello.ts that logs "Hello, World!"` +2. `List all TypeScript files in this directory` +3. `What is the current git branch?` +4. `Create a directory called test_output and write 3 files in it` + + diff --git a/docs/ja/s02-tool-use.md b/docs/ja/s02-tool-use.md index 3c41c1d5c..39793412d 100644 --- a/docs/ja/s02-tool-use.md +++ b/docs/ja/s02-tool-use.md @@ -14,7 +14,7 @@ ## 解決策 -``` +```text +--------+ +-------+ +------------------+ | User | ---> | LLM | ---> | Tool Dispatch | | prompt | | | | { | @@ -33,6 +33,8 @@ One lookup replaces any if/elif chain. 1. 各ツールにハンドラ関数を定義する。パスのサンドボックス化でワークスペース外への脱出を防ぐ。 + + ```python def safe_path(p: str) -> Path: path = (WORKDIR / p).resolve() @@ -48,8 +50,27 @@ def run_read(path: str, limit: int = None) -> str: return "\n".join(lines)[:50000] ``` + + + + +```ts +function safePath(relativePath: string): string { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) { + throw new Error(`Path escapes workspace: ${relativePath}`); + } + return filePath; +} +``` + + + 2. ディスパッチマップがツール名とハンドラを結びつける。 + + ```python TOOL_HANDLERS = { "bash": lambda **kw: run_bash(kw["command"]), @@ -60,8 +81,26 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const TOOL_HANDLERS = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => + runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), +}; +``` + + + 3. ループ内で名前によりハンドラをルックアップする。ループ本体はs01から不変。 + + ```python for block in response.content: if block.type == "tool_use": @@ -75,6 +114,29 @@ for block in response.content: }) ``` + + + + +```ts +for (const block of response.content) { + if (block.type !== "tool_use") continue; + + const handler = TOOL_HANDLERS[block.name as ToolUseName]; + const output = handler + ? handler(block.input as Record) + : `Unknown tool: ${block.name}`; + + results.push({ + type: "tool_result", + tool_use_id: block.id, + content: output, + }); +} +``` + + + ツール追加 = ハンドラ追加 + スキーマ追加。ループは決して変わらない。 ## s01からの変更点 @@ -90,6 +152,11 @@ for block in response.content: ```sh cd learn-claude-code +``` + + + +```sh python agents/s02_tool_use.py ``` @@ -97,3 +164,20 @@ python agents/s02_tool_use.py 2. `Create a file called greet.py with a greet(name) function` 3. `Edit greet.py to add a docstring to the function` 4. `Read greet.py to verify the edit worked` + + + + + +```sh +cd agents-ts +npm install +npm run s02 +``` + +1. `Read the file package.json` +2. `Create a file called greet.ts with a greet(name: string) function` +3. `Edit greet.ts to add a JSDoc comment` +4. `Read greet.ts to verify the edit worked` + + diff --git a/docs/ja/s03-todo-write.md b/docs/ja/s03-todo-write.md index 541d33c39..4fa7cb040 100644 --- a/docs/ja/s03-todo-write.md +++ b/docs/ja/s03-todo-write.md @@ -12,7 +12,7 @@ ## 解決策 -``` +```text +--------+ +-------+ +---------+ | User | ---> | LLM | ---> | Tools | | prompt | | | | + todo | @@ -36,6 +36,8 @@ 1. TodoManagerはアイテムのリストをステータス付きで保持する。`in_progress`にできるのは同時に1つだけ。 + + ```python class TodoManager: def update(self, items: list) -> str: @@ -52,8 +54,46 @@ class TodoManager: return self.render() ``` + + + + +```ts +class TodoManager { + private items: TodoItem[] = []; + + update(items: unknown): string { + if (!Array.isArray(items)) { + throw new Error("items must be an array"); + } + + let inProgressCount = 0; + const validated = items.map((item, index) => { + const record = (item ?? {}) as Record; + const text = String(record.text ?? "").trim(); + const status = String(record.status ?? "pending").toLowerCase() as TodoStatus; + const id = String(record.id ?? index + 1); + + if (status === "in_progress") inProgressCount += 1; + return { id, text, status }; + }); + + if (inProgressCount > 1) { + throw new Error("Only one task can be in_progress at a time"); + } + + this.items = validated; + return this.render(); + } +} +``` + + + 2. `todo`ツールは他のツールと同様にディスパッチマップに追加される。 + + ```python TOOL_HANDLERS = { # ...base tools... @@ -61,8 +101,23 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const TOOL_HANDLERS = { + // ...base tools... + todo: (input) => TODO.update(input.items), +}; +``` + + + 3. nagリマインダーが、モデルが3ラウンド以上`todo`を呼ばなかった場合にナッジを注入する。 + + ```python if rounds_since_todo >= 3 and messages: last = messages[-1] @@ -73,6 +128,21 @@ if rounds_since_todo >= 3 and messages: }) ``` + + + + +```ts +if (roundsSinceTodo >= 3) { + results.unshift({ + type: "text", + text: "Update your todos.", + }); +} +``` + + + 「一度にin_progressは1つだけ」の制約が逐次的な集中を強制し、nagリマインダーが説明責任を生む。 ## s02からの変更点 @@ -88,9 +158,30 @@ if rounds_since_todo >= 3 and messages: ```sh cd learn-claude-code +``` + + + +```sh python agents/s03_todo_write.py ``` 1. `Refactor the file hello.py: add type hints, docstrings, and a main guard` 2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py` 3. `Review all Python files and fix any style issues` + + + + + +```sh +cd agents-ts +npm install +npm run s03 +``` + +1. `Refactor the file hello.ts: add type annotations, comments, and a small CLI entry` +2. `Create a TypeScript package with index.ts, utils.ts, and tests/utils.test.ts` +3. `Review all TypeScript files and fix any style issues` + + diff --git a/docs/ja/s04-subagent.md b/docs/ja/s04-subagent.md index bfffc3165..3fad74229 100644 --- a/docs/ja/s04-subagent.md +++ b/docs/ja/s04-subagent.md @@ -12,7 +12,7 @@ ## 解決策 -``` +```text Parent agent Subagent +------------------+ +------------------+ | messages=[...] | | messages=[] | <-- fresh @@ -30,6 +30,8 @@ Parent context stays clean. Subagent context is discarded. 1. 親に`task`ツールを追加する。子は`task`を除くすべての基本ツールを取得する(再帰的な生成は不可)。 + + ```python PARENT_TOOLS = CHILD_TOOLS + [ {"name": "task", @@ -42,8 +44,34 @@ PARENT_TOOLS = CHILD_TOOLS + [ ] ``` + + + + +```ts +const PARENT_TOOLS = [ + ...CHILD_TOOLS, + { + name: "task", + description: "Spawn a subagent with fresh context.", + input_schema: { + type: "object", + properties: { + prompt: { type: "string" }, + description: { type: "string" }, + }, + required: ["prompt"], + }, + }, +]; +``` + + + 2. サブエージェントは`messages=[]`で開始し、自身のループを実行する。最終テキストだけが親に返る。 + + ```python def run_subagent(prompt: str) -> str: sub_messages = [{"role": "user", "content": prompt}] @@ -71,6 +99,52 @@ def run_subagent(prompt: str) -> str: ) or "(no summary)" ``` + + + + +```ts +async function runSubagent(prompt: string): Promise { + const subMessages: Message[] = [{ role: "user", content: prompt }]; + + for (let attempt = 0; attempt < 30; attempt += 1) { + const response = await client.messages.create({ + model: MODEL, + system: SUBAGENT_SYSTEM, + messages: subMessages, + tools: CHILD_TOOLS, + max_tokens: 8000, + }); + + subMessages.push({ role: "assistant", content: response.content }); + if (response.stop_reason !== "tool_use") { + return response.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("") || "(no summary)"; + } + + const results = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + const handler = TOOL_HANDLERS[block.name as ToolUseName]; + const output = handler(block.input as Record); + results.push({ + type: "tool_result" as const, + tool_use_id: block.id, + content: String(output).slice(0, 50000), + }); + } + + subMessages.push({ role: "user", content: results }); + } + + return "(no summary)"; +} +``` + + + 子のメッセージ履歴全体(30回以上のツール呼び出し)は破棄される。親は1段落の要約を通常の`tool_result`として受け取る。 ## s03からの変更点 @@ -86,9 +160,30 @@ def run_subagent(prompt: str) -> str: ```sh cd learn-claude-code +``` + + + +```sh python agents/s04_subagent.py ``` 1. `Use a subtask to find what testing framework this project uses` 2. `Delegate: read all .py files and summarize what each one does` 3. `Use a task to create a new module, then verify it from here` + + + + + +```sh +cd agents-ts +npm install +npm run s04 +``` + +1. `Use a subtask to find what testing framework this project uses` +2. `Delegate: read all .ts files and summarize what each one does` +3. `Use a task to create a new module, then verify it from here` + + diff --git a/docs/ja/s05-skill-loading.md b/docs/ja/s05-skill-loading.md index 14774bec9..ce639268f 100644 --- a/docs/ja/s05-skill-loading.md +++ b/docs/ja/s05-skill-loading.md @@ -45,7 +45,9 @@ skills/ SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ... ``` -2. SkillLoaderが `SKILL.md` を再帰的に探索し、ディレクトリ名をスキル識別子として使用する。 +2. SkillLoaderが `SKILL.md` を再帰的に探索し、ディレクトリ名をフォールバック識別子として使う。 + + ```python class SkillLoader: @@ -71,8 +73,44 @@ class SkillLoader: return f"\n{skill['body']}\n" ``` + + + + +```ts +class SkillLoader { + skills: Record = {}; + + constructor(private skillsDir: string) { + this.loadAll(); + } + + private loadAll() { + for (const filePath of collectSkillFiles(this.skillsDir)) { + const text = readFileSync(filePath, "utf8"); + const { meta, body } = parseFrontmatter(text); + const fallbackName = filePath.replace(/\\/g, "/").split("/").slice(-2, -1)[0] ?? "unknown"; + const name = meta.name || fallbackName; + this.skills[name] = { meta, body, path: filePath }; + } + } + + getContent(name: string): string { + const skill = this.skills[name]; + if (!skill) { + return `Error: Unknown skill '${name}'.`; + } + return `\n${skill.body}\n`; + } +} +``` + + + 3. 第1層はシステムプロンプトに配置。第2層は通常のツールハンドラ。 + + ```python SYSTEM = f"""You are a coding agent at {WORKDIR}. Skills available: @@ -84,6 +122,25 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const SYSTEM = `You are a coding agent at ${WORKDIR}. +Use load_skill to access specialized knowledge before tackling unfamiliar topics. + +Skills available: +${skillLoader.getDescriptions()}`; + +const TOOL_HANDLERS = { + // ...base tools... + load_skill: (input) => skillLoader.getContent(String(input.name ?? "")), +}; +``` + + + モデルはどのスキルが存在するかを知り(低コスト)、関連する時にだけ読み込む(高コスト)。 ## s04からの変更点 @@ -99,6 +156,11 @@ TOOL_HANDLERS = { ```sh cd learn-claude-code +``` + + + +```sh python agents/s05_skill_loading.py ``` @@ -106,3 +168,21 @@ python agents/s05_skill_loading.py 2. `Load the agent-builder skill and follow its instructions` 3. `I need to do a code review -- load the relevant skill first` 4. `Build an MCP server using the mcp-builder skill` + + + + + +```sh +cd agents-ts +npm install +npm run s05 +``` + +1. `What skills are available?` +2. `Load the agent-builder skill and follow its instructions` +3. `I need to do a code review -- load the relevant skill first` +4. `Build an MCP server using the mcp-builder skill` + + + diff --git a/docs/ja/s06-context-compact.md b/docs/ja/s06-context-compact.md index 555cc01e2..9c7d1a38f 100644 --- a/docs/ja/s06-context-compact.md +++ b/docs/ja/s06-context-compact.md @@ -46,6 +46,8 @@ continue [Layer 2: auto_compact] 1. **第1層 -- micro_compact**: 各LLM呼び出しの前に、古いツール結果をプレースホルダーに置換する。 + + ```python def micro_compact(messages: list) -> list: tool_results = [] @@ -62,8 +64,41 @@ def micro_compact(messages: list) -> list: return messages ``` + + + + +```ts +function microCompact(messages: Message[]): Message[] { + const toolResults: ToolResultBlock[] = []; + + for (const message of messages) { + if (message.role !== "user" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (isToolResultBlock(part)) { + toolResults.push(part); + } + } + } + + if (toolResults.length <= KEEP_RECENT) { + return messages; + } + + for (const result of toolResults.slice(0, -KEEP_RECENT)) { + result.content = `[Previous: used ${toolName}]`; + } + + return messages; +} +``` + + + 2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全なトランスクリプトをディスクに保存し、LLMに要約を依頼する。 + + ```python def auto_compact(messages: list) -> list: # Save transcript for recovery @@ -85,10 +120,42 @@ def auto_compact(messages: list) -> list: ] ``` + + + + +```ts +async function autoCompact(messages: Message[]): Promise { + const transcriptPath = resolve(TRANSCRIPT_DIR, `transcript_${Date.now()}.jsonl`); + for (const message of messages) { + appendFileSync(transcriptPath, `${JSON.stringify(message)}\n`, "utf8"); + } + + const response = await client.messages.create({ + model: MODEL, + messages: [{ + role: "user", + content: "Summarize this conversation for continuity...\n\n" + + JSON.stringify(messages).slice(0, 80_000), + }], + max_tokens: 2000, + }); + + return [ + { role: "user", content: `[Conversation compressed. Transcript: ${transcriptPath}]` }, + { role: "assistant", content: "Understood. I have the context from the summary. Continuing." }, + ]; +} +``` + + + 3. **第3層 -- manual compact**: `compact`ツールが同じ要約処理をオンデマンドでトリガーする。 4. ループが3層すべてを統合する: + + ```python def agent_loop(messages: list): while True: @@ -101,6 +168,31 @@ def agent_loop(messages: list): messages[:] = auto_compact(messages) # Layer 3 ``` + + + + +```ts +export async function agentLoop(messages: Message[]) { + while (true) { + microCompact(messages); + + if (estimateTokens(messages) > THRESHOLD) { + messages.splice(0, messages.length, ...(await autoCompact(messages))); + } + + const response = await client.messages.create(...); + // ... tool execution ... + + if (manualCompact) { + messages.splice(0, messages.length, ...(await autoCompact(messages))); + } + } +} +``` + + + トランスクリプトがディスク上に完全な履歴を保持する。何も真に失われず、アクティブなコンテキストの外に移動されるだけ。 ## s05からの変更点 @@ -117,9 +209,31 @@ def agent_loop(messages: list): ```sh cd learn-claude-code +``` + + + +```sh python agents/s06_context_compact.py ``` 1. `Read every Python file in the agents/ directory one by one` (micro-compactが古い結果を置換するのを観察する) 2. `Keep reading files until compression triggers automatically` 3. `Use the compact tool to manually compress the conversation` + + + + + +```sh +cd agents-ts +npm install +npm run s06 +``` + +1. `Read every TypeScript file in the agents-ts directory one by one` (micro-compactが古い結果を置換するのを観察する) +2. `Keep reading files until compression triggers automatically` +3. `Use the compact tool to manually compress the conversation` + + + diff --git a/docs/ja/s07-task-system.md b/docs/ja/s07-task-system.md index 77eeb2448..1385fecd3 100644 --- a/docs/ja/s07-task-system.md +++ b/docs/ja/s07-task-system.md @@ -50,6 +50,8 @@ s03のTodoManagerはメモリ上のフラットなチェックリストに過ぎ 1. **TaskManager**: タスクごとに1つのJSONファイル、依存グラフ付きCRUD。 + + ```python class TaskManager: def __init__(self, tasks_dir: Path): @@ -66,8 +68,35 @@ class TaskManager: return json.dumps(task, indent=2) ``` + + + + +```ts +class TaskManager { + create(subject: string, description = "") { + const task = { + id: this.nextId, + subject, + description, + status: "pending", + blockedBy: [], + blocks: [], + owner: "", + }; + this.save(task); + this.nextId += 1; + return JSON.stringify(task, null, 2); + } +} +``` + + + 2. **依存解除**: タスク完了時に、他タスクの`blockedBy`リストから完了IDを除去し、後続タスクをアンブロックする。 + + ```python def _clear_dependency(self, completed_id): for f in self.dir.glob("task_*.json"): @@ -77,8 +106,27 @@ def _clear_dependency(self, completed_id): self._save(task) ``` + + + + +```ts +private clearDependency(completedId: number) { + for (const task of this.loadAll()) { + if (task.blockedBy.includes(completedId)) { + task.blockedBy = task.blockedBy.filter((id) => id !== completedId); + this.save(task); + } + } +} +``` + + + 3. **ステータス遷移 + 依存配線**: `update`がステータス変更と依存エッジを担う。 + + ```python def update(self, task_id, status=None, add_blocked_by=None, add_blocks=None): @@ -90,8 +138,27 @@ def update(self, task_id, status=None, self._save(task) ``` + + + + +```ts +update(taskId: number, status?: string, addBlockedBy?: number[], addBlocks?: number[]) { + const task = this.load(taskId); + if (status === "completed") { + task.status = "completed"; + this.clearDependency(taskId); + } + this.save(task); +} +``` + + + 4. 4つのタスクツールをディスパッチマップに追加する。 + + ```python TOOL_HANDLERS = { # ...base tools... @@ -102,6 +169,21 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const TOOL_HANDLERS = { + task_create: (input) => TASKS.create(String(input.subject ?? "")), + task_update: (input) => TASKS.update(Number(input.task_id ?? 0), input.status as string), + task_list: () => TASKS.listAll(), + task_get: (input) => TASKS.get(Number(input.task_id ?? 0)), +}; +``` + + + s07以降、タスクグラフがマルチステップ作業のデフォルト。s03のTodoは軽量な単一セッション用チェックリストとして残る。 ## s06からの変更点 @@ -118,10 +200,28 @@ s07以降、タスクグラフがマルチステップ作業のデフォルト ```sh cd learn-claude-code +``` + + + +```sh python agents/s07_task_system.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s07 +``` + + + 1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.` 2. `List all tasks and show the dependency graph` 3. `Complete task 1 and then list tasks to see task 2 unblocked` 4. `Create a task board for refactoring: parse -> transform -> emit -> test, where transform and emit can run in parallel after parse` + diff --git a/docs/ja/s08-background-tasks.md b/docs/ja/s08-background-tasks.md index c09be7d14..7722204f8 100644 --- a/docs/ja/s08-background-tasks.md +++ b/docs/ja/s08-background-tasks.md @@ -34,6 +34,8 @@ Agent --[spawn A]--[spawn B]--[other work]---- 1. BackgroundManagerがスレッドセーフな通知キューでタスクを追跡する。 + + ```python class BackgroundManager: def __init__(self): @@ -42,8 +44,23 @@ class BackgroundManager: self._lock = threading.Lock() ``` + + + + +```ts +class BackgroundManager { + tasks: Record = {}; + private notificationQueue: Array<{ task_id: string; status: string; result: string }> = []; +} +``` + + + 2. `run()`がデーモンスレッドを開始し、即座にリターンする。 + + ```python def run(self, command: str) -> str: task_id = str(uuid.uuid4())[:8] @@ -54,8 +71,25 @@ def run(self, command: str) -> str: return f"Background task {task_id} started" ``` + + + + +```ts +run(command: string) { + const taskId = randomUUID().slice(0, 8); + this.tasks[taskId] = { status: "running", result: null, command }; + const child = spawn(shell, args, { cwd: WORKDIR }); + return `Background task ${taskId} started`; +} +``` + + + 3. サブプロセス完了時に、結果を通知キューへ。 + + ```python def _execute(self, task_id, command): try: @@ -69,8 +103,26 @@ def _execute(self, task_id, command): "task_id": task_id, "result": output[:500]}) ``` + + + + +```ts +child.on("close", () => { + this.notificationQueue.push({ + task_id: taskId, + status: "completed", + result: result.slice(0, 500), + }); +}); +``` + + + 4. エージェントループが各LLM呼び出しの前に通知をドレインする。 + + ```python def agent_loop(messages: list): while True: @@ -86,6 +138,22 @@ def agent_loop(messages: list): response = client.messages.create(...) ``` + + + + +```ts +const notifications = BG.drainNotifications(); +if (notifications.length) { + messages.push({ + role: "user", + content: `\n${notifText}\n`, + }); +} +``` + + + ループはシングルスレッドのまま。サブプロセスI/Oだけが並列化される。 ## s07からの変更点 @@ -101,9 +169,27 @@ def agent_loop(messages: list): ```sh cd learn-claude-code +``` + + + +```sh python agents/s08_background_tasks.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s08 +``` + + + 1. `Run "sleep 5 && echo done" in the background, then create a file while it runs` 2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.` 3. `Run pytest in the background and keep working on other things` + diff --git a/docs/ja/s09-agent-teams.md b/docs/ja/s09-agent-teams.md index 5857df496..2c9d45a3c 100644 --- a/docs/ja/s09-agent-teams.md +++ b/docs/ja/s09-agent-teams.md @@ -39,6 +39,8 @@ Communication: 1. TeammateManagerがconfig.jsonでチーム名簿を管理する。 + + ```python class TeammateManager: def __init__(self, team_dir: Path): @@ -49,8 +51,23 @@ class TeammateManager: self.threads = {} ``` + + + + +```ts +class TeammateManager { + private configPath = resolve(teamDir, "config.json"); + private config: TeamConfig = this.loadConfig(); +} +``` + + + 2. `spawn()`がチームメイトを作成し、そのエージェントループをスレッドで開始する。 + + ```python def spawn(self, name: str, role: str, prompt: str) -> str: member = {"name": name, "role": role, "status": "working"} @@ -63,8 +80,25 @@ def spawn(self, name: str, role: str, prompt: str) -> str: return f"Spawned teammate '{name}' (role: {role})" ``` + + + + +```ts +spawn(name: string, role: string, prompt: string) { + this.config.members.push({ name, role, status: "working" }); + this.saveConfig(); + void this.teammateLoop(name, role, prompt); + return `Spawned '${name}' (role: ${role})`; +} +``` + + + 3. MessageBus: 追記専用のJSONLインボックス。`send()`がJSON行を追記し、`read_inbox()`がすべて読み取ってドレインする。 + + ```python class MessageBus: def send(self, sender, to, content, msg_type="message", extra=None): @@ -83,8 +117,30 @@ class MessageBus: return json.dumps(msgs, indent=2) ``` + + + + +```ts +class MessageBus { + send(sender: string, to: string, content: string) { + appendFileSync(resolve(this.inboxDir, `${to}.jsonl`), `${JSON.stringify({ from: sender, content })}\n`); + } + + readInbox(name: string) { + const lines = readFileSync(resolve(this.inboxDir, `${name}.jsonl`), "utf8").split(/\r?\n/).filter(Boolean); + writeFileSync(resolve(this.inboxDir, `${name}.jsonl`), "", "utf8"); + return lines.map((line) => JSON.parse(line)); + } +} +``` + + + 4. 各チームメイトは各LLM呼び出しの前にインボックスを確認し、受信メッセージをコンテキストに注入する。 + + ```python def _teammate_loop(self, name, role, prompt): messages = [{"role": "user", "content": prompt}] @@ -102,6 +158,19 @@ def _teammate_loop(self, name, role, prompt): self._find_member(name)["status"] = "idle" ``` + + + + +```ts +for (const message of BUS.readInbox(name)) { + messages.push({ role: "user", content: JSON.stringify(message) }); +} +const response = await client.messages.create(...); +``` + + + ## s08からの変更点 | Component | Before (s08) | After (s09) | @@ -117,11 +186,29 @@ def _teammate_loop(self, name, role, prompt): ```sh cd learn-claude-code +``` + + + +```sh python agents/s09_agent_teams.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s09 +``` + + + 1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.` 2. `Broadcast "status update: phase 1 complete" to all teammates` 3. `Check the lead inbox for any messages` 4. `/team`と入力してステータス付きのチーム名簿を確認する 5. `/inbox`と入力してリーダーのインボックスを手動確認する + diff --git a/docs/ja/s10-team-protocols.md b/docs/ja/s10-team-protocols.md index fd19562d9..d413f2810 100644 --- a/docs/ja/s10-team-protocols.md +++ b/docs/ja/s10-team-protocols.md @@ -44,6 +44,8 @@ Trackers: 1. リーダーがrequest_idを生成し、インボックス経由でシャットダウンを開始する。 + + ```python shutdown_requests = {} @@ -55,8 +57,24 @@ def handle_shutdown_request(teammate: str) -> str: return f"Shutdown request {req_id} sent (status: pending)" ``` + + + + +```ts +function handleShutdownRequest(teammate: string) { + const requestId = randomUUID().slice(0, 8); + shutdownRequests[requestId] = { target: teammate, status: "pending" }; + BUS.send("lead", teammate, "Please shut down gracefully.", "shutdown_request", { request_id: requestId }); +} +``` + + + 2. チームメイトがリクエストを受信し、承認または拒否で応答する。 + + ```python if tool_name == "shutdown_response": req_id = args["request_id"] @@ -67,8 +85,26 @@ if tool_name == "shutdown_response": {"request_id": req_id, "approve": approve}) ``` + + + + +```ts +if (toolName === "shutdown_response") { + shutdownRequests[requestId].status = input.approve ? "approved" : "rejected"; + BUS.send(sender, "lead", String(input.reason ?? ""), "shutdown_response", { + request_id: requestId, + approve: Boolean(input.approve), + }); +} +``` + + + 3. プラン承認も同一パターン。チームメイトがプランを提出(request_idを生成)、リーダーがレビュー(同じrequest_idを参照)。 + + ```python plan_requests = {} @@ -80,6 +116,23 @@ def handle_plan_review(request_id, approve, feedback=""): {"request_id": request_id, "approve": approve}) ``` + + + + +```ts +function handlePlanReview(requestId: string, approve: boolean, feedback = "") { + const request = planRequests[requestId]; + request.status = approve ? "approved" : "rejected"; + BUS.send("lead", request.from, feedback, "plan_approval_response", { + request_id: requestId, + approve, + }); +} +``` + + + 1つのFSM、2つの応用。同じ`pending -> approved | rejected`状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。 ## s09からの変更点 @@ -96,11 +149,29 @@ def handle_plan_review(request_id, approve, feedback=""): ```sh cd learn-claude-code +``` + + + +```sh python agents/s10_team_protocols.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s10 +``` + + + 1. `Spawn alice as a coder. Then request her shutdown.` 2. `List teammates to see alice's status after shutdown approval` 3. `Spawn bob with a risky refactoring task. Review and reject his plan.` 4. `Spawn charlie, have him submit a plan, then approve it.` 5. `/team`と入力してステータスを監視する + diff --git a/docs/ja/s11-autonomous-agents.md b/docs/ja/s11-autonomous-agents.md index 4bc690e61..509576839 100644 --- a/docs/ja/s11-autonomous-agents.md +++ b/docs/ja/s11-autonomous-agents.md @@ -49,6 +49,8 @@ Identity re-injection after compression: 1. チームメイトのループはWORKとIDLEの2フェーズ。LLMがツール呼び出しを止めた時(または`idle`ツールを呼んだ時)、IDLEフェーズに入る。 + + ```python def _loop(self, name, role, prompt): while True: @@ -71,8 +73,27 @@ def _loop(self, name, role, prompt): self._set_status(name, "working") ``` + + + + +```ts +async loop(name: string, role: string, prompt: string) { + while (true) { + // WORK phase + if (idleRequested) break; + // IDLE phase + if (!resume) return; + } +} +``` + + + 2. IDLEフェーズがインボックスとタスクボードをポーリングする。 + + ```python def _idle_poll(self, name, messages): for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12 @@ -92,8 +113,26 @@ def _idle_poll(self, name, messages): return False # timeout -> shutdown ``` + + + + +```ts +while (Date.now() - start < IDLE_TIMEOUT) { + await sleep(POLL_INTERVAL); + const inbox = BUS.readInbox(name); + const unclaimed = scanUnclaimedTasks(); + if (inbox.length || unclaimed.length) return true; +} +return false; +``` + + + 3. タスクボードスキャン: pendingかつ未割り当てかつブロックされていないタスクを探す。 + + ```python def scan_unclaimed_tasks() -> list: unclaimed = [] @@ -106,8 +145,24 @@ def scan_unclaimed_tasks() -> list: return unclaimed ``` + + + + +```ts +function scanUnclaimedTasks() { + return loadTasks().filter((task) => + task.status === "pending" && !task.owner && !(task.blockedBy?.length), + ); +} +``` + + + 4. アイデンティティ再注入: コンテキストが短すぎる(圧縮が起きた)場合にアイデンティティブロックを挿入する。 + + ```python if len(messages) <= 3: messages.insert(0, {"role": "user", @@ -117,6 +172,19 @@ if len(messages) <= 3: "content": f"I am {name}. Continuing."}) ``` + + + + +```ts +if (messages.length <= 3) { + messages.unshift({ role: "assistant", content: `I am ${name}. Continuing.` }); + messages.unshift(makeIdentityBlock(name, role, teamName)); +} +``` + + + ## s10からの変更点 | Component | Before (s10) | After (s11) | @@ -132,11 +200,29 @@ if len(messages) <= 3: ```sh cd learn-claude-code +``` + + + +```sh python agents/s11_autonomous_agents.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s11 +``` + + + 1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.` 2. `Spawn a coder teammate and let it find work from the task board itself` 3. `Create tasks with dependencies. Watch teammates respect the blocked order.` 4. `/tasks`と入力してオーナー付きのタスクボードを確認する 5. `/team`と入力して誰が作業中でアイドルかを監視する + diff --git a/docs/ja/s12-worktree-task-isolation.md b/docs/ja/s12-worktree-task-isolation.md index 380422c52..82d51e19e 100644 --- a/docs/ja/s12-worktree-task-isolation.md +++ b/docs/ja/s12-worktree-task-isolation.md @@ -38,21 +38,49 @@ State machines: 1. **タスクを作成する。** まず目標を永続化する。 + + ```python TASKS.create("Implement auth refactor") # -> .tasks/task_1.json status=pending worktree="" ``` + + + + +```ts +TASKS.create("Implement auth refactor"); +// -> .tasks/task_1.json status=pending worktree="" +``` + + + 2. **worktreeを作成してタスクに紐付ける。** `task_id`を渡すと、タスクが自動的に`in_progress`に遷移する。 + + ```python WORKTREES.create("auth-refactor", task_id=1) # -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD # -> index.json gets new entry, task_1.json gets worktree="auth-refactor" ``` + + + + +```ts +WORKTREES.create("auth-refactor", 1); +// -> index.json gets new entry, task_1.json gets worktree="auth-refactor" +``` + + + 紐付けは両側に状態を書き込む: + + ```python def bind_worktree(self, task_id, worktree): task = self._load(task_id) @@ -62,17 +90,46 @@ def bind_worktree(self, task_id, worktree): self._save(task) ``` + + + + +```ts +bindWorktree(taskId: number, worktree: string) { + const task = this.load(taskId); + task.worktree = worktree; + if (task.status === "pending") task.status = "in_progress"; + this.save(task); +} +``` + + + 3. **worktree内でコマンドを実行する。** `cwd`が分離ディレクトリを指す。 + + ```python subprocess.run(command, shell=True, cwd=worktree_path, capture_output=True, text=True, timeout=300) ``` + + + + +```ts +runCommand(command, worktree.path, 300_000); +``` + + + 4. **終了処理。** 2つの選択肢: - `worktree_keep(name)` -- ディレクトリを保持する。 - `worktree_remove(name, complete_task=True)` -- ディレクトリを削除し、紐付けられたタスクを完了し、イベントを発行する。1回の呼び出しで後片付けと完了を処理する。 + + ```python def remove(self, name, force=False, complete_task=False): self._run_git(["worktree", "remove", wt["path"]]) @@ -82,6 +139,21 @@ def remove(self, name, force=False, complete_task=False): self.events.emit("task.completed", ...) ``` + + + + +```ts +remove(name: string, force = false, completeTask = false) { + if (completeTask && worktree.task_id) { + this.tasks.update(worktree.task_id, "completed"); + this.tasks.unbindWorktree(worktree.task_id); + } +} +``` + + + 5. **イベントストリーム。** ライフサイクルの各ステップが`.worktrees/events.jsonl`に記録される: ```json @@ -111,11 +183,29 @@ def remove(self, name, force=False, complete_task=False): ```sh cd learn-claude-code +``` + + + +```sh python agents/s12_worktree_task_isolation.py ``` + + + + +```sh +cd agents-ts +npm install +npm run s12 +``` + + + 1. `Create tasks for backend auth and frontend login page, then list tasks.` 2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".` 3. `Run "git status --short" in worktree "auth-refactor".` 4. `Keep worktree "ui-login", then list worktrees and inspect events.` 5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.` + diff --git a/docs/zh/s01-the-agent-loop.md b/docs/zh/s01-the-agent-loop.md index 3caef8fe0..2d571c343 100644 --- a/docs/zh/s01-the-agent-loop.md +++ b/docs/zh/s01-the-agent-loop.md @@ -12,7 +12,7 @@ ## 解决方案 -``` +```text +--------+ +-------+ +---------+ | User | ---> | LLM | ---> | Tool | | prompt | | | | execute | @@ -29,12 +29,26 @@ 1. 用户 prompt 作为第一条消息。 + + ```python messages.append({"role": "user", "content": query}) ``` + + + + +```ts +history.push({ role: "user", content: query }); +``` + + + 2. 将消息和工具定义一起发给 LLM。 + + ```python response = client.messages.create( model=MODEL, system=SYSTEM, messages=messages, @@ -42,16 +56,53 @@ response = client.messages.create( ) ``` + + + + +```ts +const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: history, + tools: TOOLS, + max_tokens: 8000, +}); +``` + + + 3. 追加助手响应。检查 `stop_reason` -- 如果模型没有调用工具, 结束。 + + ```python messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": return ``` + + + + +```ts +history.push({ + role: "assistant", + content: response.content, +}); + +if (response.stop_reason !== "tool_use") { + return; +} +``` + + + 4. 执行每个工具调用, 收集结果, 作为 user 消息追加。回到第 2 步。 + + ```python results = [] for block in response.content: @@ -65,8 +116,28 @@ for block in response.content: messages.append({"role": "user", "content": results}) ``` + + + + +```ts +const results = response.content + .filter((block) => block.type === "tool_use") + .map((block) => ({ + type: "tool_result" as const, + tool_use_id: block.id, + content: runBash(block.input.command), + })); + +history.push({ role: "user", content: results }); +``` + + + 组装为一个完整函数: + + ```python def agent_loop(query): messages = [{"role": "user", "content": query}] @@ -92,6 +163,42 @@ def agent_loop(query): messages.append({"role": "user", "content": results}) ``` + + + + +```ts +export async function agentLoop(history: Message[]) { + while (true) { + const response = await client.messages.create({ + model: MODEL, + system: SYSTEM, + messages: history, + tools: TOOLS, + max_tokens: 8000, + }); + + history.push({ role: "assistant", content: response.content }); + + if (response.stop_reason !== "tool_use") { + return; + } + + const results = response.content + .filter((block) => block.type === "tool_use") + .map((block) => ({ + type: "tool_result" as const, + tool_use_id: block.id, + content: runBash(block.input.command), + })); + + history.push({ role: "user", content: results }); + } +} +``` + + + 不到 30 行, 这就是整个智能体。后面 11 个章节都在这个循环上叠加机制 -- 循环本身始终不变。 ## 变更内容 @@ -107,6 +214,11 @@ def agent_loop(query): ```sh cd learn-claude-code +``` + + + +```sh python agents/s01_agent_loop.py ``` @@ -116,3 +228,22 @@ python agents/s01_agent_loop.py 2. `List all Python files in this directory` 3. `What is the current git branch?` 4. `Create a directory called test_output and write 3 files in it` + + + + + +```sh +cd agents-ts +npm install +npm run s01 +``` + +试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): + +1. `Create a file called hello.ts that logs "Hello, World!"` +2. `List all TypeScript files in this directory` +3. `What is the current git branch?` +4. `Create a directory called test_output and write 3 files in it` + + diff --git a/docs/zh/s02-tool-use.md b/docs/zh/s02-tool-use.md index a26d0a190..9afd734c0 100644 --- a/docs/zh/s02-tool-use.md +++ b/docs/zh/s02-tool-use.md @@ -14,7 +14,7 @@ ## 解决方案 -``` +```text +--------+ +-------+ +------------------+ | User | ---> | LLM | ---> | Tool Dispatch | | prompt | | | | { | @@ -33,6 +33,8 @@ One lookup replaces any if/elif chain. 1. 每个工具有一个处理函数。路径沙箱防止逃逸工作区。 + + ```python def safe_path(p: str) -> Path: path = (WORKDIR / p).resolve() @@ -48,8 +50,27 @@ def run_read(path: str, limit: int = None) -> str: return "\n".join(lines)[:50000] ``` + + + + +```ts +function safePath(relativePath: string): string { + const filePath = resolve(WORKDIR, relativePath); + const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; + if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) { + throw new Error(`Path escapes workspace: ${relativePath}`); + } + return filePath; +} +``` + + + 2. dispatch map 将工具名映射到处理函数。 + + ```python TOOL_HANDLERS = { "bash": lambda **kw: run_bash(kw["command"]), @@ -60,8 +81,26 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const TOOL_HANDLERS = { + bash: (input) => runBash(String(input.command ?? "")), + read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), + write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), + edit_file: (input) => + runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")), +}; +``` + + + 3. 循环中按名称查找处理函数。循环体本身与 s01 完全一致。 + + ```python for block in response.content: if block.type == "tool_use": @@ -75,6 +114,29 @@ for block in response.content: }) ``` + + + + +```ts +for (const block of response.content) { + if (block.type !== "tool_use") continue; + + const handler = TOOL_HANDLERS[block.name as ToolUseName]; + const output = handler + ? handler(block.input as Record) + : `Unknown tool: ${block.name}`; + + results.push({ + type: "tool_result", + tool_use_id: block.id, + content: output, + }); +} +``` + + + 加工具 = 加 handler + 加 schema。循环永远不变。 ## 相对 s01 的变更 @@ -90,6 +152,11 @@ for block in response.content: ```sh cd learn-claude-code +``` + + + +```sh python agents/s02_tool_use.py ``` @@ -99,3 +166,22 @@ python agents/s02_tool_use.py 2. `Create a file called greet.py with a greet(name) function` 3. `Edit greet.py to add a docstring to the function` 4. `Read greet.py to verify the edit worked` + + + + + +```sh +cd agents-ts +npm install +npm run s02 +``` + +试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): + +1. `Read the file package.json` +2. `Create a file called greet.ts with a greet(name: string) function` +3. `Edit greet.ts to add a JSDoc comment` +4. `Read greet.ts to verify the edit worked` + + diff --git a/docs/zh/s03-todo-write.md b/docs/zh/s03-todo-write.md index e593233a6..f3ba3ae35 100644 --- a/docs/zh/s03-todo-write.md +++ b/docs/zh/s03-todo-write.md @@ -12,7 +12,7 @@ ## 解决方案 -``` +```text +--------+ +-------+ +---------+ | User | ---> | LLM | ---> | Tools | | prompt | | | | + todo | @@ -36,6 +36,8 @@ 1. TodoManager 存储带状态的项目。同一时间只允许一个 `in_progress`。 + + ```python class TodoManager: def update(self, items: list) -> str: @@ -52,8 +54,46 @@ class TodoManager: return self.render() ``` + + + + +```ts +class TodoManager { + private items: TodoItem[] = []; + + update(items: unknown): string { + if (!Array.isArray(items)) { + throw new Error("items must be an array"); + } + + let inProgressCount = 0; + const validated = items.map((item, index) => { + const record = (item ?? {}) as Record; + const text = String(record.text ?? "").trim(); + const status = String(record.status ?? "pending").toLowerCase() as TodoStatus; + const id = String(record.id ?? index + 1); + + if (status === "in_progress") inProgressCount += 1; + return { id, text, status }; + }); + + if (inProgressCount > 1) { + throw new Error("Only one task can be in_progress at a time"); + } + + this.items = validated; + return this.render(); + } +} +``` + + + 2. `todo` 工具和其他工具一样加入 dispatch map。 + + ```python TOOL_HANDLERS = { # ...base tools... @@ -61,8 +101,23 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const TOOL_HANDLERS = { + // ...base tools... + todo: (input) => TODO.update(input.items), +}; +``` + + + 3. nag reminder: 模型连续 3 轮以上不调用 `todo` 时注入提醒。 + + ```python if rounds_since_todo >= 3 and messages: last = messages[-1] @@ -73,6 +128,21 @@ if rounds_since_todo >= 3 and messages: }) ``` + + + + +```ts +if (roundsSinceTodo >= 3) { + results.unshift({ + type: "text", + text: "Update your todos.", + }); +} +``` + + + "同时只能有一个 in_progress" 强制顺序聚焦。nag reminder 制造问责压力 -- 你不更新计划, 系统就追着你问。 ## 相对 s02 的变更 @@ -88,6 +158,11 @@ if rounds_since_todo >= 3 and messages: ```sh cd learn-claude-code +``` + + + +```sh python agents/s03_todo_write.py ``` @@ -96,3 +171,21 @@ python agents/s03_todo_write.py 1. `Refactor the file hello.py: add type hints, docstrings, and a main guard` 2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py` 3. `Review all Python files and fix any style issues` + + + + + +```sh +cd agents-ts +npm install +npm run s03 +``` + +试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): + +1. `Refactor the file hello.ts: add type annotations, comments, and a small CLI entry` +2. `Create a TypeScript package with index.ts, utils.ts, and tests/utils.test.ts` +3. `Review all TypeScript files and fix any style issues` + + diff --git a/docs/zh/s04-subagent.md b/docs/zh/s04-subagent.md index 5bff765dc..306b3cb06 100644 --- a/docs/zh/s04-subagent.md +++ b/docs/zh/s04-subagent.md @@ -12,7 +12,7 @@ ## 解决方案 -``` +```text Parent agent Subagent +------------------+ +------------------+ | messages=[...] | | messages=[] | <-- fresh @@ -30,6 +30,8 @@ Parent context stays clean. Subagent context is discarded. 1. 父智能体有一个 `task` 工具。子智能体拥有除 `task` 外的所有基础工具 (禁止递归生成)。 + + ```python PARENT_TOOLS = CHILD_TOOLS + [ {"name": "task", @@ -42,8 +44,34 @@ PARENT_TOOLS = CHILD_TOOLS + [ ] ``` + + + + +```ts +const PARENT_TOOLS = [ + ...CHILD_TOOLS, + { + name: "task", + description: "Spawn a subagent with fresh context.", + input_schema: { + type: "object", + properties: { + prompt: { type: "string" }, + description: { type: "string" }, + }, + required: ["prompt"], + }, + }, +]; +``` + + + 2. 子智能体以 `messages=[]` 启动, 运行自己的循环。只有最终文本返回给父智能体。 + + ```python def run_subagent(prompt: str) -> str: sub_messages = [{"role": "user", "content": prompt}] @@ -71,6 +99,52 @@ def run_subagent(prompt: str) -> str: ) or "(no summary)" ``` + + + + +```ts +async function runSubagent(prompt: string): Promise { + const subMessages: Message[] = [{ role: "user", content: prompt }]; + + for (let attempt = 0; attempt < 30; attempt += 1) { + const response = await client.messages.create({ + model: MODEL, + system: SUBAGENT_SYSTEM, + messages: subMessages, + tools: CHILD_TOOLS, + max_tokens: 8000, + }); + + subMessages.push({ role: "assistant", content: response.content }); + if (response.stop_reason !== "tool_use") { + return response.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("") || "(no summary)"; + } + + const results = []; + for (const block of response.content) { + if (block.type !== "tool_use") continue; + const handler = TOOL_HANDLERS[block.name as ToolUseName]; + const output = handler(block.input as Record); + results.push({ + type: "tool_result" as const, + tool_use_id: block.id, + content: String(output).slice(0, 50000), + }); + } + + subMessages.push({ role: "user", content: results }); + } + + return "(no summary)"; +} +``` + + + 子智能体可能跑了 30+ 次工具调用, 但整个消息历史直接丢弃。父智能体收到的只是一段摘要文本, 作为普通 `tool_result` 返回。 ## 相对 s03 的变更 @@ -86,6 +160,11 @@ def run_subagent(prompt: str) -> str: ```sh cd learn-claude-code +``` + + + +```sh python agents/s04_subagent.py ``` @@ -94,3 +173,21 @@ python agents/s04_subagent.py 1. `Use a subtask to find what testing framework this project uses` 2. `Delegate: read all .py files and summarize what each one does` 3. `Use a task to create a new module, then verify it from here` + + + + + +```sh +cd agents-ts +npm install +npm run s04 +``` + +试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): + +1. `Use a subtask to find what testing framework this project uses` +2. `Delegate: read all .ts files and summarize what each one does` +3. `Use a task to create a new module, then verify it from here` + + diff --git a/docs/zh/s05-skill-loading.md b/docs/zh/s05-skill-loading.md index 09e2ab10a..fdc382eb9 100644 --- a/docs/zh/s05-skill-loading.md +++ b/docs/zh/s05-skill-loading.md @@ -45,7 +45,9 @@ skills/ SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ... ``` -2. SkillLoader 递归扫描 `SKILL.md` 文件, 用目录名作为技能标识。 +2. SkillLoader 递归扫描 `SKILL.md` 文件, 并用目录名作为兜底技能标识。 + + ```python class SkillLoader: @@ -71,8 +73,44 @@ class SkillLoader: return f"\n{skill['body']}\n" ``` + + + + +```ts +class SkillLoader { + skills: Record = {}; + + constructor(private skillsDir: string) { + this.loadAll(); + } + + private loadAll() { + for (const filePath of collectSkillFiles(this.skillsDir)) { + const text = readFileSync(filePath, "utf8"); + const { meta, body } = parseFrontmatter(text); + const fallbackName = filePath.replace(/\\/g, "/").split("/").slice(-2, -1)[0] ?? "unknown"; + const name = meta.name || fallbackName; + this.skills[name] = { meta, body, path: filePath }; + } + } + + getContent(name: string): string { + const skill = this.skills[name]; + if (!skill) { + return `Error: Unknown skill '${name}'.`; + } + return `\n${skill.body}\n`; + } +} +``` + + + 3. 第一层写入系统提示。第二层不过是 dispatch map 中的又一个工具。 + + ```python SYSTEM = f"""You are a coding agent at {WORKDIR}. Skills available: @@ -84,6 +122,25 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const SYSTEM = `You are a coding agent at ${WORKDIR}. +Use load_skill to access specialized knowledge before tackling unfamiliar topics. + +Skills available: +${skillLoader.getDescriptions()}`; + +const TOOL_HANDLERS = { + // ...base tools... + load_skill: (input) => skillLoader.getContent(String(input.name ?? "")), +}; +``` + + + 模型知道有哪些技能 (便宜), 需要时再加载完整内容 (贵)。 ## 相对 s04 的变更 @@ -99,12 +156,35 @@ TOOL_HANDLERS = { ```sh cd learn-claude-code +``` + +试试这些 prompt: + + + +```sh python agents/s05_skill_loading.py ``` -试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): +1. `What skills are available?` +2. `Load the agent-builder skill and follow its instructions` +3. `I need to do a code review -- load the relevant skill first` +4. `Build an MCP server using the mcp-builder skill` + + + + + +```sh +cd agents-ts +npm install +npm run s05 +``` 1. `What skills are available?` 2. `Load the agent-builder skill and follow its instructions` 3. `I need to do a code review -- load the relevant skill first` 4. `Build an MCP server using the mcp-builder skill` + + + diff --git a/docs/zh/s06-context-compact.md b/docs/zh/s06-context-compact.md index c9d2a2b74..15de87b7e 100644 --- a/docs/zh/s06-context-compact.md +++ b/docs/zh/s06-context-compact.md @@ -46,6 +46,8 @@ continue [Layer 2: auto_compact] 1. **第一层 -- micro_compact**: 每次 LLM 调用前, 将旧的 tool result 替换为占位符。 + + ```python def micro_compact(messages: list) -> list: tool_results = [] @@ -62,8 +64,41 @@ def micro_compact(messages: list) -> list: return messages ``` + + + + +```ts +function microCompact(messages: Message[]): Message[] { + const toolResults: ToolResultBlock[] = []; + + for (const message of messages) { + if (message.role !== "user" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (isToolResultBlock(part)) { + toolResults.push(part); + } + } + } + + if (toolResults.length <= KEEP_RECENT) { + return messages; + } + + for (const result of toolResults.slice(0, -KEEP_RECENT)) { + result.content = `[Previous: used ${toolName}]`; + } + + return messages; +} +``` + + + 2. **第二层 -- auto_compact**: token 超过阈值时, 保存完整对话到磁盘, 让 LLM 做摘要。 + + ```python def auto_compact(messages: list) -> list: # Save transcript for recovery @@ -85,10 +120,42 @@ def auto_compact(messages: list) -> list: ] ``` + + + + +```ts +async function autoCompact(messages: Message[]): Promise { + const transcriptPath = resolve(TRANSCRIPT_DIR, `transcript_${Date.now()}.jsonl`); + for (const message of messages) { + appendFileSync(transcriptPath, `${JSON.stringify(message)}\n`, "utf8"); + } + + const response = await client.messages.create({ + model: MODEL, + messages: [{ + role: "user", + content: "Summarize this conversation for continuity...\n\n" + + JSON.stringify(messages).slice(0, 80_000), + }], + max_tokens: 2000, + }); + + return [ + { role: "user", content: `[Conversation compressed. Transcript: ${transcriptPath}]` }, + { role: "assistant", content: "Understood. I have the context from the summary. Continuing." }, + ]; +} +``` + + + 3. **第三层 -- manual compact**: `compact` 工具按需触发同样的摘要机制。 4. 循环整合三层: + + ```python def agent_loop(messages: list): while True: @@ -101,6 +168,31 @@ def agent_loop(messages: list): messages[:] = auto_compact(messages) # Layer 3 ``` + + + + +```ts +export async function agentLoop(messages: Message[]) { + while (true) { + microCompact(messages); + + if (estimateTokens(messages) > THRESHOLD) { + messages.splice(0, messages.length, ...(await autoCompact(messages))); + } + + const response = await client.messages.create(...); + // ... tool execution ... + + if (manualCompact) { + messages.splice(0, messages.length, ...(await autoCompact(messages))); + } + } +} +``` + + + 完整历史通过 transcript 保存在磁盘上。信息没有真正丢失, 只是移出了活跃上下文。 ## 相对 s05 的变更 @@ -117,11 +209,33 @@ def agent_loop(messages: list): ```sh cd learn-claude-code -python agents/s06_context_compact.py ``` -试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): +试试这些 prompt: + + + +```sh +python agents/s06_context_compact.py +``` 1. `Read every Python file in the agents/ directory one by one` (观察 micro-compact 替换旧结果) 2. `Keep reading files until compression triggers automatically` 3. `Use the compact tool to manually compress the conversation` + + + + + +```sh +cd agents-ts +npm install +npm run s06 +``` + +1. `Read every TypeScript file in the agents-ts directory one by one` (观察 micro-compact 替换旧结果) +2. `Keep reading files until compression triggers automatically` +3. `Use the compact tool to manually compress the conversation` + + + diff --git a/docs/zh/s07-task-system.md b/docs/zh/s07-task-system.md index 81ce309bb..e7287f7d6 100644 --- a/docs/zh/s07-task-system.md +++ b/docs/zh/s07-task-system.md @@ -50,6 +50,8 @@ s03 的 TodoManager 只是内存中的扁平清单: 没有顺序、没有依赖 1. **TaskManager**: 每个任务一个 JSON 文件, CRUD + 依赖图。 + + ```python class TaskManager: def __init__(self, tasks_dir: Path): @@ -66,8 +68,35 @@ class TaskManager: return json.dumps(task, indent=2) ``` + + + + +```ts +class TaskManager { + create(subject: string, description = "") { + const task = { + id: this.nextId, + subject, + description, + status: "pending", + blockedBy: [], + blocks: [], + owner: "", + }; + this.save(task); + this.nextId += 1; + return JSON.stringify(task, null, 2); + } +} +``` + + + 2. **依赖解除**: 完成任务时, 自动将其 ID 从其他任务的 `blockedBy` 中移除, 解锁后续任务。 + + ```python def _clear_dependency(self, completed_id): for f in self.dir.glob("task_*.json"): @@ -77,8 +106,27 @@ def _clear_dependency(self, completed_id): self._save(task) ``` + + + + +```ts +private clearDependency(completedId: number) { + for (const task of this.loadAll()) { + if (task.blockedBy.includes(completedId)) { + task.blockedBy = task.blockedBy.filter((id) => id !== completedId); + this.save(task); + } + } +} +``` + + + 3. **状态变更 + 依赖关联**: `update` 处理状态转换和依赖边。 + + ```python def update(self, task_id, status=None, add_blocked_by=None, add_blocks=None): @@ -90,8 +138,27 @@ def update(self, task_id, status=None, self._save(task) ``` + + + + +```ts +update(taskId: number, status?: string, addBlockedBy?: number[], addBlocks?: number[]) { + const task = this.load(taskId); + if (status === "completed") { + task.status = "completed"; + this.clearDependency(taskId); + } + this.save(task); +} +``` + + + 4. 四个任务工具加入 dispatch map。 + + ```python TOOL_HANDLERS = { # ...base tools... @@ -102,6 +169,21 @@ TOOL_HANDLERS = { } ``` + + + + +```ts +const TOOL_HANDLERS = { + task_create: (input) => TASKS.create(String(input.subject ?? "")), + task_update: (input) => TASKS.update(Number(input.task_id ?? 0), input.status as string), + task_list: () => TASKS.listAll(), + task_get: (input) => TASKS.get(Number(input.task_id ?? 0)), +}; +``` + + + 从 s07 起, 任务图是多步工作的默认选择。s03 的 Todo 仍可用于单次会话内的快速清单。 ## 相对 s06 的变更 @@ -118,12 +200,30 @@ TOOL_HANDLERS = { ```sh cd learn-claude-code +``` + + + +```sh python agents/s07_task_system.py ``` -试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): + + + + +```sh +cd agents-ts +npm install +npm run s07 +``` + + + +试试这些 prompt: 1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.` 2. `List all tasks and show the dependency graph` 3. `Complete task 1 and then list tasks to see task 2 unblocked` 4. `Create a task board for refactoring: parse -> transform -> emit -> test, where transform and emit can run in parallel after parse` + diff --git a/docs/zh/s08-background-tasks.md b/docs/zh/s08-background-tasks.md index 418fabc06..342955f43 100644 --- a/docs/zh/s08-background-tasks.md +++ b/docs/zh/s08-background-tasks.md @@ -34,6 +34,8 @@ Agent --[spawn A]--[spawn B]--[other work]---- 1. BackgroundManager 用线程安全的通知队列追踪任务。 + + ```python class BackgroundManager: def __init__(self): @@ -42,8 +44,23 @@ class BackgroundManager: self._lock = threading.Lock() ``` + + + + +```ts +class BackgroundManager { + tasks: Record = {}; + private notificationQueue: Array<{ task_id: string; status: string; result: string }> = []; +} +``` + + + 2. `run()` 启动守护线程, 立即返回。 + + ```python def run(self, command: str) -> str: task_id = str(uuid.uuid4())[:8] @@ -54,8 +71,25 @@ def run(self, command: str) -> str: return f"Background task {task_id} started" ``` + + + + +```ts +run(command: string) { + const taskId = randomUUID().slice(0, 8); + this.tasks[taskId] = { status: "running", result: null, command }; + const child = spawn(shell, args, { cwd: WORKDIR }); + return `Background task ${taskId} started`; +} +``` + + + 3. 子进程完成后, 结果进入通知队列。 + + ```python def _execute(self, task_id, command): try: @@ -69,8 +103,26 @@ def _execute(self, task_id, command): "task_id": task_id, "result": output[:500]}) ``` + + + + +```ts +child.on("close", () => { + this.notificationQueue.push({ + task_id: taskId, + status: "completed", + result: result.slice(0, 500), + }); +}); +``` + + + 4. 每次 LLM 调用前排空通知队列。 + + ```python def agent_loop(messages: list): while True: @@ -86,6 +138,22 @@ def agent_loop(messages: list): response = client.messages.create(...) ``` + + + + +```ts +const notifications = BG.drainNotifications(); +if (notifications.length) { + messages.push({ + role: "user", + content: `\n${notifText}\n`, + }); +} +``` + + + 循环保持单线程。只有子进程 I/O 被并行化。 ## 相对 s07 的变更 @@ -101,11 +169,29 @@ def agent_loop(messages: list): ```sh cd learn-claude-code +``` + + + +```sh python agents/s08_background_tasks.py ``` -试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): + + + + +```sh +cd agents-ts +npm install +npm run s08 +``` + + + +试试这些 prompt: 1. `Run "sleep 5 && echo done" in the background, then create a file while it runs` 2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.` 3. `Run pytest in the background and keep working on other things` + diff --git a/docs/zh/s09-agent-teams.md b/docs/zh/s09-agent-teams.md index f42065145..12cc875b7 100644 --- a/docs/zh/s09-agent-teams.md +++ b/docs/zh/s09-agent-teams.md @@ -39,6 +39,8 @@ Communication: 1. TeammateManager 通过 config.json 维护团队名册。 + + ```python class TeammateManager: def __init__(self, team_dir: Path): @@ -49,8 +51,23 @@ class TeammateManager: self.threads = {} ``` + + + + +```ts +class TeammateManager { + private configPath = resolve(teamDir, "config.json"); + private config: TeamConfig = this.loadConfig(); +} +``` + + + 2. `spawn()` 创建队友并在线程中启动 agent loop。 + + ```python def spawn(self, name: str, role: str, prompt: str) -> str: member = {"name": name, "role": role, "status": "working"} @@ -63,8 +80,25 @@ def spawn(self, name: str, role: str, prompt: str) -> str: return f"Spawned teammate '{name}' (role: {role})" ``` + + + + +```ts +spawn(name: string, role: string, prompt: string) { + this.config.members.push({ name, role, status: "working" }); + this.saveConfig(); + void this.teammateLoop(name, role, prompt); + return `Spawned '${name}' (role: ${role})`; +} +``` + + + 3. MessageBus: append-only 的 JSONL 收件箱。`send()` 追加一行; `read_inbox()` 读取全部并清空。 + + ```python class MessageBus: def send(self, sender, to, content, msg_type="message", extra=None): @@ -83,8 +117,30 @@ class MessageBus: return json.dumps(msgs, indent=2) ``` + + + + +```ts +class MessageBus { + send(sender: string, to: string, content: string) { + appendFileSync(resolve(this.inboxDir, `${to}.jsonl`), `${JSON.stringify({ from: sender, content })}\n`); + } + + readInbox(name: string) { + const lines = readFileSync(resolve(this.inboxDir, `${name}.jsonl`), "utf8").split(/\r?\n/).filter(Boolean); + writeFileSync(resolve(this.inboxDir, `${name}.jsonl`), "", "utf8"); + return lines.map((line) => JSON.parse(line)); + } +} +``` + + + 4. 每个队友在每次 LLM 调用前检查收件箱, 将消息注入上下文。 + + ```python def _teammate_loop(self, name, role, prompt): messages = [{"role": "user", "content": prompt}] @@ -102,6 +158,19 @@ def _teammate_loop(self, name, role, prompt): self._find_member(name)["status"] = "idle" ``` + + + + +```ts +for (const message of BUS.readInbox(name)) { + messages.push({ role: "user", content: JSON.stringify(message) }); +} +const response = await client.messages.create(...); +``` + + + ## 相对 s08 的变更 | 组件 | 之前 (s08) | 之后 (s09) | @@ -117,13 +186,31 @@ def _teammate_loop(self, name, role, prompt): ```sh cd learn-claude-code +``` + + + +```sh python agents/s09_agent_teams.py ``` -试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): + + + + +```sh +cd agents-ts +npm install +npm run s09 +``` + + + +试试这些 prompt: 1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.` 2. `Broadcast "status update: phase 1 complete" to all teammates` 3. `Check the lead inbox for any messages` 4. 输入 `/team` 查看团队名册和状态 5. 输入 `/inbox` 手动检查领导的收件箱 + diff --git a/docs/zh/s10-team-protocols.md b/docs/zh/s10-team-protocols.md index a57c926b7..3802826a8 100644 --- a/docs/zh/s10-team-protocols.md +++ b/docs/zh/s10-team-protocols.md @@ -44,6 +44,8 @@ Trackers: 1. 领导生成 request_id, 通过收件箱发起关机请求。 + + ```python shutdown_requests = {} @@ -55,8 +57,24 @@ def handle_shutdown_request(teammate: str) -> str: return f"Shutdown request {req_id} sent (status: pending)" ``` + + + + +```ts +function handleShutdownRequest(teammate: string) { + const requestId = randomUUID().slice(0, 8); + shutdownRequests[requestId] = { target: teammate, status: "pending" }; + BUS.send("lead", teammate, "Please shut down gracefully.", "shutdown_request", { request_id: requestId }); +} +``` + + + 2. 队友收到请求后, 用 approve/reject 响应。 + + ```python if tool_name == "shutdown_response": req_id = args["request_id"] @@ -67,8 +85,26 @@ if tool_name == "shutdown_response": {"request_id": req_id, "approve": approve}) ``` + + + + +```ts +if (toolName === "shutdown_response") { + shutdownRequests[requestId].status = input.approve ? "approved" : "rejected"; + BUS.send(sender, "lead", String(input.reason ?? ""), "shutdown_response", { + request_id: requestId, + approve: Boolean(input.approve), + }); +} +``` + + + 3. 计划审批遵循完全相同的模式。队友提交计划 (生成 request_id), 领导审查 (引用同一个 request_id)。 + + ```python plan_requests = {} @@ -80,6 +116,23 @@ def handle_plan_review(request_id, approve, feedback=""): {"request_id": request_id, "approve": approve}) ``` + + + + +```ts +function handlePlanReview(requestId: string, approve: boolean, feedback = "") { + const request = planRequests[requestId]; + request.status = approve ? "approved" : "rejected"; + BUS.send("lead", request.from, feedback, "plan_approval_response", { + request_id: requestId, + approve, + }); +} +``` + + + 一个 FSM, 两种用途。同样的 `pending -> approved | rejected` 状态机可以套用到任何请求-响应协议上。 ## 相对 s09 的变更 @@ -96,13 +149,31 @@ def handle_plan_review(request_id, approve, feedback=""): ```sh cd learn-claude-code +``` + + + +```sh python agents/s10_team_protocols.py ``` -试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): + + + + +```sh +cd agents-ts +npm install +npm run s10 +``` + + + +试试这些 prompt: 1. `Spawn alice as a coder. Then request her shutdown.` 2. `List teammates to see alice's status after shutdown approval` 3. `Spawn bob with a risky refactoring task. Review and reject his plan.` 4. `Spawn charlie, have him submit a plan, then approve it.` 5. 输入 `/team` 监控状态 + diff --git a/docs/zh/s11-autonomous-agents.md b/docs/zh/s11-autonomous-agents.md index ba34ca150..7fb866aab 100644 --- a/docs/zh/s11-autonomous-agents.md +++ b/docs/zh/s11-autonomous-agents.md @@ -49,6 +49,8 @@ Identity re-injection after compression: 1. 队友循环分两个阶段: WORK 和 IDLE。LLM 停止调用工具 (或调用了 `idle`) 时, 进入 IDLE。 + + ```python def _loop(self, name, role, prompt): while True: @@ -71,8 +73,27 @@ def _loop(self, name, role, prompt): self._set_status(name, "working") ``` + + + + +```ts +async loop(name: string, role: string, prompt: string) { + while (true) { + // WORK phase + if (idleRequested) break; + // IDLE phase + if (!resume) return; + } +} +``` + + + 2. 空闲阶段循环轮询收件箱和任务看板。 + + ```python def _idle_poll(self, name, messages): for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12 @@ -92,8 +113,26 @@ def _idle_poll(self, name, messages): return False # timeout -> shutdown ``` + + + + +```ts +while (Date.now() - start < IDLE_TIMEOUT) { + await sleep(POLL_INTERVAL); + const inbox = BUS.readInbox(name); + const unclaimed = scanUnclaimedTasks(); + if (inbox.length || unclaimed.length) return true; +} +return false; +``` + + + 3. 任务看板扫描: 找 pending 状态、无 owner、未被阻塞的任务。 + + ```python def scan_unclaimed_tasks() -> list: unclaimed = [] @@ -106,8 +145,24 @@ def scan_unclaimed_tasks() -> list: return unclaimed ``` + + + + +```ts +function scanUnclaimedTasks() { + return loadTasks().filter((task) => + task.status === "pending" && !task.owner && !(task.blockedBy?.length), + ); +} +``` + + + 4. 身份重注入: 上下文过短 (说明发生了压缩) 时, 在开头插入身份块。 + + ```python if len(messages) <= 3: messages.insert(0, {"role": "user", @@ -117,6 +172,19 @@ if len(messages) <= 3: "content": f"I am {name}. Continuing."}) ``` + + + + +```ts +if (messages.length <= 3) { + messages.unshift({ role: "assistant", content: `I am ${name}. Continuing.` }); + messages.unshift(makeIdentityBlock(name, role, teamName)); +} +``` + + + ## 相对 s10 的变更 | 组件 | 之前 (s10) | 之后 (s11) | @@ -132,13 +200,31 @@ if len(messages) <= 3: ```sh cd learn-claude-code +``` + + + +```sh python agents/s11_autonomous_agents.py ``` -试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): + + + + +```sh +cd agents-ts +npm install +npm run s11 +``` + + + +试试这些 prompt: 1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.` 2. `Spawn a coder teammate and let it find work from the task board itself` 3. `Create tasks with dependencies. Watch teammates respect the blocked order.` 4. 输入 `/tasks` 查看带 owner 的任务看板 5. 输入 `/team` 监控谁在工作、谁在空闲 + diff --git a/docs/zh/s12-worktree-task-isolation.md b/docs/zh/s12-worktree-task-isolation.md index dbfaa96dd..8eb18528b 100644 --- a/docs/zh/s12-worktree-task-isolation.md +++ b/docs/zh/s12-worktree-task-isolation.md @@ -38,21 +38,49 @@ State machines: 1. **创建任务。** 先把目标持久化。 + + ```python TASKS.create("Implement auth refactor") # -> .tasks/task_1.json status=pending worktree="" ``` + + + + +```ts +TASKS.create("Implement auth refactor"); +// -> .tasks/task_1.json status=pending worktree="" +``` + + + 2. **创建 worktree 并绑定任务。** 传入 `task_id` 自动将任务推进到 `in_progress`。 + + ```python WORKTREES.create("auth-refactor", task_id=1) # -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD # -> index.json gets new entry, task_1.json gets worktree="auth-refactor" ``` + + + + +```ts +WORKTREES.create("auth-refactor", 1); +// -> index.json gets new entry, task_1.json gets worktree="auth-refactor" +``` + + + 绑定同时写入两侧状态: + + ```python def bind_worktree(self, task_id, worktree): task = self._load(task_id) @@ -62,17 +90,46 @@ def bind_worktree(self, task_id, worktree): self._save(task) ``` + + + + +```ts +bindWorktree(taskId: number, worktree: string) { + const task = this.load(taskId); + task.worktree = worktree; + if (task.status === "pending") task.status = "in_progress"; + this.save(task); +} +``` + + + 3. **在 worktree 中执行命令。** `cwd` 指向隔离目录。 + + ```python subprocess.run(command, shell=True, cwd=worktree_path, capture_output=True, text=True, timeout=300) ``` + + + + +```ts +runCommand(command, worktree.path, 300_000); +``` + + + 4. **收尾。** 两种选择: - `worktree_keep(name)` -- 保留目录供后续使用。 - `worktree_remove(name, complete_task=True)` -- 删除目录, 完成绑定任务, 发出事件。一个调用搞定拆除 + 完成。 + + ```python def remove(self, name, force=False, complete_task=False): self._run_git(["worktree", "remove", wt["path"]]) @@ -82,6 +139,21 @@ def remove(self, name, force=False, complete_task=False): self.events.emit("task.completed", ...) ``` + + + + +```ts +remove(name: string, force = false, completeTask = false) { + if (completeTask && worktree.task_id) { + this.tasks.update(worktree.task_id, "completed"); + this.tasks.unbindWorktree(worktree.task_id); + } +} +``` + + + 5. **事件流。** 每个生命周期步骤写入 `.worktrees/events.jsonl`: ```json @@ -111,13 +183,31 @@ def remove(self, name, force=False, complete_task=False): ```sh cd learn-claude-code +``` + + + +```sh python agents/s12_worktree_task_isolation.py ``` -试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文): + + + + +```sh +cd agents-ts +npm install +npm run s12 +``` + + + +试试这些 prompt: 1. `Create tasks for backend auth and frontend login page, then list tasks.` 2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".` 3. `Run "git status --short" in worktree "auth-refactor".` 4. `Keep worktree "ui-login", then list worktrees and inspect events.` 5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.` + diff --git a/web/package.json b/web/package.json index 984b6028a..c5a5ce878 100644 --- a/web/package.json +++ b/web/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "extract": "tsx scripts/extract-content.ts", + "test": "node --import tsx --test scripts/*.test.ts src/lib/*.test.ts", "predev": "npm run extract", "dev": "next dev", "prebuild": "npm run extract", diff --git a/web/scripts/extract-content.test.ts b/web/scripts/extract-content.test.ts new file mode 100644 index 000000000..0e2bb2e86 --- /dev/null +++ b/web/scripts/extract-content.test.ts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { extractTools } from "./extract-content"; + +test("extractTools only returns tool definition names", () => { + const source = ` +type TeamMember = { name: string; role: string }; +type TeamConfig = { team_name: string; members: TeamMember[] }; + +const TOOLS = [ + { name: "bash", description: "Run shell commands.", input_schema: { type: "object" } }, + { name: "spawn_teammate", description: "Spawn a teammate.", input_schema: { type: "object" } }, +]; + +const fallback = { team_name: "default", members: [] }; +`; + + assert.deepEqual(extractTools(source), ["bash", "spawn_teammate"]); +}); + +test("extractTools supports python-style quoted keys without matching config names", () => { + const source = ` +TEAM_CONFIG = {"team_name": "default", "members": [{"name": "alice"}]} + +TOOLS = [ + {"name": "bash", "description": "Run a shell command.", "input_schema": {"type": "object"}}, + {"name": "read_file", "description": "Read file contents.", "input_schema": {"type": "object"}}, +] +`; + + assert.deepEqual(extractTools(source), ["bash", "read_file"]); +}); diff --git a/web/scripts/extract-content.ts b/web/scripts/extract-content.ts index 6e35badd9..ea6f214ec 100644 --- a/web/scripts/extract-content.ts +++ b/web/scripts/extract-content.ts @@ -1,281 +1,423 @@ import * as fs from "fs"; import * as path from "path"; +import { pathToFileURL } from "url"; import type { AgentVersion, + AgentLanguage, + DocLanguage, VersionDiff, DocContent, VersionIndex, } from "../src/types/agent-data"; -import { VERSION_META, VERSION_ORDER, LEARNING_PATH } from "../src/lib/constants"; +import { + VERSION_META, + VERSION_ORDER, + LEARNING_PATH, + LEARNING_LANGUAGES, +} from "../src/lib/constants"; -// Resolve paths relative to this script's location (web/scripts/) const WEB_DIR = path.resolve(__dirname, ".."); const REPO_ROOT = path.resolve(WEB_DIR, ".."); const AGENTS_DIR = path.join(REPO_ROOT, "agents"); +const TS_AGENTS_DIR = path.join(REPO_ROOT, "agents-ts"); const DOCS_DIR = path.join(REPO_ROOT, "docs"); const OUT_DIR = path.join(WEB_DIR, "src", "data", "generated"); -// Map python filenames to version IDs -// s01_agent_loop.py -> s01 -// s02_tools.py -> s02 -// s_full.py -> s_full (reference agent, typically skipped) +function normalizeLineEndings(content: string): string { + return content.replace(/\r\n?/g, "\n"); +} + function filenameToVersionId(filename: string): string | null { - const base = path.basename(filename, ".py"); + const base = path.basename(filename, path.extname(filename)); if (base === "s_full") return null; if (base === "__init__") return null; const match = base.match(/^(s\d+[a-c]?)_/); - if (!match) return null; - return match[1]; + return match ? match[1] : null; } -// Extract classes from Python source -function extractClasses( +function extractPythonClasses( lines: string[] ): { name: string; startLine: number; endLine: number }[] { const classes: { name: string; startLine: number; endLine: number }[] = []; const classPattern = /^class\s+(\w+)/; for (let i = 0; i < lines.length; i++) { - const m = lines[i].match(classPattern); - if (m) { - const name = m[1]; - const startLine = i + 1; - // Find end of class: next class/function at indent 0, or EOF - let endLine = lines.length; - for (let j = i + 1; j < lines.length; j++) { - if ( - lines[j].match(/^class\s/) || - lines[j].match(/^def\s/) || - (lines[j].match(/^\S/) && lines[j].trim() !== "" && !lines[j].startsWith("#") && !lines[j].startsWith("@")) - ) { - endLine = j; - break; + const match = lines[i].match(classPattern); + if (!match) continue; + + let endLine = lines.length; + for (let j = i + 1; j < lines.length; j++) { + if ( + lines[j].match(/^class\s/) || + lines[j].match(/^def\s/) || + (lines[j].match(/^\S/) && + lines[j].trim() !== "" && + !lines[j].startsWith("#") && + !lines[j].startsWith("@")) + ) { + endLine = j; + break; + } + } + + classes.push({ + name: match[1], + startLine: i + 1, + endLine, + }); + } + + return classes; +} + +function extractTypeScriptClasses( + lines: string[] +): { name: string; startLine: number; endLine: number }[] { + const classes: { name: string; startLine: number; endLine: number }[] = []; + const classPattern = /^\s*(?:export\s+)?class\s+(\w+)/; + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(classPattern); + if (!match) continue; + + let braceDepth = 0; + let started = false; + let endLine = lines.length; + + for (let j = i; j < lines.length; j++) { + const line = lines[j]; + for (const char of line) { + if (char === "{") { + braceDepth += 1; + started = true; + } else if (char === "}") { + braceDepth -= 1; + if (started && braceDepth === 0) { + endLine = j + 1; + break; + } } } - classes.push({ name, startLine, endLine }); + if (endLine !== lines.length) break; } + + classes.push({ + name: match[1], + startLine: i + 1, + endLine, + }); } + return classes; } -// Extract top-level functions from Python source -function extractFunctions( +function extractClasses( + lines: string[], + language: AgentLanguage +): { name: string; startLine: number; endLine: number }[] { + return language === "ts" + ? extractTypeScriptClasses(lines) + : extractPythonClasses(lines); +} + +function extractPythonFunctions( lines: string[] ): { name: string; signature: string; startLine: number }[] { const functions: { name: string; signature: string; startLine: number }[] = []; const funcPattern = /^def\s+(\w+)\((.*?)\)/; for (let i = 0; i < lines.length; i++) { - const m = lines[i].match(funcPattern); - if (m) { + const match = lines[i].match(funcPattern); + if (!match) continue; + + functions.push({ + name: match[1], + signature: `def ${match[1]}(${match[2]})`, + startLine: i + 1, + }); + } + + return functions; +} + +function extractTypeScriptFunctions( + lines: string[] +): { name: string; signature: string; startLine: number }[] { + const functions: { name: string; signature: string; startLine: number }[] = []; + const patterns = [ + { + pattern: /^\s*(?:export\s+)?async\s+function\s+(\w+)\((.*?)\)/, + signature: (name: string, args: string) => `async function ${name}(${args})`, + }, + { + pattern: /^\s*(?:export\s+)?function\s+(\w+)\((.*?)\)/, + signature: (name: string, args: string) => `function ${name}(${args})`, + }, + { + pattern: /^\s*const\s+(\w+)\s*=\s*(?:async\s*)?\((.*?)\)\s*=>/, + signature: (name: string, args: string) => `const ${name} = (${args}) =>`, + }, + ]; + + for (let i = 0; i < lines.length; i++) { + for (const entry of patterns) { + const match = lines[i].match(entry.pattern); + if (!match) continue; + functions.push({ - name: m[1], - signature: `def ${m[1]}(${m[2]})`, + name: match[1], + signature: entry.signature(match[1], match[2]), startLine: i + 1, }); + break; } } + return functions; } -// Extract tool names from Python source -// Looks for "name": "tool_name" patterns in dict literals -function extractTools(source: string): string[] { - const toolPattern = /"name"\s*:\s*"(\w+)"/g; +function extractFunctions( + lines: string[], + language: AgentLanguage +): { name: string; signature: string; startLine: number }[] { + return language === "ts" + ? extractTypeScriptFunctions(lines) + : extractPythonFunctions(lines); +} + +export function extractTools(source: string): string[] { + const toolPattern = + /\{\s*(?:"name"|name)\s*:\s*"([^"]+)"\s*,\s*(?:"description"|description)\s*:/gms; const tools = new Set(); - let m; - while ((m = toolPattern.exec(source)) !== null) { - tools.add(m[1]); + let match: RegExpExecArray | null; + + while ((match = toolPattern.exec(source)) !== null) { + tools.add(match[1]); } + return Array.from(tools); } -// Count non-blank, non-comment lines function countLoc(lines: string[]): number { return lines.filter((line) => { const trimmed = line.trim(); - return trimmed !== "" && !trimmed.startsWith("#"); + return trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("//"); }).length; } -// Detect locale from subdirectory path -// docs/en/s01-the-agent-loop.md -> "en" -// docs/zh/s01-the-agent-loop.md -> "zh" -// docs/ja/s01-the-agent-loop.md -> "ja" -function detectLocale(relPath: string): "en" | "zh" | "ja" { - if (relPath.startsWith("zh/") || relPath.startsWith("zh\\")) return "zh"; - if (relPath.startsWith("ja/") || relPath.startsWith("ja\\")) return "ja"; - return "en"; +function extractDocVersion(filename: string): string | null { + const match = filename.match(/^(s\d+[a-c]?)-/); + return match ? match[1] : null; } -// Extract version from doc filename (e.g., "s01-the-agent-loop.md" -> "s01") -function extractDocVersion(filename: string): string | null { - const m = filename.match(/^(s\d+[a-c]?)-/); - return m ? m[1] : null; +function getAgentFiles(): { filePath: string; filename: string; language: AgentLanguage }[] { + const files: { filePath: string; filename: string; language: AgentLanguage }[] = []; + + for (const entry of fs.readdirSync(AGENTS_DIR, { withFileTypes: true })) { + const entryPath = path.join(AGENTS_DIR, entry.name); + + if (entry.isFile() && entry.name.startsWith("s") && entry.name.endsWith(".py")) { + files.push({ filePath: entryPath, filename: entry.name, language: "python" }); + continue; + } + } + + if (fs.existsSync(TS_AGENTS_DIR)) { + for (const filename of fs.readdirSync(TS_AGENTS_DIR)) { + if (!filename.startsWith("s")) continue; + if (filename === "shared.ts") continue; + if (!filename.endsWith(".ts")) continue; + files.push({ + filePath: path.join(TS_AGENTS_DIR, filename), + filename, + language: "ts", + }); + } + } + + return files; +} + +function readDocs(): DocContent[] { + const docs: DocContent[] = []; + + if (!fs.existsSync(DOCS_DIR)) { + console.warn(` Docs directory not found: ${DOCS_DIR}`); + return docs; + } + + const localeDirs = ["en", "zh", "ja"] as const; + let totalDocFiles = 0; + + for (const locale of localeDirs) { + const localeDir = path.join(DOCS_DIR, locale); + if (!fs.existsSync(localeDir)) continue; + + for (const entry of fs.readdirSync(localeDir, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith(".md")) { + totalDocFiles += 1; + const version = extractDocVersion(entry.name); + if (!version) continue; + + const content = normalizeLineEndings(fs.readFileSync(path.join(localeDir, entry.name), "utf-8")); + const titleMatch = content.match(/^#\s+(.+)$/m); + + docs.push({ + language: "shared" as DocLanguage, + version, + locale, + title: titleMatch ? titleMatch[1] : entry.name, + content, + }); + } + + if (!entry.isDirectory()) continue; + if (!LEARNING_LANGUAGES.includes(entry.name as AgentLanguage)) continue; + + const language = entry.name as AgentLanguage; + const languageDir = path.join(localeDir, entry.name); + + for (const filename of fs.readdirSync(languageDir)) { + if (!filename.endsWith(".md")) continue; + totalDocFiles += 1; + + const version = extractDocVersion(filename); + if (!version) continue; + + const content = normalizeLineEndings(fs.readFileSync(path.join(languageDir, filename), "utf-8")); + const titleMatch = content.match(/^#\s+(.+)$/m); + + docs.push({ + language, + version, + locale, + title: titleMatch ? titleMatch[1] : filename, + content, + }); + } + } + } + + console.log(` Found ${totalDocFiles} doc files across ${localeDirs.length} locales`); + return docs; } -// Main extraction -function main() { +export function main() { console.log("Extracting content from agents and docs..."); console.log(` Repo root: ${REPO_ROOT}`); console.log(` Agents dir: ${AGENTS_DIR}`); console.log(` Docs dir: ${DOCS_DIR}`); - // Skip extraction if source directories don't exist (e.g. Vercel build). - // Pre-committed generated data will be used instead. if (!fs.existsSync(AGENTS_DIR)) { console.log(" Agents directory not found, skipping extraction."); console.log(" Using pre-committed generated data."); return; } - // 1. Read all agent files - const agentFiles = fs - .readdirSync(AGENTS_DIR) - .filter((f) => f.startsWith("s") && f.endsWith(".py")); - + const agentFiles = getAgentFiles(); console.log(` Found ${agentFiles.length} agent files`); const versions: AgentVersion[] = []; - for (const filename of agentFiles) { - const versionId = filenameToVersionId(filename); + for (const agentFile of agentFiles) { + const versionId = filenameToVersionId(agentFile.filename); if (!versionId) { - console.warn(` Skipping ${filename}: could not determine version ID`); + console.warn(` Skipping ${agentFile.filename}: could not determine version ID`); continue; } - const filePath = path.join(AGENTS_DIR, filename); - const source = fs.readFileSync(filePath, "utf-8"); + const source = normalizeLineEndings(fs.readFileSync(agentFile.filePath, "utf-8")); const lines = source.split("\n"); - const meta = VERSION_META[versionId]; - const classes = extractClasses(lines); - const functions = extractFunctions(lines); - const tools = extractTools(source); - const loc = countLoc(lines); versions.push({ + language: agentFile.language, id: versionId, - filename, + filename: agentFile.filename, title: meta?.title ?? versionId, subtitle: meta?.subtitle ?? "", - loc, - tools, - newTools: [], // computed after all versions are loaded + loc: countLoc(lines), + tools: extractTools(source), + newTools: [], coreAddition: meta?.coreAddition ?? "", keyInsight: meta?.keyInsight ?? "", - classes, - functions, + classes: extractClasses(lines, agentFile.language), + functions: extractFunctions(lines, agentFile.language), layer: meta?.layer ?? "tools", source, }); } - // Sort versions according to VERSION_ORDER - const orderMap = new Map(VERSION_ORDER.map((v, i) => [v, i])); + const languageOrder = new Map(LEARNING_LANGUAGES.map((language, index) => [language, index])); + const versionOrder = new Map(VERSION_ORDER.map((version, index) => [version, index])); + versions.sort( - (a, b) => (orderMap.get(a.id as any) ?? 99) - (orderMap.get(b.id as any) ?? 99) + (a, b) => + (languageOrder.get(a.language) ?? 99) - (languageOrder.get(b.language) ?? 99) || + (versionOrder.get(a.id as (typeof VERSION_ORDER)[number]) ?? 99) - + (versionOrder.get(b.id as (typeof VERSION_ORDER)[number]) ?? 99) ); - // 2. Compute newTools for each version - for (let i = 0; i < versions.length; i++) { - const prev = i > 0 ? new Set(versions[i - 1].tools) : new Set(); - versions[i].newTools = versions[i].tools.filter((t) => !prev.has(t)); + for (const language of LEARNING_LANGUAGES) { + const scopedVersions = versions.filter((version) => version.language === language); + for (let i = 0; i < scopedVersions.length; i++) { + const prevTools = i > 0 ? new Set(scopedVersions[i - 1].tools) : new Set(); + scopedVersions[i].newTools = scopedVersions[i].tools.filter((tool) => !prevTools.has(tool)); + } } - // 3. Compute diffs between adjacent versions in LEARNING_PATH const diffs: VersionDiff[] = []; - const versionMap = new Map(versions.map((v) => [v.id, v])); - - for (let i = 1; i < LEARNING_PATH.length; i++) { - const fromId = LEARNING_PATH[i - 1]; - const toId = LEARNING_PATH[i]; - const fromVer = versionMap.get(fromId); - const toVer = versionMap.get(toId); - - if (!fromVer || !toVer) continue; - - const fromClassNames = new Set(fromVer.classes.map((c) => c.name)); - const fromFuncNames = new Set(fromVer.functions.map((f) => f.name)); - const fromToolNames = new Set(fromVer.tools); - - diffs.push({ - from: fromId, - to: toId, - newClasses: toVer.classes - .map((c) => c.name) - .filter((n) => !fromClassNames.has(n)), - newFunctions: toVer.functions - .map((f) => f.name) - .filter((n) => !fromFuncNames.has(n)), - newTools: toVer.tools.filter((t) => !fromToolNames.has(t)), - locDelta: toVer.loc - fromVer.loc, - }); - } - - // 4. Read doc files from locale subdirectories (en/, zh/, ja/) - const docs: DocContent[] = []; - - if (fs.existsSync(DOCS_DIR)) { - const localeDirs = ["en", "zh", "ja"]; - let totalDocFiles = 0; - - for (const locale of localeDirs) { - const localeDir = path.join(DOCS_DIR, locale); - if (!fs.existsSync(localeDir)) continue; - - const docFiles = fs - .readdirSync(localeDir) - .filter((f) => f.endsWith(".md")); - - totalDocFiles += docFiles.length; - - for (const filename of docFiles) { - const version = extractDocVersion(filename); - if (!version) { - console.warn(` Skipping doc ${locale}/${filename}: could not determine version`); - continue; - } - - const filePath = path.join(localeDir, filename); - const content = fs.readFileSync(filePath, "utf-8"); - - const titleMatch = content.match(/^#\s+(.+)$/m); - const title = titleMatch ? titleMatch[1] : filename; - - docs.push({ version, locale: locale as "en" | "zh" | "ja", title, content }); - } + const versionMap = new Map(versions.map((version) => [`${version.language}:${version.id}`, version])); + + for (const language of LEARNING_LANGUAGES) { + for (let i = 1; i < LEARNING_PATH.length; i++) { + const fromId = LEARNING_PATH[i - 1]; + const toId = LEARNING_PATH[i]; + const fromVer = versionMap.get(`${language}:${fromId}`); + const toVer = versionMap.get(`${language}:${toId}`); + + if (!fromVer || !toVer) continue; + + const fromClassNames = new Set(fromVer.classes.map((item) => item.name)); + const fromFunctionNames = new Set(fromVer.functions.map((item) => item.name)); + const fromToolNames = new Set(fromVer.tools); + + diffs.push({ + language, + from: fromId, + to: toId, + newClasses: toVer.classes + .map((item) => item.name) + .filter((name) => !fromClassNames.has(name)), + newFunctions: toVer.functions + .map((item) => item.name) + .filter((name) => !fromFunctionNames.has(name)), + newTools: toVer.tools.filter((tool) => !fromToolNames.has(tool)), + locDelta: toVer.loc - fromVer.loc, + }); } - - console.log(` Found ${totalDocFiles} doc files across ${localeDirs.length} locales`); - } else { - console.warn(` Docs directory not found: ${DOCS_DIR}`); } - // 5. Write output + const docs = readDocs(); + fs.mkdirSync(OUT_DIR, { recursive: true }); const index: VersionIndex = { versions, diffs }; - const indexPath = path.join(OUT_DIR, "versions.json"); - fs.writeFileSync(indexPath, JSON.stringify(index, null, 2)); - console.log(` Wrote ${indexPath}`); - - const docsPath = path.join(OUT_DIR, "docs.json"); - fs.writeFileSync(docsPath, JSON.stringify(docs, null, 2)); - console.log(` Wrote ${docsPath}`); + fs.writeFileSync(path.join(OUT_DIR, "versions.json"), JSON.stringify(index, null, 2)); + fs.writeFileSync(path.join(OUT_DIR, "docs.json"), JSON.stringify(docs, null, 2)); - // Summary console.log("\nExtraction complete:"); console.log(` ${versions.length} versions`); console.log(` ${diffs.length} diffs`); console.log(` ${docs.length} docs`); - for (const v of versions) { + for (const version of versions) { console.log( - ` ${v.id}: ${v.loc} LOC, ${v.tools.length} tools, ${v.classes.length} classes, ${v.functions.length} functions` + ` ${version.language}/${version.id}: ${version.loc} LOC, ${version.tools.length} tools, ${version.classes.length} classes, ${version.functions.length} functions` ); } } -main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/web/src/app/[locale]/(learn)/[version]/diff/page.tsx b/web/src/app/[locale]/(learn)/[version]/diff/page.tsx deleted file mode 100644 index fdbe03d24..000000000 --- a/web/src/app/[locale]/(learn)/[version]/diff/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { LEARNING_PATH } from "@/lib/constants"; -import { DiffPageContent } from "./diff-content"; - -export function generateStaticParams() { - return LEARNING_PATH.map((version) => ({ version })); -} - -export default async function DiffPage({ - params, -}: { - params: Promise<{ locale: string; version: string }>; -}) { - const { version } = await params; - return ; -} diff --git a/web/src/app/[locale]/(learn)/compare/page.tsx b/web/src/app/[locale]/(learn)/compare/page.tsx index a38a4204e..246053801 100644 --- a/web/src/app/[locale]/(learn)/compare/page.tsx +++ b/web/src/app/[locale]/(learn)/compare/page.tsx @@ -1,52 +1,42 @@ "use client"; -import { useState, useMemo } from "react"; -import { useLocale, useTranslations } from "@/lib/i18n"; +import { useMemo, useState } from "react"; +import { useTranslations } from "@/lib/i18n"; import { LEARNING_PATH, VERSION_META } from "@/lib/constants"; import { Card, CardHeader, CardTitle } from "@/components/ui/card"; import { LayerBadge } from "@/components/ui/badge"; import { CodeDiff } from "@/components/diff/code-diff"; import { ArchDiagram } from "@/components/architecture/arch-diagram"; import { ArrowRight, FileCode, Wrench, Box, FunctionSquare } from "lucide-react"; -import type { VersionIndex } from "@/types/agent-data"; -import versionData from "@/data/generated/versions.json"; - -const data = versionData as VersionIndex; +import { DEFAULT_LANGUAGE, getVersion } from "@/lib/learning"; +import { usePreferredLanguage } from "@/hooks/usePreferredLanguage"; export default function ComparePage() { const t = useTranslations("compare"); - const locale = useLocale(); + const language = usePreferredLanguage() || DEFAULT_LANGUAGE; const [versionA, setVersionA] = useState(""); const [versionB, setVersionB] = useState(""); - const infoA = useMemo(() => data.versions.find((v) => v.id === versionA), [versionA]); - const infoB = useMemo(() => data.versions.find((v) => v.id === versionB), [versionB]); + const infoA = useMemo(() => getVersion(language, versionA), [language, versionA]); + const infoB = useMemo(() => getVersion(language, versionB), [language, versionB]); const metaA = versionA ? VERSION_META[versionA] : null; const metaB = versionB ? VERSION_META[versionB] : null; const comparison = useMemo(() => { if (!infoA || !infoB) return null; + const toolsA = new Set(infoA.tools); const toolsB = new Set(infoB.tools); - const onlyA = infoA.tools.filter((t) => !toolsB.has(t)); - const onlyB = infoB.tools.filter((t) => !toolsA.has(t)); - const shared = infoA.tools.filter((t) => toolsB.has(t)); - - const classesA = new Set(infoA.classes.map((c) => c.name)); - const classesB = new Set(infoB.classes.map((c) => c.name)); - const newClasses = infoB.classes.map((c) => c.name).filter((c) => !classesA.has(c)); - - const funcsA = new Set(infoA.functions.map((f) => f.name)); - const funcsB = new Set(infoB.functions.map((f) => f.name)); - const newFunctions = infoB.functions.map((f) => f.name).filter((f) => !funcsA.has(f)); + const classesA = new Set(infoA.classes.map((item) => item.name)); + const funcsA = new Set(infoA.functions.map((item) => item.name)); return { locDelta: infoB.loc - infoA.loc, - toolsOnlyA: onlyA, - toolsOnlyB: onlyB, - toolsShared: shared, - newClasses, - newFunctions, + toolsOnlyA: infoA.tools.filter((tool) => !toolsB.has(tool)), + toolsOnlyB: infoB.tools.filter((tool) => !toolsA.has(tool)), + toolsShared: infoA.tools.filter((tool) => toolsB.has(tool)), + newClasses: infoB.classes.map((item) => item.name).filter((name) => !classesA.has(name)), + newFunctions: infoB.functions.map((item) => item.name).filter((name) => !funcsA.has(name)), }; }, [infoA, infoB]); @@ -57,7 +47,6 @@ export default function ComparePage() {

{t("subtitle")}

- {/* Selectors */}
@@ -85,23 +74,21 @@ export default function ComparePage() {
- {/* Results */} {infoA && infoB && comparison && (
- {/* Side-by-side version info */}
@@ -127,7 +114,6 @@ export default function ComparePage() {
- {/* Side-by-side Architecture Diagrams */}

{t("architecture")}

@@ -135,18 +121,17 @@ export default function ComparePage() {

{metaA?.title || versionA}

- +

{metaB?.title || versionB}

- +
- {/* Structural diff */}
@@ -157,7 +142,8 @@ export default function ComparePage() { = 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}> - {comparison.locDelta >= 0 ? "+" : ""}{comparison.locDelta} + {comparison.locDelta >= 0 ? "+" : ""} + {comparison.locDelta} {t("lines")} @@ -173,15 +159,6 @@ export default function ComparePage() { {comparison.toolsOnlyB.length} - {comparison.toolsOnlyB.length > 0 && ( -
- {comparison.toolsOnlyB.map((tool) => ( - - {tool} - - ))} -
- )}
@@ -194,15 +171,6 @@ export default function ComparePage() { {comparison.newClasses.length} - {comparison.newClasses.length > 0 && ( -
- {comparison.newClasses.map((cls) => ( - - {cls} - - ))} -
- )}
@@ -215,19 +183,9 @@ export default function ComparePage() { {comparison.newFunctions.length} - {comparison.newFunctions.length > 0 && ( -
- {comparison.newFunctions.map((fn) => ( - - {fn} - - ))} -
- )}
- {/* Tool comparison */} {t("tool_comparison")} @@ -237,54 +195,41 @@ export default function ComparePage() {

{t("only_in")} {metaA?.title || versionA}

- {comparison.toolsOnlyA.length === 0 ? ( -

{t("none")}

- ) : ( -
- {comparison.toolsOnlyA.map((tool) => ( - - {tool} - - ))} -
- )} +
+ {comparison.toolsOnlyA.map((tool) => ( + + {tool} + + ))} +

{t("shared")}

- {comparison.toolsShared.length === 0 ? ( -

{t("none")}

- ) : ( -
- {comparison.toolsShared.map((tool) => ( - - {tool} - - ))} -
- )} +
+ {comparison.toolsShared.map((tool) => ( + + {tool} + + ))} +

{t("only_in")} {metaB?.title || versionB}

- {comparison.toolsOnlyB.length === 0 ? ( -

{t("none")}

- ) : ( -
- {comparison.toolsOnlyB.map((tool) => ( - - {tool} - - ))} -
- )} +
+ {comparison.toolsOnlyB.map((tool) => ( + + {tool} + + ))} +
- {/* Code Diff */}

{t("source_diff")}

)} - {/* Empty state */} {(!versionA || !versionB) && (

{t("empty_hint")}

diff --git a/web/src/app/[locale]/(learn)/layers/page.tsx b/web/src/app/[locale]/(learn)/layers/page.tsx index ceeee9245..ad7693efb 100644 --- a/web/src/app/[locale]/(learn)/layers/page.tsx +++ b/web/src/app/[locale]/(learn)/layers/page.tsx @@ -3,14 +3,12 @@ import Link from "next/link"; import { useTranslations, useLocale } from "@/lib/i18n"; import { LAYERS, VERSION_META } from "@/lib/constants"; -import { Card, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card } from "@/components/ui/card"; import { LayerBadge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { ChevronRight } from "lucide-react"; -import type { VersionIndex } from "@/types/agent-data"; -import versionData from "@/data/generated/versions.json"; - -const data = versionData as VersionIndex; +import { DEFAULT_LANGUAGE, getVersion, getVersionRoute } from "@/lib/learning"; +import { usePreferredLanguage } from "@/hooks/usePreferredLanguage"; const LAYER_BORDER_CLASSES: Record = { tools: "border-l-blue-500", @@ -31,6 +29,7 @@ const LAYER_HEADER_BG: Record = { export default function LayersPage() { const t = useTranslations("layers"); const locale = useLocale(); + const language = usePreferredLanguage() || DEFAULT_LANGUAGE; return (
@@ -41,11 +40,13 @@ export default function LayersPage() {
{LAYERS.map((layer, index) => { - const versionInfos = layer.versions.map((vId) => { - const info = data.versions.find((v) => v.id === vId); - const meta = VERSION_META[vId]; - return { id: vId, info, meta }; - }); + const versionInfos = layer.versions + .map((versionId) => ({ + id: versionId, + info: getVersion(language, versionId), + meta: VERSION_META[versionId], + })) + .filter((item) => item.info); return (
- {/* Layer header */}

- L{index + 1} - {" "} + L{index + 1}{" "} {layer.label}

@@ -71,13 +70,12 @@ export default function LayersPage() {

- {/* Version cards within this layer */}
{versionInfos.map(({ id, info, meta }) => ( @@ -106,7 +104,7 @@ export default function LayersPage() { {info?.tools.length ?? "?"} tools
{meta?.keyInsight && ( -

+

{meta.keyInsight}

)} @@ -116,7 +114,6 @@ export default function LayersPage() {
- {/* Composition indicator */} {index < LAYERS.length - 1 && (
diff --git a/web/src/app/[locale]/[language]/[version]/diff/page.tsx b/web/src/app/[locale]/[language]/[version]/diff/page.tsx new file mode 100644 index 000000000..19f0844fe --- /dev/null +++ b/web/src/app/[locale]/[language]/[version]/diff/page.tsx @@ -0,0 +1,20 @@ +import { getAllVersions } from "@/lib/learning"; +import { DiffPageContent } from "@/components/pages/diff-page-content"; + +export function generateStaticParams() { + return getAllVersions() + .filter((item) => item.id !== "s01") + .map((item) => ({ + language: item.language, + version: item.id, + })); +} + +export default async function DiffPage({ + params, +}: { + params: Promise<{ locale: string; language: string; version: string }>; +}) { + const { language, version } = await params; + return ; +} diff --git a/web/src/app/[locale]/(learn)/[version]/page.tsx b/web/src/app/[locale]/[language]/[version]/page.tsx similarity index 58% rename from web/src/app/[locale]/(learn)/[version]/page.tsx rename to web/src/app/[locale]/[language]/[version]/page.tsx index 90c35a22b..559603076 100644 --- a/web/src/app/[locale]/(learn)/[version]/page.tsx +++ b/web/src/app/[locale]/[language]/[version]/page.tsx @@ -1,30 +1,44 @@ import Link from "next/link"; -import { LEARNING_PATH, VERSION_META, LAYERS } from "@/lib/constants"; +import { VersionDetailClient } from "@/components/pages/version-detail-client"; import { LayerBadge } from "@/components/ui/badge"; -import versionsData from "@/data/generated/versions.json"; -import { VersionDetailClient } from "./client"; +import { LAYERS } from "@/lib/constants"; import { getTranslations } from "@/lib/i18n-server"; +import { + getAllVersions, + getDiffRoute, + getLanguageLabel, + getNextVersionId, + getPrevVersionId, + getVersion, + getVersionDiff, + getVersionMeta, + getVersionRoute, +} from "@/lib/learning"; export function generateStaticParams() { - return LEARNING_PATH.map((version) => ({ version })); + return getAllVersions().map((item) => ({ + language: item.language, + version: item.id, + })); } export default async function VersionPage({ params, }: { - params: Promise<{ locale: string; version: string }>; + params: Promise<{ locale: string; language: string; version: string }>; }) { - const { locale, version } = await params; - - const versionData = versionsData.versions.find((v) => v.id === version); - const meta = VERSION_META[version]; - const diff = versionsData.diffs.find((d) => d.to === version) ?? null; + const { locale, language, version } = await params; + const versionData = getVersion(language, version); + const meta = getVersionMeta(version); + const diff = getVersionDiff(language, version); if (!versionData || !meta) { return (

Version not found

-

{version}

+

+ {language}/{version} +

); } @@ -32,40 +46,40 @@ export default async function VersionPage({ const t = getTranslations(locale, "version"); const tSession = getTranslations(locale, "sessions"); const tLayer = getTranslations(locale, "layer_labels"); - const layer = LAYERS.find((l) => l.id === meta.layer); - - const pathIndex = LEARNING_PATH.indexOf(version as typeof LEARNING_PATH[number]); - const prevVersion = pathIndex > 0 ? LEARNING_PATH[pathIndex - 1] : null; - const nextVersion = - pathIndex < LEARNING_PATH.length - 1 - ? LEARNING_PATH[pathIndex + 1] - : null; + const layer = LAYERS.find((item) => item.id === meta.layer); + const prevVersion = getPrevVersionId(version); + const nextVersion = getNextVersionId(version); return (
- {/* Header */}
{version} -

{tSession(version) || meta.title}

- {layer && ( - {tLayer(layer.id)} - )} +

+ {tSession(version) || meta.title} +

+ {layer && {tLayer(layer.id)}}
-

- {meta.subtitle} -

+ +

{meta.subtitle}

+
{versionData.loc} LOC - {versionData.tools.length} {t("tools")} + + {versionData.tools.length} {t("tools")} + + + {getLanguageLabel(language)} + {meta.coreAddition && ( {meta.coreAddition} )}
+ {meta.keyInsight && (
{meta.keyInsight} @@ -73,48 +87,53 @@ export default async function VersionPage({ )}
- {/* Client-rendered interactive sections */} - {/* Prev / Next navigation */}