Compare commits

...

603 Commits

Author SHA1 Message Date
a7fb0a4369 fix: 同步 PI_CODING_AGENT_DIR 并更新模型列表
Some checks failed
CI / build-check-test (push) Has been cancelled
- build.sh 导出 PI_CODING_AGENT_DIR 以对齐 pi-mcp-adapter 配置路径
- 更新 models.generated.ts
- 移除 bmi-calculator.html 示例文件
2026-06-26 13:21:14 +08:00
539599dbb8 feat(coding-agent): isolate SproutClaw namespace from upstream pi
Some checks failed
CI / build-check-test (push) Has been cancelled
Use piConfig name sproutclaw and configDir .sproutclaw so commands, env vars,
and project config no longer collide with the stock pi install.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 10:29:28 +08:00
294ce75892 docs: 更新 README,修正过时内容
Some checks failed
CI / build-check-test (push) Has been cancelled
- 移除 .pi/ 目录条目(已 gitignore)
- 更新 WebUI 说明为独立 sproutclaw-web 服务
- 修正 build.sh/test.sh 说明和脚本引用错误
- 更新合并上游流程(直接 merge,不再用临时分支)
- 补充 Gitea remote 配置说明

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:56:31 +08:00
f1c5e7dc1d chore: rename pi-built/pi-test scripts to build/test
Some checks failed
CI / build-check-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:54:00 +08:00
812bca79a3 docs: 将 CONTRIBUTING.md 和 SECURITY.md 翻译为中文
Some checks failed
CI / build-check-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:49:18 +08:00
07a918d4cd chore: merge upstream sync branch into main
Some checks failed
CI / build-check-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:18:34 +08:00
8ad811c977 feat: sync upstream pi-mono (386 commits)
从上游 badlogic/pi-mono 同步 386 个提交,手动解决所有冲突:

保留 SproutClaw 独有功能:
- RPC: reload 命令、bash 流式输出、get_extensions 命令
- settings-manager: showChangelogOnStartup 设置
- agent-session: turnIndex getter
- 移除 pi 版本检查通知(interactive-mode)
- skills 系统、启动脚本、自定义工具脚本

直接采用上游版本:
- packages/ai 模型列表及测试文件
- packages/coding-agent 文档
- 其余未冲突的全部上游代码

升级 @mistralai/mistralai 至 2.2.6(修复 promptCacheKey 类型错误)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 20:18:02 +08:00
131546b2fe chore: remove npm audit scheduled cron trigger
Some checks failed
CI / build-check-test (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 19:50:04 +08:00
9ed9ed91dd chore: remove .pi config from git tracking
.pi 是本地持久化配置目录,不应上传到仓库。
从追踪中移除所有 .pi 文件,并在 .gitignore 顶部添加整体排除规则。
同时修复 pre-commit hook 在 gitignore 文件重 add 时不报错。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 19:45:14 +08:00
0d8ea01b43 chore: sync latest workspace updates
- 更新 AI 模型列表(models.generated.ts / image-models.generated.ts)
- 新增 bmi-calculator.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 19:41:28 +08:00
Armin Ronacher
d93b92baca fix(coding-agent): show changelog URL in update notice 2026-06-20 22:23:25 +02:00
Armin Ronacher
b4f31408b6 Add [Unreleased] section for next cycle 2026-06-20 22:15:40 +02:00
Armin Ronacher
615bf2f874 Release v0.79.9 2026-06-20 22:15:37 +02:00
Armin Ronacher
1aa79b9b29 docs: update unreleased changelog audit 2026-06-20 22:00:00 +02:00
Armin Ronacher
350ac3f344 fix: remove inprogress from auto-closed issues 2026-06-20 21:48:37 +02:00
Armin Ronacher
500b568b04 fix(ai): use OpenAI endpoint for Fireworks GLM-5.2
closes #5923
2026-06-20 21:32:02 +02:00
Armin Ronacher
5505316ea2 fix(coding-agent): cache extension imports for session switches
Closes #5905.
2026-06-20 21:07:45 +02:00
Armin Ronacher
a1da88aed4 fix(coding-agent): make session path traversal linear
Avoid quadratic path construction when walking deep session branches. On a 600k-entry pathological session, path construction improved from about 20.3s with Array.unshift() to about 35ms with push() plus reverse().

Refs #5804.
2026-06-20 20:29:15 +02:00
Armin Ronacher
8597ebafd9 fix(ai): expose OpenRouter GLM-5.2 xhigh effort
closes #5770
2026-06-20 18:47:42 +02:00
Armin Ronacher
416c673dfe fix: skip no-action for to-discuss issues 2026-06-20 16:13:13 +02:00
Alexey Zaytsev
3095977d13 fix(tui): stabilize streaming code fence rendering (#5846) 2026-06-20 14:58:30 +02:00
Armin Ronacher
8b97e75c6b feat(ai): add chat-template thinking compat
closes #5673
2026-06-19 23:34:17 +02:00
Armin Ronacher
128330e36f fix(coding-agent): preserve untouched lines in fuzzy edit
closes #5899
2026-06-19 20:16:47 +02:00
Vegard Stikbakke
1287b69fe0 fix(coding-agent): run legacy WSL bash commands via stdin
closes #5893
2026-06-19 15:08:25 +02:00
Vegard Stikbakke
6e6ce70caf fix(ai): filter Copilot models by account availability
closes #5897
2026-06-19 13:17:13 +02:00
Armin Ronacher
226a316824 fix: mark auto-closed issues not planned 2026-06-19 12:46:56 +02:00
Vegard Stikbakke
373cd6ae52 fix(coding-agent): prioritize provider matches in model selector
closes #5892
2026-06-19 12:25:02 +02:00
Armin Ronacher
47d1d90a8e fix: close no-action issues as not planned 2026-06-19 11:08:16 +02:00
Armin Ronacher
783571a615 feat: track auto-closed issue triage 2026-06-19 11:03:11 +02:00
Armin Ronacher
56b22768db Add [Unreleased] section for next cycle 2026-06-19 09:53:45 +02:00
Armin Ronacher
8eb9704b3e Release v0.79.8 2026-06-19 09:53:43 +02:00
Armin Ronacher
74677bbfbf docs: audit unreleased changelogs 2026-06-19 09:45:03 +02:00
Armin Ronacher
a2f70e5f28 fix(coding-agent): reset tool test mocks 2026-06-19 09:22:13 +02:00
Armin Ronacher
ea65a51a15 fix: update vulnerable dependencies 2026-06-19 00:43:44 +02:00
Fred K. Schott
0d89a33373 feat(packages): Add selective pi-ai base entrypoints (#5348) 2026-06-19 00:41:49 +02:00
Armin Ronacher
1a418ad223 chore: remove inprogress label on close 2026-06-19 00:26:30 +02:00
Armin Ronacher
9179734cc2 docs(coding-agent): audit unreleased changelog 2026-06-18 23:58:34 +02:00
Armin Ronacher
651d10d90b feat(ai): enable Mistral prompt caching 2026-06-18 23:47:25 +02:00
Armin Ronacher
8025fdd01f meta: Update readmes slightly 2026-06-18 23:36:08 +02:00
Armin Ronacher
fd1ba2c7fe test(coding-agent): seed auto-compaction queue fixture 2026-06-18 23:15:18 +02:00
Armin Ronacher
cab89d1435 docs: audit unreleased changelogs 2026-06-18 23:09:41 +02:00
Armin Ronacher
c60f6a8ab3 feat(coding-agent): expose post-compaction token estimates
closes #5877
2026-06-18 23:01:10 +02:00
Danila Poyarkov
b09fbde00e feat(ai): add OpenRouter Fusion alias (#5866) 2026-06-18 22:39:02 +02:00
Armin Ronacher
7d08c81a09 fix(coding-agent): avoid empty compaction summaries
closes #4811
2026-06-18 22:17:57 +02:00
Armin Ronacher
6b9f3f492f fix(coding-agent): avoid retrying successful overflow compaction
closes #5720
2026-06-18 19:30:14 +02:00
Armin Ronacher
788a044483 Add [Unreleased] section for next cycle 2026-06-18 18:22:10 +02:00
Armin Ronacher
c4ab61dcbf Release v0.79.7 2026-06-18 18:22:07 +02:00
Armin Ronacher
717494223d docs: audit unreleased changelogs 2026-06-18 18:16:48 +02:00
Armin Ronacher
aae62dfa87 feat(coding-agent): make bare update self-only 2026-06-18 18:09:03 +02:00
Alexey Zaytsev
2b46f38863 feat(coding-agent): Expose edit-diff for extensions (#5756) 2026-06-18 17:14:39 +02:00
Armin Ronacher
d0b46764b1 feat(coding-agent): add automatic theme mode (#5874) 2026-06-18 17:13:47 +02:00
Armin Ronacher
908be616f2 ref: Remove some options from package reporting 2026-06-18 17:12:03 +02:00
Armin Ronacher
bc93655e1c meta: Added report template 2026-06-18 16:26:01 +02:00
Vegard Stikbakke
20da9bc140 fix attribution for 008c76f9 2026-06-18 10:59:25 +02:00
Diego de Oliveira
7a14325b24 feat(tui): detect Warp terminal and enable Kitty image protocol (#5841)
Warp supports the Kitty graphics protocol and OSC 8 hyperlinks,
but pi's terminal-image capability detection did not recognize it.
Add detection via TERM_PROGRAM=WarpTerminal, WARP_SESSION_ID,
and WARP_TERMINAL_SESSION_UUID so images render inline without
requiring the TERM_PROGRAM=kitty workaround.

Fixes #5827
2026-06-18 10:45:51 +02:00
Vegard Stikbakke
51f7523587 fix(coding-agent): include RPC request id on unknown commands
closes #5868
2026-06-18 10:44:59 +02:00
Vegard Stikbakke
008c76f955 feat(coding-agent): export project config dir name 2026-06-18 10:27:11 +02:00
Vegard Stikbakke
58dd2f5996 feat(ai): add GLM-5.2 to OpenCode Go model catalog
closes #5860
2026-06-18 10:06:23 +02:00
Armin Ronacher
6d5ede31c8 fix(coding-agent): match provider-first model searches 2026-06-17 17:16:39 +02:00
Vegard Stikbakke
ae89286d07 docs: update changelogs for tree panning
closes #5830
2026-06-17 14:48:35 +02:00
Vegard Stikbakke
068ab5d19b fix(coding-agent): horizontally pan tree selector
Fixes #5830
2026-06-17 14:41:39 +02:00
github-actions[bot]
29c1504cc1 chore: approve contributor dodiego 2026-06-17 07:24:29 +00:00
Armin Ronacher
12bb8dd2c9 Add [Unreleased] section for next cycle 2026-06-16 23:59:17 +02:00
Armin Ronacher
31bfb2f16f Release v0.79.6 2026-06-16 23:59:14 +02:00
Armin Ronacher
34b6aea1e6 docs(coding-agent): add changelog entries for fetch override and DeepSeek V4 thinking-off fixes 2026-06-16 23:59:00 +02:00
Armin Ronacher
7da475db66 fix(ai): regenerate model catalog 2026-06-16 23:58:43 +02:00
Armin Ronacher
bd9f8773ad fix(ai): restore OpenCode Go DeepSeek thinking controls 2026-06-16 23:14:56 +02:00
Armin Ronacher
a93f066607 fix(coding-agent): preserve fetch overrides 2026-06-16 20:04:53 +02:00
Armin Ronacher
a78cd7ccb1 fix(coding-agent): stabilize self-update tests 2026-06-16 20:02:20 +02:00
Armin Ronacher
0b0b9eae62 Add [Unreleased] section for next cycle 2026-06-16 19:54:26 +02:00
Armin Ronacher
6561cb293b Release v0.79.5 2026-06-16 19:54:23 +02:00
Armin Ronacher
b6b5bed9ae docs: update unreleased changelogs 2026-06-16 19:46:15 +02:00
Armin Ronacher
2431491c92 fix(ai): avoid duplicate OpenCode DeepSeek reasoning controls
closes #5818
2026-06-16 19:05:44 +02:00
Armin Ronacher
2d597f0212 fix(ai): tolerate null Responses message content
closes #5819
2026-06-16 19:00:12 +02:00
Armin Ronacher
910508595a feat(coding-agent): add settings http proxy
closes #5790
2026-06-16 18:53:19 +02:00
Armin Ronacher
0680726ac4 fix: upgrade marked to 18.0.5 2026-06-16 17:41:59 +02:00
Armin Ronacher
8f0e9251b9 fix(coding-agent): do not open browser for device code login 2026-06-16 17:35:50 +02:00
Armin Ronacher
7f29e7a369 feat: add provider-scoped environment overrides (#5807) 2026-06-16 17:19:08 +02:00
4h9fbZ
3039f3e17d fix(tui): restore cursorUp start-of-line jump when input is non-empty (#5789) 2026-06-16 15:14:17 +02:00
Armin Ronacher
06d8c54de2 fix(coding-agent): avoid Windows pi update exit assertion
closes #5805
2026-06-16 15:10:23 +02:00
Armin Ronacher
75b0d723c0 fix(ai): support Z.AI GLM-5.2 effort levels
closes #5770
2026-06-16 14:29:27 +02:00
Ramiz Wachtler
f8a77f47f2 feat(coding-agent): add Vercel AI Gateway attribution (#5798) 2026-06-16 09:37:37 +02:00
Armin Ronacher
b0c8f65ffa fix(ai): update Google Vertex Gemini models
closes #5761
2026-06-16 01:03:12 +02:00
Armin Ronacher
7cfd1af395 fix(coding-agent): keep empty session selector open
closes #5747
2026-06-16 00:39:44 +02:00
Armin Ronacher
bee8e9c805 feat(coding-agent): mark experimental sessions in footer 2026-06-15 23:22:23 +02:00
Armin Ronacher
431d88f4fc meta: Link to rfcs 2026-06-15 17:36:39 +02:00
Armin Ronacher
0369bdb8f4 fix(ai): add Moonshot CN Kimi K2.7 metadata
closes #5760
2026-06-15 17:08:59 +02:00
root
a2a5539e78 Merge branch 'main' of ssh://git.shumengya.top:8022/shumengya/sproutclaw 2026-06-15 17:08:54 +08:00
root
072398bddc chore: ignore .pi/agent/skills directory 2026-06-15 17:08:22 +08:00
Armin Ronacher
1aa3c02d56 Add [Unreleased] section for next cycle 2026-06-15 10:28:24 +02:00
Armin Ronacher
bba6af2c71 Release v0.79.4 2026-06-15 10:28:21 +02:00
Armin Ronacher
b1ad469be4 docs: audit changelog entries 2026-06-15 10:23:19 +02:00
Armin Ronacher
8a7ad60f03 feat(coding-agent): add binary release checksums
closes #5739
2026-06-15 10:11:44 +02:00
Joseph Mearman
3fa4095629 fix: drain stdout before resolving when a child holds the pipe past exit (#5753) 2026-06-15 09:55:45 +02:00
Armin Ronacher
408ac103ec fix(ai): update Copilot Claude thinking metadata
closes #4637
2026-06-15 09:50:25 +02:00
github-actions[bot]
28b3af5d0f chore: approve contributor Mearman 2026-06-15 07:25:09 +00:00
Bucky
0be5bb6c96 fix(ai): price anthropic 1h cache writes at 2x input (#5738)
anthropic 1h cache writes were billed at the 5m rate; split the bucket and price the 1h portion correctly
2026-06-15 09:21:14 +02:00
Vegard Stikbakke
a851968170 docs(coding-agent): reorder containerization patterns 2026-06-15 09:16:13 +02:00
731ba23515 feat(ui): 美化启动界面并重构 /is 工具/插件展示
Some checks failed
npm audit / audit (push) Has been cancelled
CI / build-check-test (push) Has been cancelled
- 缩小标题加 ❯ 引导符、彩色版本号、中文说明
- 去除启动头部段落间空行
- /is 本地扩展和 npm 扩展合并为 [插件] 分组展示
- /is 新增 [工具] 区块显示所有注册工具及激活状态

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:32:16 +08:00
root
675dd18182 chore: sync latest workspace updates
Some checks failed
CI / build-check-test (push) Has been cancelled
2026-06-15 13:38:22 +08:00
Armin Ronacher
bb959aae01 fix(coding-agent): wrap tree help on narrow terminals
closes #5055
2026-06-15 01:25:46 +02:00
Armin Ronacher
24053eab66 fix(tui): update tab overlay boundary expectation 2026-06-15 01:12:40 +02:00
Armin Ronacher
5b6058c3a7 fix(tui): align overlays over CJK wide cells
closes #5297
2026-06-15 01:06:15 +02:00
Armin Ronacher
ba0ec61563 fix(coding-agent): restore terminal on SIGTERM
closes #5724
2026-06-15 00:47:35 +02:00
Armin Ronacher
b5e13bcd8a docs(coding-agent): clarify active tools docs
closes #5729
2026-06-15 00:02:31 +02:00
Armin Ronacher
93b3b7c1fe fix(tui): preserve WezTerm Kitty images on full redraw
Reserve visible Kitty image rows before drawing placements in full redraws, while preserving the first-row path for taller-than-viewport images so #4415 stays fixed.

Closes #5618.
2026-06-14 23:16:59 +02:00
Armin Ronacher
d683a581b7 meta: Update CONTRIBUTING.md for clearer language 2026-06-14 22:31:42 +02:00
Alexey Zaytsev
6b40c99a59 feat(examples): Wrap question extension text instead of truncating (#5708) 2026-06-14 15:02:58 +02:00
ad62015fe3 chore: commit pending workspace updates
Some checks failed
CI / build-check-test (push) Has been cancelled
2026-06-14 20:36:01 +08:00
Armin Ronacher
11b5403fad fix(coding-agent): exit after package commands
closes #5687
2026-06-14 10:53:00 +02:00
Vegard Stikbakke
f0989800cb feat: detect first-run terminal theme (#5385) 2026-06-14 07:14:42 +02:00
Armin Ronacher
3fcfb7abf7 docs(coding-agent): document extension resource lifecycle 2026-06-14 02:05:11 +02:00
Armin Ronacher
c48f656f88 fix(coding-agent): handle npm package semver ranges
closes #5695
2026-06-14 01:38:51 +02:00
Armin Ronacher
2fbdff9dab fix(coding-agent): fix pnpm self-update bin-dir
closes #5689
2026-06-14 01:31:50 +02:00
Armin Ronacher
5be8c31fa7 meta: add extension disclaimer to bug reporting 2026-06-14 00:57:56 +02:00
Armin Ronacher
21a904f4b7 fix(ai): disable OpenCode long cache retention for rejecting routes
closes #5702
2026-06-13 23:59:45 +02:00
Armin Ronacher
9e9fc79478 fix(coding-agent): treat uppercase config values as literals
closes #5661
2026-06-13 21:24:22 +02:00
Armin Ronacher
f315d81491 meta: Update weekend policy in contributing to be less confusing 2026-06-13 15:43:00 +02:00
root
62e2866228 chore: commit pending changes before gitea push
Some checks failed
CI / build-check-test (push) Has been cancelled
npm audit / audit (push) Has been cancelled
2026-06-13 19:47:30 +08:00
Armin Ronacher
6f29450e13 fix(ai): update adaptive thinking model expectations 2026-06-13 11:17:38 +02:00
Armin Ronacher
b15148fe37 Add [Unreleased] section for next cycle 2026-06-13 11:11:14 +02:00
Armin Ronacher
f2585c4c82 Release v0.79.3 2026-06-13 11:11:11 +02:00
Armin Ronacher
57b6bdce71 docs(coding-agent): update Codex context limit changelog 2026-06-13 11:07:31 +02:00
Armin Ronacher
aa3a5233ad fix(ai): restore Codex context limits 2026-06-13 11:02:38 +02:00
Armin Ronacher
032c01c1e1 Add [Unreleased] section for next cycle 2026-06-13 00:25:18 +02:00
Armin Ronacher
f21f3c4bbd Release v0.79.2 2026-06-13 00:25:15 +02:00
Armin Ronacher
e320f09618 docs: update unreleased changelogs 2026-06-13 00:20:01 +02:00
Armin Ronacher
121f0edbf4 fix(ai): detect parenthesized context overflow errors
closes #5677
2026-06-13 00:01:37 +02:00
Michael Yu
7a3cb6312e fix(ai): normalize generated model costs (#5634) 2026-06-13 00:01:04 +02:00
Armin Ronacher
b4bff7f0d0 fix(coding-agent): avoid project trust prompt for update (#5674) 2026-06-12 23:37:16 +02:00
Armin Ronacher
a7cdc679e7 fix(ai): correct GPT-5 context window metadata
closes #5644
2026-06-12 23:03:10 +02:00
Armin Ronacher
17721d5e01 fix(tui): preserve unordered user list markers (closes #5657) 2026-06-12 20:22:53 +02:00
Armin Ronacher
daab056ac1 fix(agent): ignore late tool progress updates
closes #5573
2026-06-12 18:59:32 +02:00
Armin Ronacher
adf567c1c6 fix(coding-agent): rechain fork paths without labels
closes #5669
2026-06-12 18:50:31 +02:00
Armin Ronacher
1b2c32c653 fix(coding-agent): resolve authenticated slash model ids
closes #5643
2026-06-12 18:32:42 +02:00
Armin Ronacher
0caca6cf3f fix(tui): support slash-separated fuzzy filter tokens 2026-06-12 18:24:55 +02:00
Armin Ronacher
6102dd2084 fix(coding-agent): handle missing export themes
closes #5596
2026-06-12 17:56:43 +02:00
Armin Ronacher
1fc80f4f6c fix(coding-agent): preserve custom fallback thinking
closes #5552
2026-06-12 17:26:42 +02:00
Armin Ronacher
be7d5cf585 fix(ai): relax Codex SSE header timeout 2026-06-12 17:08:30 +02:00
Ramiz Wachtler
a455f62f72 fix(ai): preserve Anthropic refusal details (#5666)
Propagate Anthropic refusal stop_details explanation to errorMessage
2026-06-12 14:52:46 +02:00
Armin Ronacher
1c24336560 fix(tui): keep WezTerm Kitty images visible
Closes #5618.
2026-06-12 12:59:32 +02:00
Vegard Stikbakke
3f44d3e2eb fix(ai): remove stale OpenRouter Kimi free model assertion (#5650) 2026-06-12 09:00:15 +02:00
Vegard Stikbakke
1da903983a fix(coding-agent): skip first-time setup for forks (#5627) 2026-06-11 21:36:26 +02:00
Vegard Stikbakke
406a2214aa fix(coding-agent): refine setup copy 2026-06-10 19:55:19 +02:00
d51a055d78 feat: update webui extensions, models, and agent config
Some checks failed
CI / build-check-test (push) Has been cancelled
npm audit / audit (push) Has been cancelled
2026-06-10 21:45:53 +08:00
Vegard Stikbakke
0ab2aa86af feat(coding-agent): add experimental first-time setup flow (#5587)
Behind PI_EXPERIMENTAL=1, show a first-time setup dialog on interactive startup when the default agent directory is used and settings.json does not exist. The dialog preselects the detected terminal appearance with an explicit dark/light choice (live preview) and asks for opt-in analytics data sharing. Submitting writes settings.json, which serves as the completion marker; opting in stores a generated trackingId. Escape at any point skips out of setup.
2026-06-10 12:11:49 +02:00
Mario Zechner
a3cd03e7ff Merge pull request #5585 from haoqixu/fix-editor-cjk-wrap
fix(tui): wrap CJK text at character boundaries in editor
2026-06-10 11:43:42 +02:00
Mario Zechner
2f5066d7a0 Merge pull request #5562 from Perlence/fix-tui-render-loose-lists
fix(tui): separate list items with blank lines in loose lists
2026-06-10 11:42:12 +02:00
Mario Zechner
e537dba3de Merge pull request #5561 from unexge/push-lpxyxwstnswr
feat(ai): link AWS data retention docs in Bedrock validation errors
2026-06-10 11:41:29 +02:00
Mario Zechner
9fd75b8acb Merge pull request #5560 from haoqixu/fix-5552
fix(coding-agent): parse :thinking suffix from custom model IDs in fallback path
2026-06-10 11:39:52 +02:00
Mario Zechner
a7f9fe681d fix: bump shell-quote to 1.8.4 in lockfile (GHSA-w7jw-789q-3m8p) 2026-06-10 11:01:30 +02:00
root
cf5edd6394 feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled
Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 16:57:08 +08:00
Burak Varli
0b6c95dda0 feat(ai): link AWS data retention docs in Bedrock validation errors
When Bedrock rejects a request with "data retention mode '<mode>' is not
available for this model", append a pointer to the AWS data retention
documentation so users can configure a supported mode.
2026-06-10 07:45:50 +00:00
haoqixu
eb01f59dc9 fix(tui): wrap CJK text at character boundaries in editor 2026-06-10 15:39:44 +08:00
Armin Ronacher
9ccfcd7cfc fix(ai): omit disabled thinking for Claude Fable 5 2026-06-10 00:27:11 +02:00
Armin Ronacher
dacb367e9e fix(ai): expect Claude Fable 5 in adaptive thinking model test 2026-06-09 23:20:20 +02:00
Armin Ronacher
82f2b1e908 Add [Unreleased] section for next cycle 2026-06-09 23:14:55 +02:00
Armin Ronacher
28df940f0d Release v0.79.1 2026-06-09 23:14:52 +02:00
Armin Ronacher
4d9f9f455d fix(ai): regenerate image models for upstream Riverflow rename 2026-06-09 23:14:38 +02:00
Armin Ronacher
66f432cae4 fix(ai): regenerate models for Claude Fable 5 and Azure metadata overrides
Also add inherited coding-agent changelog entries for Fable 5 and the
Azure gpt-5.4/5.5 context window and gpt-5-pro maxTokens fixes.
2026-06-09 23:08:05 +02:00
Armin Ronacher
6b5923f107 fix(ai): correct Azure gpt-5.4/5.5 context window and gpt-5-pro maxTokens
Azure Foundry deploys gpt-5.4 and gpt-5.5 with a 1,050,000 context
window, but the Azure provider cloned OpenAI-direct's 272k API limit.
Override the context window in the Azure derivation.

Also fix gpt-5-pro maxTokens, which upstream metadata set to 272000 (a
duplicate of the input sub-limit) instead of the actual 128000 max
output; corrected at the source so OpenAI-direct and Azure both match.

closes #5559
2026-06-09 22:57:20 +02:00
Armin Ronacher
5a9d72ea02 feat(ai): add Claude Fable 5 metadata 2026-06-09 22:42:48 +02:00
Armin Ronacher
a0c2465d47 docs: audit unreleased changelogs 2026-06-09 22:33:02 +02:00
Sviatoslav Abakumov
dfd4571cb0 fix(tui): separate list items with blank lines in loose lists 2026-06-09 23:53:18 +04:00
haoqixu
aa03982169 fix(coding-agent): parse :thinking suffix from custom model IDs in fallback path
Fixes #5552
2026-06-10 02:01:35 +08:00
Mario Zechner
c5582102f5 Merge pull request #5553 from dannote/prompt-template-defaults
Add prompt template argument defaults
2026-06-09 14:23:08 +02:00
Mario Zechner
143b251e77 Merge branch 'main' into prompt-template-defaults 2026-06-09 14:23:00 +02:00
Mario Zechner
ae7a885da2 Closes #5045, /new should not persist if original session was ephemeral 2026-06-09 14:17:33 +02:00
Mario Zechner
b7e721cb38 feat(tui): support autocomplete trigger characters
closes #4703
2026-06-09 14:15:39 +02:00
Mario Zechner
69ea1a6310 docs(coding-agent): clarify model name display docs
closes #4841
2026-06-09 14:14:57 +02:00
Danila Poyarkov
d81ac20920 feat(coding-agent): add prompt template argument defaults 2026-06-09 15:12:07 +03:00
Mario Zechner
d041b5cc35 Merge pull request #5549 from earendil-works/approval-settings
feat(ui): Improved project approval settings
2026-06-09 13:47:29 +02:00
Armin Ronacher
c20ea06d4a fix: --help and --version redirect 2026-06-09 13:43:54 +02:00
Mario Zechner
3d02d1da11 fix(ai): map OpenCode max tokens
closes #5331
2026-06-09 13:41:08 +02:00
Mario Zechner
9632bddd38 fix(coding-agent): stabilize OAuth login prompt rows
closes #5433
2026-06-09 13:41:08 +02:00
Mario Zechner
64b51efb6e fix(ai): use z.ai thinking payload
closes #5330
2026-06-09 13:37:58 +02:00
Armin Ronacher
359a0769f1 fix: simplify help 2026-06-09 13:36:05 +02:00
Armin Ronacher
5cb4f597f7 feat(ui): Improved project approval settings 2026-06-09 13:25:54 +02:00
Vegard Stikbakke
66335d3a49 feat(coding-agent): add experimental feature guard (#5547) 2026-06-09 13:23:07 +02:00
Mario Zechner
28c83e8385 fix(coding-agent): sync queue modes on reload
closes #5377
2026-06-09 13:21:59 +02:00
Mario Zechner
1906074369 fix(coding-agent): handle invalid models json during migration
closes #5418
2026-06-09 13:14:24 +02:00
Mario Zechner
e4907b3b09 fix(tui): restore prompt draft after history browsing
closes #5494
2026-06-09 13:06:13 +02:00
Mario Zechner
db3f9953ee feat(coding-agent): expose project trust to extensions
closes #5523
2026-06-09 13:03:59 +02:00
github-actions[bot]
081a0a2bef chore: approve contributor dangooddd 2026-06-09 11:02:46 +00:00
Mario Zechner
84cdd02400 fix(ai): disable Azure OpenAI response storage
closes #5530
2026-06-09 12:53:17 +02:00
Mario Zechner
8da077bcca fix(tui): wrap CJK text at grapheme boundaries
closes #5495
2026-06-09 12:52:52 +02:00
github-actions[bot]
def99d395e chore: approve contributor vdxz 2026-06-09 10:51:30 +00:00
Mario Zechner
22e4549275 Merge pull request #5283 from smoosex/main
fix(tui): keep hardware cursor marker during slash-command autocomplete
2026-06-09 12:33:15 +02:00
Mario Zechner
2326d5cb4a fix(ai): disable Moonshot thinking when requested
closes #5531
2026-06-09 12:23:41 +02:00
github-actions[bot]
c6bdfa1971 chore: approve contributor davidlifschitz 2026-06-09 09:40:40 +00:00
Mario Zechner
4c4863654e Merge pull request #5505 from awakenalive/patch-1
Update models for together.ai
2026-06-09 10:52:03 +02:00
Mario Zechner
44e33798fd Merge pull request #5527 from AJM10565/fix/bedrock-arn-region-parsing
fix(amazon-bedrock): extract region from inference profile ARNs
2026-06-09 09:50:01 +02:00
ajm_ensighten
8cef3c8d7f fix(amazon-bedrock): extract region from inference profile ARNs
Application inference profile ARNs encode the region
(arn:aws:bedrock:<region>:<account>:...) but the provider ignored it,
falling through to AWS_REGION which may point to a different region.
Extract the region from the ARN when present, taking priority over
environment variables.

Fixes #4860
2026-06-08 17:01:51 -05:00
Armin Ronacher
20b78eafb4 fix(coding-agent): fix changelog links
Fixes #5516
2026-06-08 20:31:20 +02:00
Armin Ronacher
2edd6b432a Add [Unreleased] section for next cycle 2026-06-08 17:15:54 +02:00
Armin Ronacher
c10fb95fd9 Release v0.79.0 2026-06-08 17:15:51 +02:00
Armin Ronacher
35120d7e48 docs: audit unreleased changelogs 2026-06-08 16:01:13 +02:00
Armin Ronacher
ce3a72444e docs(coding-agent): document security model 2026-06-08 15:56:29 +02:00
Eric Henry
2527441b40 Update generate-models.ts
Removed together.ai MiniMaxAI/MiniMax-M2.5 due to it not being supported for serverless inference.

 Error: 400 Unable to access non-serverless model MiniMaxAI/MiniMax-M2.5. Please visit
 https://api.together.ai/models/MiniMaxAI/MiniMax-M2.5 to create and start a new dedicated endpoint for the model.
2026-06-08 08:32:16 -05:00
Mario Zechner
d8aef0feff feat(coding-agent): allow project trust extensions to defer 2026-06-08 13:38:29 +02:00
Mario Zechner
085a08582f fix(coding-agent): remove stale hooks export 2026-06-08 13:27:16 +02:00
Mario Zechner
21917fed37 Merge pull request #5499 from Roman-Galeev/fix/editor-cursor-move-refresh-autocomplete
fix(tui): re-query autocomplete picker on cursor movement
2026-06-08 13:13:05 +02:00
Mario Zechner
718215bd95 feat(coding-agent): add extension project trust decisions 2026-06-08 12:59:08 +02:00
Armin Ronacher
dce3e28516 fix: show security advisories in prompt widget 2026-06-08 12:35:18 +02:00
Armin Ronacher
f4f72d4ed5 docs(agent): add security advisory prompt 2026-06-08 12:35:18 +02:00
Roman Galeev
9d007fe642 fix(tui): re-query autocomplete picker on cursor movement
moveCursor() repositioned the cursor but never called updateAutocomplete()
or cancelAutocomplete(), unlike insertCharacter()/handleBackspace(). So an
open autocomplete picker went stale when the cursor moved: e.g. typing
`/cmd ` (argument menu open) then arrowing Left back into the command name
left the argument list showing against a `/cmd` prefix, and a Tab there
concatenated the stale suggestion onto the partial command name
(e.g. `/swarepo`).

Sync the picker at the end of moveCursor(): when a picker is open, re-query
via updateAutocomplete(), which refreshes the list for the new cursor
position or closes it when nothing matches.

Adds a regression test in test/editor.test.ts (verified to fail without the
fix). Fixes earendil-works/pi#5496.
2026-06-08 10:57:50 +02:00
Mario Zechner
38f18be447 fix(coding-agent): persist implicit project trust on reload 2026-06-08 10:08:01 +02:00
Mario Zechner
130ae577ac fix(tui,coding-agent): make keyboard protocol fallback response-driven
closes #5188
2026-06-07 17:04:19 +02:00
Mario Zechner
72fd911356 fix(coding-agent): neutralize compaction summarization prompt
closes #5401
2026-06-07 11:28:27 +02:00
Mario Zechner
eb43bd4493 fix(coding-agent): export package asset path helpers
closes #5415
2026-06-07 11:25:15 +02:00
Mario Zechner
8c6c8a4ef3 fix(agent): neutralize compaction summarization prompt
closes #5401
2026-06-07 11:19:10 +02:00
Mario Zechner
b8f6f660e7 fix(ai): honor OpenAI Responses developer role compat
closes #5456
2026-06-07 11:07:36 +02:00
Mario Zechner
59d7025668 fix(tui): position cursor for prompt history navigation
closes #5454
2026-06-07 10:56:09 +02:00
Mario Zechner
22ac2713a3 feat(coding-agent): export RPC extension UI types
closes #5455
2026-06-07 10:56:01 +02:00
Mario Zechner
e34b3b3803 docs(coding-agent): update tmux requirement docs
closes #5432
2026-06-07 10:34:57 +02:00
github-actions[bot]
98697d2d7a chore: approve contributor ItsumoSeito 2026-06-07 08:29:37 +00:00
Mario Zechner
ff3e9df5f5 fix(coding-agent): make trust input traversal portable 2026-06-06 22:32:35 +02:00
Mario Zechner
89a92207f1 feat(coding-agent): add project trust gating 2026-06-05 10:51:43 +02:00
Mario Zechner
db594d3a59 feat(coding-agent): show cache hit rate in footer 2026-06-05 10:51:43 +02:00
Mario Zechner
3e73204242 Merge pull request #5371 from Perlence/fix/skill-user-message-spacing
fix(coding-agent): add a space between the skill and user messages
2026-06-04 23:52:17 +02:00
Mario Zechner
b9bfa7ed46 Fix OpenRouter routing compat for custom providers
closes #5347
2026-06-04 23:50:01 +02:00
Mario Zechner
2808fc03f4 Merge pull request #5383 from xl0/instruct-pi-commit
docs: document commit message format
2026-06-04 23:48:19 +02:00
Mario Zechner
c52c22b39e Document issue view gh fields 2026-06-04 23:47:06 +02:00
Mario Zechner
c45787411e Fix tool expand hint punctuation styling
closes #5359
2026-06-04 23:44:25 +02:00
Mario Zechner
ca66adfe60 Add [Unreleased] section for next cycle 2026-06-04 18:31:35 +02:00
Mario Zechner
592c34c056 Release v0.78.1 2026-06-04 18:31:33 +02:00
Mario Zechner
1d33a8eb7a Update generated image model list 2026-06-04 18:31:11 +02:00
Mario Zechner
4f7d756df8 Exclude optional Gondolin example from root check 2026-06-04 18:27:18 +02:00
Mario Zechner
e0c2813a2a Audit unreleased changelog entries 2026-06-04 18:22:58 +02:00
Mario Zechner
f9ce0bf0e7 Update generated model lists 2026-06-04 18:22:58 +02:00
Alexey Zaytsev
e4d6f45efe docs: document commit message format 2026-06-03 23:15:15 -05:00
Sviatoslav Abakumov
e9a9322199 fix(coding-agent): add a space between the skill and user messages 2026-06-03 22:42:38 +04:00
Vegard Stikbakke
dc7b547f62 docs(coding-agent): remove containerization platform note 2026-06-03 15:55:33 +02:00
Vegard Stikbakke
86314bf38d docs: add containerization guide and Gondolin example (#5356) 2026-06-03 15:53:16 +02:00
Mario Zechner
564ad70fb8 Merge pull request #5333 from vastxie/zai-coding-cn-provider
feat(ai): add ZAI Coding Plan China provider
2026-06-03 01:07:01 +02:00
Mario Zechner
4ac20511be Merge pull request #5346 from mattiacerutti/fix/remove-codex-models
fix(ai): remove stale codex models
2026-06-03 00:54:27 +02:00
Armin Ronacher
ea3465a8e3 fix(coding-agent): move temporary extension cache (#5345) 2026-06-03 00:44:21 +02:00
Armin Ronacher
135fb545f9 fix(coding-agent): set auth file mode on creation 2026-06-03 00:32:54 +02:00
Mattia Cerutti
83afcdc24f fix(ai): remove stale codex models 2026-06-03 00:09:30 +02:00
Armin Ronacher
ba6e5298df fix(oauth): harden browser launch handling 2026-06-02 23:15:46 +02:00
Armin Ronacher
a98e087e5d fix(coding-agent): harden git package install paths 2026-06-02 18:20:35 +02:00
弎水
51df39b9b9 feat(ai): add ZAI Coding Plan China provider 2026-06-02 23:39:56 +08:00
Armin Ronacher
0462d44f56 doc: clarify wording in SECURITY.md 2026-06-02 17:24:41 +02:00
Armin Ronacher
e30b1b18d0 Added security file 2026-06-02 17:03:40 +02:00
Mario Zechner
25a4a8ed1e Add Ant Ling provider 2026-06-02 16:37:44 +02:00
Mario Zechner
6cb23f9b5d Fix HTML export URL sanitization 2026-06-02 16:32:18 +02:00
Mario Zechner
7e72ca47c8 Add MiniMax-M3 to direct minimax providers
closes #5313
2026-06-02 16:00:29 +02:00
Mario Zechner
276cb1bb89 Fix stale SDK stream options test after httpIdleTimeoutMs universalization 2026-06-02 15:53:37 +02:00
Mario Zechner
2125221bfc Fix OpenRouter Kimi reasoning compat
closes #5309
2026-06-02 15:36:52 +02:00
Mario Zechner
0d38e17b68 Fix empty self-rendered tool rows
closes #5299
2026-06-02 15:25:27 +02:00
Mario Zechner
6e269edfd0 Restore NVIDIA Qwen 3.5 122B model 2026-06-02 15:10:36 +02:00
Mario Zechner
6014801229 Add NVIDIA NIM provider 2026-06-02 15:09:36 +02:00
Mario Zechner
13898f048f Fix TUI tab width accounting
closes #5218
2026-06-02 13:54:47 +02:00
Mario Zechner
4c6c4482b4 Fix SDK embed ENOENT when package.json missing (closes #5226)
- Wrap package.json read in try/catch to handle missing file gracefully
- Use defaults (APP_NAME=pi, CONFIG_DIR_NAME=.pi, etc.) when package.json not found
- Preserves rebranding via piConfig when package.json exists
- Only ignores ENOENT; other errors still throw
2026-06-02 12:12:10 +02:00
Mario Zechner
7c08227019 Fix stale model references in AI tests after model regeneration
- Update GitHub Copilot tests to use available models (claude-haiku-4.5, gemini-2.5-pro)
- Update OpenRouter tests to use available models (google/gemini-2.5-flash, deepseek/deepseek-chat)
- Fix over-broad sed that incorrectly renamed OpenAI Responses gpt-4o test
- Remove nvidia.md from repo root
- Update packages/ai/CHANGELOG.md
2026-06-02 11:37:32 +02:00
Mario Zechner
7c531d0518 fix: apply httpIdleTimeoutMs to all providers, not just Codex Responses
The HTTP timeout setting (httpIdleTimeoutMs) was only used as a fallback for the
openai-codex-responses API. For other providers like openai-completions
(llama.cpp), the SDK default timeout (10 min) was used instead, ignoring the
user's disabled timeout setting.

Now httpIdleTimeoutMs applies universally as the default SDK request timeout
for all providers that support timeoutMs. Setting HTTP timeout = false (0)
correctly disables SDK timeouts across the board by sending max int32
(effectively infinite) instead of 0, since SDKs treat timeout=0 as immediate
timeout.

closes #5294
2026-06-02 11:26:36 +02:00
Mario Zechner
f58c1702cf fix: apply httpIdleTimeoutMs to all providers, not just Codex Responses
The HTTP timeout setting (httpIdleTimeoutMs) was only used as a fallback for the
openai-codex-responses API. For other providers like openai-completions
(llama.cpp), the SDK default timeout (10 min) was used instead, ignoring the
user's disabled timeout setting.

Now httpIdleTimeoutMs applies universally as the default SDK request timeout
for all providers that support timeoutMs. Setting HTTP timeout = false (0)
correctly disables SDK timeouts across the board.

closes #5294
2026-06-02 11:20:38 +02:00
Mario Zechner
afcf9ff0dc Merge pull request #5306 from xl0/system_prompt_options
Add system prompt options to extension commands
2026-06-02 11:09:30 +02:00
Alexey Zaytsev
8216cca5a7 Add system prompt options to extension commands 2026-06-01 15:42:56 -05:00
Mario Zechner
e56521e323 Add extension mode context 2026-06-01 18:32:03 +02:00
Mario Zechner
335e09ba0d Merge pull request #5264 from psoukie/fix/wsl-mntc-footer-branch-refresh
fix(coding-agent): refresh branch in footer on WSL /mnt repos
2026-06-01 18:07:52 +02:00
Mario Zechner
34719d3f4f Fix Bedrock blank text blocks
closes #4975
2026-06-01 18:04:04 +02:00
Mario Zechner
893ba99466 Merge pull request #5235 from nicobailon/feat/issue-5129-tui-overlay-focus
Feat/issue 5129 tui overlay focus
2026-06-01 17:58:01 +02:00
github-actions[bot]
ce065efa7e chore: approve contributor vastxie 2026-06-01 15:03:47 +00:00
Nico Bailon
91a2f86600 fix(tui): harden overlay focus restoration 2026-05-31 21:20:33 -07:00
Pavel Soukenik
654e4529d5 fix: refresh branch in footer on WSL /mnt repos 2026-05-31 17:12:11 -07:00
Mario Zechner
f429ddbffd Fix OpenAI GPT-5.5 thinking metadata
closes #5243
2026-06-01 00:39:48 +02:00
github-actions[bot]
d4fa41ae3c chore: approve contributor psoukie 2026-05-31 22:22:22 +00:00
Mario Zechner
b177333026 Merge pull request #5221 from PriNova/fix/openrouter-system-role
Fix OpenRouter reasoning instruction role
2026-06-01 00:09:58 +02:00
Mario Zechner
ad9081b7fa Merge pull request #5251 from yzhg1983/fix/anthropic-opus-temperature
fix(ai): suppress deprecated temperature param for Claude Opus 4.7+
2026-06-01 00:03:07 +02:00
Michael Yu
40832d1957 fix(ai): suppress deprecated temperature param for Claude Opus 4.7+ 2026-05-31 16:20:22 +08:00
Nico Bailon
82f29ea442 docs(coding-agent): expand overlay focus demo
Update the overlay QA focus demo to exercise three visible overlays with text input, focus cycling, per-panel dismissal, and visual focus ordering.
2026-05-30 13:29:36 -07:00
Nico Bailon
735ccbd00f fix(tui): release overlay focus to explicit targets
Add an explicit overlay unfocus target so callers can move input to the editor or another component while overlays remain visible. Align fallback overlay focus with visual focus order and cover blocked replacement release, null targets, and multi-overlay cycling.
2026-05-30 13:29:36 -07:00
Nico Bailon
5d9b28ee43 fix(tui): keep focused overlays interactive after UI
Preserve a focused visible overlay as the input owner across extension custom UI replacement, while letting the replacement receive and close its own input before focus is restored. Fixes #5129.
2026-05-30 13:29:36 -07:00
Mario Zechner
3911d6f5cd fix(coding-agent): stream large session files
closes #5231
2026-05-30 21:21:42 +02:00
PriNova
74d81bd363 Fix OpenAI completions developer role test 2026-05-30 12:24:19 +02:00
PriNova
b55fe1bbd4 Fix OpenRouter reasoning instruction role 2026-05-30 11:15:30 +02:00
Mario Zechner
dbb9911a54 Add [Unreleased] section for next cycle 2026-05-30 01:47:40 +02:00
Mario Zechner
0897f175e4 Release v0.78.0 2026-05-30 01:47:37 +02:00
Mario Zechner
36515a3793 Document release npm age gate override 2026-05-30 01:47:26 +02:00
Mario Zechner
886fa6cd11 Audit unreleased changelog entries 2026-05-30 01:27:28 +02:00
Mario Zechner
c1633e609d Clarify hardware cursor docs
closes #5200
2026-05-30 01:20:14 +02:00
Mario Zechner
cfefc30cf1 Merge pull request #5195 from yzhg1983/private/fix-startup-input-queue
fix(coding-agent): buffer early input before prompt loop
2026-05-30 01:15:45 +02:00
Mario Zechner
0ffa590a33 Fix GitLab Duo thinking metadata
closes #5201
2026-05-30 01:08:18 +02:00
Mario Zechner
9c4a3f3518 Fix ANSI wrapping stack overflow
closes #5185
2026-05-30 01:02:48 +02:00
Mario Zechner
31b961f2ac fix(coding-agent): sync clipboard binary archive deps
closes #5184
2026-05-30 00:52:03 +02:00
Mario Zechner
ed1952c5ca Merge pull request #5189 from mpazik/feat/tool-title-hyperlinks
OSC 8 hyperlinks file paths in tool titles
2026-05-30 00:05:22 +02:00
Mario Zechner
5992a62baa Merge pull request #5202 from xl0/export-args-parse
feat(coding-agent): Export CLI argument parser
2026-05-29 23:45:22 +02:00
Mario Zechner
a213abb97e Fix OpenRouter Kimi K2.6 developer role
closes #5159
2026-05-29 23:41:14 +02:00
Mario Zechner
778f519b23 Remove leading spaces from resume session hint 2026-05-29 23:30:02 +02:00
Mario Zechner
ba2d313858 fix(ai): handle OpenCode Kimi reasoning params 2026-05-29 23:27:48 +02:00
Mario Zechner
a36a132c70 fix(ai): abort Codex SSE body reads 2026-05-29 23:20:18 +02:00
Mario Zechner
7921ae499a Require explicit provider API keys 2026-05-29 22:36:05 +02:00
Mario Zechner
01a8c2d62c Merge pull request #5196 from earendil-works/fix/opencode-thinking-requests
fix(ai): handle OpenCode reasoning params
2026-05-29 22:32:13 +02:00
Alexey Zaytsev
dbcfc16741 feat(coding-agent): Export CLI argument parser 2026-05-29 13:22:46 -05:00
Michael Yu
edd1212200 fix(coding-agent): buffer early input before prompt loop 2026-05-29 23:03:03 +08:00
Marek Pazik
9d2bceba5d fix(tui): forward OSC 8 hyperlinks under tmux when the client supports them
detectCapabilities previously disabled hyperlinks under tmux unconditionally. tmux re-emits OSC 8 to the outer terminal only when the attached client advertises the 'hyperlinks' feature in client_termfeatures, and strips them otherwise.

Probe the running server with 'tmux display-message -p #{client_termfeatures}' and enable hyperlinks only when the feature is listed. The probe fails closed: any error (no tmux/server, timeout, old tmux) yields false. images stays null; only the hyperlink decision changed. The probe is injected into detectCapabilities so it stays testable without spawning a process.
2026-05-29 22:02:25 +10:00
Marek Pazik
42ce989a62 feat(coding-agent): hyperlink file paths in tool titles
Wrap the path shown in read/write/edit/ls tool titles in an OSC 8 file:// hyperlink when the terminal advertises hyperlink support.
The link target is always the absolute path, while the visible text stays relative/home-shortened and line annotations (e.g. :120-140) are kept out of the target.
2026-05-29 22:02:25 +10:00
Armin Ronacher
4faac05419 fix(ai): handle OpenCode reasoning params 2026-05-29 12:21:37 +02:00
Mario Zechner
ce554ad3de Add startup session name flag
closes #5153
2026-05-29 11:40:58 +02:00
Mario Zechner
0c448e12e8 Merge pull request #5176 from yzhg1983/myu003/resume-hint-on-exit
feat(coding-agent): print resume hint on interactive exit
2026-05-29 11:16:31 +02:00
Mario Zechner
3d1d18fa96 Merge pull request #5178 from stephanmck/bedrock-custom-headers-pr
ai: add custom-header support to Bedrock provider
2026-05-29 11:15:18 +02:00
Mario Zechner
c122bcde44 Merge pull request #5167 from xl0/expose-convert-png
feat(coding-agent): Export convertToPng for extensions
2026-05-29 11:13:42 +02:00
Stephan Schneider
7619aaefa9 ai: add custom-header support to Bedrock provider
Honour StreamOptions.headers in the Bedrock provider by attaching a
Smithy build-step middleware that merges caller headers into the
request before SigV4 signing. Reserved headers (x-amz-*, authorization,
host) are silently skipped to preserve signing and the bearer-token
auth path. No-op when no headers are supplied. Updates the
StreamOptions.headers JSDoc to drop the Bedrock caveat.
2026-05-29 10:21:44 +02:00
Michael Yu
17e9e87576 feat(coding-agent): print resume hint on interactive exit 2026-05-29 15:56:42 +08:00
Alexey Zaytsev
7a5dc0d0db feat(coding-agent): Export convertToPng for extensions 2026-05-29 00:44:51 -05:00
smoose
c8df99a795 fix(tui): keep hardware cursor marker during slash-command autocomplete
Remove the !autocompleteState guard so CURSOR_MARKER is still emitted
while the slash-command menu is visible. This lets the TUI position the
hardware cursor correctly, which fixes IME candidate-window placement
for CJK input methods.
2026-05-29 10:00:59 +08:00
github-actions[bot]
7be8a10d23 chore: approve contributor rolfvreijdenberger 2026-05-28 23:26:43 +00:00
Armin Ronacher
b6b0f6923f feat(ci): Actually bump setup-bun to 2.2.0 2026-05-29 01:03:12 +02:00
Armin Ronacher
20bcab26e0 feat(ci): Update setup-bun to 2.2.0 2026-05-29 00:51:11 +02:00
Armin Ronacher
93600d8980 fix(release): align package repository metadata 2026-05-29 00:44:58 +02:00
Armin Ronacher
f3b4e1285c fix(release): upgrade npm for trusted publishing 2026-05-29 00:36:43 +02:00
Armin Ronacher
abf07d0c4d Add [Unreleased] section for next cycle 2026-05-29 00:19:23 +02:00
Armin Ronacher
8322745e28 Release v0.77.0 2026-05-29 00:19:06 +02:00
Mario Zechner
f29472c387 Audit unreleased changelog entries 2026-05-29 00:06:06 +02:00
Mario Zechner
0127caea7b Fix OpenRouter DeepSeek V4 xhigh reasoning
closes #4801
2026-05-28 23:56:59 +02:00
Mario Zechner
f9fa077bbb Fix startup timing attribution
closes #4829
2026-05-28 23:53:20 +02:00
Mario Zechner
9380d5f2e4 feat(coding-agent): add exclude tools option closes #5109 2026-05-28 23:49:18 +02:00
Mario Zechner
1ab2899800 Remove unavailable tool preference guideline
Closes #5132
2026-05-28 23:37:13 +02:00
Armin Ronacher
4b4641c6b0 fix(coding-agent): scope custom session dir lookups 2026-05-28 23:25:33 +02:00
Mario Zechner
b64f3f5eae fix(coding-agent): run extension cleanup and restore terminal on signal exits
Signal-triggered shutdown (SIGTERM/SIGHUP) now emits session_shutdown
before any terminal writes, and SIGHUP no longer hard-exits, so extension
resources (e.g. sockets) are released and the terminal is restored even
when the terminal is gone. A genuinely dead terminal still exits without
hot-spinning via the EIO error path.

closes #5080
2026-05-28 23:19:27 +02:00
Mario Zechner
d1fb34bc8d fix(ai): use valid synthetic Responses message ids closes #5148 2026-05-28 23:13:15 +02:00
Mario Zechner
3f1ce9b6e9 fix(ai): avoid duplicate Codex replay message ids closes #5148 2026-05-28 23:11:05 +02:00
github-actions[bot]
f9832ccb82 chore: approve contributor stephanmck 2026-05-28 20:54:28 +00:00
Mario Zechner
97ef317389 Fix Kimi and Xiaomi model metadata
Closes #5075, closes #5078. Refs #5087.
2026-05-28 22:19:47 +02:00
github-actions[bot]
7e70886652 chore: approve contributor MichaelYochpaz 2026-05-28 20:05:12 +00:00
Mario Zechner
bfa3d1fa60 Update Claude Opus and GPT thinking metadata 2026-05-28 21:51:55 +02:00
Armin Ronacher
ae50dec121 chore(release): publish packages from CI 2026-05-28 17:43:05 +02:00
Mario Zechner
53ca936adb Update clipboard native addon
closes #5028
2026-05-28 16:59:44 +02:00
Mario Zechner
b63d26332f Finish harness tool registry semantics 2026-05-28 12:34:02 +02:00
Mario Zechner
458a7bc27c Fix Anthropic empty thinking signature replay
closes #4464
2026-05-28 12:04:33 +02:00
Armin Ronacher
3e9f717445 fix(coding-agent): make config env references explicit
closes #5095
2026-05-28 11:58:38 +02:00
Mario Zechner
5b31ffd744 Abort session work during dispose 2026-05-28 11:35:19 +02:00
Vegard Stikbakke
9d5fb70b7e feat(ai): add Codex device code login (#4911) 2026-05-28 11:09:33 +02:00
Mario Zechner
edd26444af Expose tool prompt guidelines to extensions
closes #4879
2026-05-28 11:03:25 +02:00
Mario Zechner
17d39ccfd4 Add changelog entry for PR 5115 2026-05-28 10:48:11 +02:00
Mario Zechner
8e77f8797b Merge pull request #5115 from DanielThomas/dannyt/sendUserMessage-agent-end
fix(coding-agent): drain follow-ups queued during agent_end
2026-05-28 10:47:31 +02:00
Mario Zechner
e43f2c3d0a Clarify PR review worktree rules 2026-05-28 10:31:03 +02:00
Danny Thomas
a29a7902e9 fix(coding-agent): drain follow-ups queued during agent_end
When an extension queues a follow-up during `agent_end` it
gets stuck on the follow-up queue until after the next user
message.

Add a hasQueuedMessages() check to _handlePostAgentRun
so the existing while/continue loop drains them.

- One-line fix in _handlePostAgentRun
- Integration test in agent-session-queue
- git-merge-and-resolve example extension with tests
2026-05-28 18:27:39 +10:00
Mario Zechner
5f00f3f50f Merge pull request #5091 from earendil-works/fix/kitty-da-negotiation
fix(tui): harden keyboard protocol negotiation
2026-05-28 10:23:41 +02:00
Mario Zechner
cbe8625528 Fix input event streaming behavior semantics 2026-05-28 10:13:06 +02:00
Mario Zechner
1fc57a5d5b Merge pull request #5107 from DanielThomas/dannyt/inputevent-streamingbehavior
feat(coding-agent): expose streamingBehavior on InputEvent
2026-05-28 10:11:30 +02:00
Danny Thomas
bcea4b2e27 feat(coding-agent): expose streamingBehavior on InputEvent
Add streamingBehavior to InputEvent so extensions can distinguish
idle prompts from mid-stream steers and queued follow-ups.

- Add streamingBehavior field to InputEvent type
- Thread it through ExtensionRunner.emitInput() and AgentSession.prompt()
- Add streaming-aware input gate example with tests
- Document in extensions.md
2026-05-28 15:52:38 +10:00
Armin Ronacher
b85bf65678 fix(coding-agent): restore diff code block highlighting
closes #5092
2026-05-28 00:49:27 +02:00
Armin Ronacher
99aec8e02b test(tui): reduce keyboard negotiation coverage 2026-05-27 23:03:13 +02:00
Armin Ronacher
1e168a89c5 docs(coding-agent): fix development AGENTS link
Closes #5041
2026-05-27 22:51:30 +02:00
Armin Ronacher
6ab62a06d7 fix(tui): harden keyboard protocol negotiation 2026-05-27 22:23:39 +02:00
Armin Ronacher
39a26c84f8 Add [Unreleased] section for next cycle 2026-05-27 22:04:01 +02:00
Armin Ronacher
706f8720f8 Release v0.76.0 2026-05-27 22:03:21 +02:00
Armin Ronacher
16dc525e7b fix(build): resolve internal packages from workspace dist 2026-05-27 21:56:55 +02:00
Armin Ronacher
41d28a9232 docs(changelog): audit unreleased entries 2026-05-27 21:40:07 +02:00
Armin Ronacher
52dc08c1f7 feat(session): Explicit session id naming (#5076) 2026-05-27 21:32:10 +02:00
Armin Ronacher
7c02a55632 fix(ai): timeout Codex SSE header stalls 2026-05-27 21:24:51 +02:00
Armin Ronacher
91b46c2ba9 fix(ai): avoid stale Cerebras test model 2026-05-27 20:57:00 +02:00
xu0o0
701801dec2 fix(tui): align input word segmentation with editor (#5068) 2026-05-27 20:50:04 +02:00
Armin Ronacher
61babc24cf feat(rpc): add excludeFromContext flag to bash command (closes #5039) 2026-05-27 18:34:42 +02:00
Sviatoslav Abakumov
4bbe2959bd fix(tui): provide the JetBrains terminal capabilities (#5037) 2026-05-27 12:20:44 +02:00
xu0o0
b62776e4a2 fix(tui): preserve ASCII punctuation word boundaries with Intl.Segmenter (#5067) 2026-05-27 12:12:10 +02:00
Armin Ronacher
493efd422d fix(codex): timeouts for websockets (#4979) 2026-05-27 12:11:29 +02:00
Armin Ronacher
26f1e00f71 fix(ai): use hyphenated Codex session header
closes #4967
2026-05-27 01:11:40 +02:00
Armin Ronacher
2531fc130d fix(ui): preserve user ordered-list markers (closes #5013) 2026-05-27 01:03:33 +02:00
Armin Ronacher
59ec8003db fix(coding-agent): bypass age gates for self-update
closes #4929
2026-05-27 00:59:09 +02:00
xu0o0
4402100830 fix(tui): leverage Intl.Segmenter for proper Unicode word boundaries (#5022) 2026-05-27 00:28:47 +02:00
Armin Ronacher
8fb1e877cd fix(ai): disable hidden provider 429 retries (#4991) 2026-05-26 23:41:58 +02:00
Mario Zechner
96f0edd02b Count user image tokens in context estimates
closes #4983
2026-05-26 18:46:54 +02:00
github-actions[bot]
7c2775f6f6 chore: approve contributor DanielThomas 2026-05-26 07:39:20 +00:00
Armin Ronacher
71446c6c2b fix(ai): correct Codex Spark context window
closes #4969
2026-05-26 09:08:40 +02:00
Armin Ronacher
fa1180b6b7 fix(ai): detect Poolside context overflow
Closes #4943
2026-05-26 08:58:21 +02:00
Armin Ronacher
fc8a155901 fix(ai): honor Codex Responses maxRetries 2026-05-25 12:30:04 +02:00
Armin Ronacher
3eb002766f fix(tui): enable OSC 8 for Windows Terminal (closes #4923) 2026-05-25 00:55:39 +02:00
Armin Ronacher
e007fcd0d2 fix(rpc): reject pending requests on child process exit
closes #4764
2026-05-25 00:40:07 +02:00
Armin Ronacher
ce0e801d8e fix(coding-agent): retry RPC stdout backpressure
closes #4897
2026-05-24 22:59:35 +02:00
Armin Ronacher
9600ded922 revert: fix rpc stdout backpressure
This reverts commit d0d1d8edca.
2026-05-24 11:50:53 +02:00
Armin Ronacher
d0d1d8edca fix(rpc): respect stdout backpressure
closes #4897
2026-05-24 11:28:02 +02:00
Mario Zechner
fc51a40d02 Merge pull request #4922 from earendil-works/horrifying-terminal-hack
fix(tui): detect Apple Terminal Shift+Enter
2026-05-23 20:19:57 +02:00
Armin Ronacher
30b3ab3532 fix(tui): remove native modifier escape hatch 2026-05-23 15:32:38 +02:00
Armin Ronacher
c5181a266e fix(tui): detect Apple Terminal Shift+Enter 2026-05-23 15:09:01 +02:00
Armin Ronacher
4a98f748bb chore(tui): remove unused xterm dependency 2026-05-23 14:18:49 +02:00
Armin Ronacher
15f1dea8df fix(coding-agent): disable managed extension peer resolution
closes #4907
2026-05-23 14:15:00 +02:00
Mario Zechner
30f48feae7 Add [Unreleased] section for next cycle 2026-05-23 12:08:14 +02:00
Mario Zechner
89ba72c35d Update release instructions and image models 2026-05-23 12:07:52 +02:00
Mario Zechner
83a227aed3 Update release instructions and generated models 2026-05-23 12:02:35 +02:00
Mario Zechner
ea2b70ddd9 Release v0.75.5 2026-05-23 11:58:31 +02:00
Mario Zechner
98477f2ee7 Clarify release smoke test instructions 2026-05-23 11:54:36 +02:00
Mario Zechner
b9566fc111 Audit unreleased changelog entries 2026-05-23 11:48:27 +02:00
Mario Zechner
373bd1282e Collapse read output by default
closes #4916
2026-05-23 11:40:54 +02:00
Mario Zechner
8100046cb8 Finish async tool cleanup 2026-05-23 11:36:41 +02:00
Mario Zechner
c4f86e3992 Merge remote-tracking branch 'origin/main' into pr-4756-squash-cleanup
# Conflicts:
#	scripts/build-binaries.sh
2026-05-23 10:52:28 +02:00
Mario Zechner
3f89350cef fix(coding-agent): ship clipboard sidecar in bun binaries
closes #4307
2026-05-23 10:47:35 +02:00
Mario Zechner
ba09f1c9e0 Refine async tool control flow 2026-05-23 10:30:00 +02:00
Mario Zechner
2e1f07bac2 fix(coding-agent): avoid invalid footer home abbreviation
closes #4878
2026-05-23 09:53:36 +02:00
Mario Zechner
e9146a5ff7 fix(coding-agent): use async operations in tools 2026-05-23 09:50:03 +02:00
Armin Ronacher
c85dbb1620 fix(coding-agent): reconcile pinned git update refs
closes #4869
2026-05-23 00:16:07 +02:00
Armin Ronacher
d80bcc367e test(ai): avoid hardcoded Fireworks router id 2026-05-22 22:59:10 +02:00
Armin Ronacher
42379a37bd fix(coding-agent): add OpenCode session headers
closes #4847
2026-05-22 22:22:55 +02:00
Mario Zechner
9b62f1f87c Fix Anthropic eager tool input compat test 2026-05-22 18:40:31 +02:00
Mario Zechner
d801d88a11 Support adaptive thinking for Anthropic-compatible aliases
closes #4790
2026-05-22 18:30:51 +02:00
Armin Ronacher
7002c68f8b fix(ai): declare Bedrock Smithy HTTP handler dependency
closes #4842
2026-05-22 16:50:19 +02:00
Mario Zechner
c841a6c78f Clean up OAuth device-code callbacks 2026-05-22 15:51:36 +02:00
Armin Ronacher
b3ed545938 fix(export-html): escape quotes in exported attributes
closes #4832
2026-05-22 15:48:21 +02:00
Mario Zechner
60e2e4332d Merge pull request #4898 from haoqixu/fix-4761
fix(coding-agent): use the right basedir for patterns
2026-05-22 15:22:45 +02:00
Mario Zechner
11e868b790 Merge pull request #4788 from earendil-works/refactor-device-code-login
feat(ai): refactor device code login for copilot
2026-05-22 15:21:42 +02:00
root
11c3a3a399 fix(ci): satisfy ts-imports and regenerate image models
Some checks failed
CI / build-check-test (push) Has been cancelled
npm audit / audit (push) Has been cancelled
Use .ts relative import in webui server for pi's check:ts-imports policy
and regenerate image-models.generated.ts to remove duplicate model keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:45:00 +08:00
root
092279126b fix(webui): pin frontend deps for CI check:pinned-deps
Some checks failed
CI / build-check-test (push) Has been cancelled
Use exact versions in webui frontend package.json to satisfy the
monorepo pinned dependency policy enforced by GitHub Actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:43:08 +08:00
root
0a91cc99d0 chore: add sproutclaw git workflow and track local extensions
Some checks failed
CI / build-check-test (push) Has been cancelled
Document main/upstream-sync/feature branch strategy, add sync/push
scripts, track .pi/agent extensions and webui in git, and disable
startup changelog via showChangelogOnStartup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:38:20 +08:00
root
a8daa1d8ff merge: sync upstream pi-mono main into sproutclaw
Some checks failed
CI / build-check-test (push) Has been cancelled
Merge upstream/main (195 commits) while keeping sproutclaw-specific
README, webui workflow docs, and restored upstream .pi prompt templates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 20:05:31 +08:00
haoqixu
baf4028fb9 fix(coding-agent): use the right basedir for patterns 2026-05-22 18:33:26 +08:00
Mario Zechner
7426ce977d Tighten AGENTS.md and extract LLM provider checklist to skill
- Tighten all sections (279 -> 159 lines) without dropping rules.
- Reorganize: git rules moved next to issues/PRs.
- Defer contributor gate details to CONTRIBUTING.md.
- Replace stale npx tsx test command with node (strip-only mode); add ./test.sh + e2e warning.
- Releasing: explicit WebAuthn briefing step, no bash timeout, stop on partial publish failure.
- Move LLM provider checklist to .pi/skills/add-llm-provider.md.
2026-05-22 12:04:12 +02:00
Armin Ronacher
c100620bf4 fix(coding-agent): Clean up Path Handling (#4873) 2026-05-22 11:25:15 +02:00
Mario Zechner
bf56a86e1e fix(coding-agent): reconcile git package refs
closes #4870
2026-05-22 11:13:26 +02:00
Mario Zechner
1a2a536dba chore: update PR prompt template 2026-05-22 11:11:38 +02:00
Vegard Stikbakke
c554364c2a feat(ai): refactor device code login for copilot 2026-05-22 08:54:18 +02:00
Mario Zechner
ced73b396a docs: note Node 20 rescue release
Closes #4876
2026-05-22 00:28:49 +02:00
Mario Zechner
11c3da4f74 fix(ai): set bedrock claude default max tokens
closes #4848
2026-05-21 23:27:40 +02:00
Mario Zechner
b0c5554902 docs: document safe development install
closes #4868
2026-05-21 20:44:05 +02:00
github-actions[bot]
1367164204 chore: approve contributor AJM10565 2026-05-21 18:34:39 +00:00
Armin Ronacher
2171cefbaa test(coding-agent): update bash truncation expectation 2026-05-21 18:35:48 +02:00
Mario Zechner
f953067814 fix(coding-agent): correct bash truncation line count
closes #4818
2026-05-21 16:55:22 +02:00
Mario Zechner
6a38d7ff6e Merge pull request #4813 from julien-c/align-pi-test-env-vars
chore: add HF_TOKEN to pi-test.ps1 --no-env unset list
2026-05-21 12:21:17 +02:00
Mario Zechner
a93c6a5266 Merge pull request #4830 from Perlence/fix/theme-picker-content-names
fix(coding-agent): list themes by content name
2026-05-21 12:18:23 +02:00
Mario Zechner
60a55a2399 feat(coding-agent): expose edit tool unified patch
closes #4821
2026-05-21 12:15:39 +02:00
Mario Zechner
7dad27e5f2 fix(coding-agent): avoid duplicate bash truncation path
closes #4819
2026-05-21 12:04:14 +02:00
Mario Zechner
4868222e34 chore(tui): replace koffi with Windows VT input helper
closes #4480
2026-05-21 00:21:21 +02:00
Mario Zechner
385a11bf53 docs: document dependency install security 2026-05-20 22:53:38 +02:00
Sviatoslav Abakumov
088987b200 fix(coding-agent): list themes by content name 2026-05-21 00:10:50 +04:00
Julien Chaumond
3b37c9e0a1 chore: add HF_TOKEN to pi-test.ps1 --no-env unset list
Aligns the PowerShell script with pi-test.sh, which already unsets HF_TOKEN.

Co-Authored-By: julien-agent <Agents+cyolo@huggingface.co>
2026-05-20 18:12:00 +02:00
Mario Zechner
b8326ca935 Add [Unreleased] section for next cycle 2026-05-20 16:26:05 +02:00
Mario Zechner
3533843dd7 Release v0.75.4 2026-05-20 16:11:46 +02:00
Mario Zechner
144b93861f docs: audit unreleased changelog entries 2026-05-20 16:10:23 +02:00
Mario Zechner
cd2ae1b544 docs(coding-agent): document fnm npm shim fix
closes #4793
2026-05-20 16:05:33 +02:00
Mario Zechner
beefa6a4fe chore: add bun local release smoke install 2026-05-20 16:02:30 +02:00
Mario Zechner
17cc86a479 chore: harden dependency workflows 2026-05-20 15:57:41 +02:00
Mario Zechner
aa4adac766 chore: run pi-test through tsx 2026-05-20 15:55:31 +02:00
Mario Zechner
8011e20932 Merge pull request #4799 from Perlence/fix/fork-session-id
fix(coding-agent): keep fork session id aligned
2026-05-20 15:26:12 +02:00
Mario Zechner
29851d01e4 chore: enforce npm dependency age gate 2026-05-20 14:55:38 +02:00
Mario Zechner
5b7c5d2157 docs: recommend scriptless npm installs 2026-05-20 14:47:18 +02:00
Mario Zechner
a3ebcd2322 fix(coding-agent): disable scripts during self-update 2026-05-20 14:42:45 +02:00
Mario Zechner
715c82ce04 chore: shrinkwrap coding agent release deps 2026-05-20 14:19:40 +02:00
Sviatoslav Abakumov
dce24ac9ff fix(coding-agent): keep fork session id aligned 2026-05-20 15:54:11 +04:00
Mario Zechner
fec3871432 docs: require local release smoke test 2026-05-20 13:38:40 +02:00
Mario Zechner
ea4eab15cf chore: add local release and dependency guards 2026-05-20 13:06:28 +02:00
Mario Zechner
2e02c74dcb chore: pin dependencies and use native TypeScript 2026-05-20 12:46:17 +02:00
Armin Ronacher
849f9d9c5a fix(coding-agent): configure HTTP idle timeout (#4759) 2026-05-20 12:45:00 +02:00
Mario Zechner
f1f7cd50c3 test: speed up provider payload coverage 2026-05-20 12:37:58 +02:00
github-actions[bot]
ac58c6f449 chore: approve contributor mbazso 2026-05-20 09:54:27 +00:00
Armin Ronacher
9fdc54b6f0 fix(package-manager): ignore managed npm folders in cloud sync
closes #4763
2026-05-20 11:40:46 +02:00
Mario Zechner
04e93af5c4 chore: run pi test script with node 2026-05-20 02:26:10 +02:00
Mario Zechner
b141e1fa24 chore: remove web-ui workspace 2026-05-20 02:26:09 +02:00
Mario Zechner
559a71683d Merge pull request #4775 from xl0/xl0/export-image-resize-utils
Export image resize utilities
2026-05-20 02:13:44 +02:00
Mario Zechner
9e64c72386 Merge branch 'main' into xl0/export-image-resize-utils 2026-05-20 02:13:35 +02:00
Armin Ronacher
23b361cfd0 fix(tui): initialize loader before starting indicator 2026-05-20 01:16:49 +02:00
Alexey Zaytsev
e78f1a3dac Export image resize utilities 2026-05-19 18:12:17 -05:00
Armin Ronacher
8a6775bbe6 chore: remove redundant erasable type check 2026-05-20 00:06:37 +02:00
Armin Ronacher
ae9450dc51 chore(ts): use source import extensions 2026-05-20 00:04:03 +02:00
Mario Zechner
06c6c324d7 chore: enforce erasable TypeScript syntax 2026-05-19 23:15:39 +02:00
Mario Zechner
48b6510c18 fix(coding-agent): clarify pi docs path resolution closes #4752 2026-05-19 18:01:10 +02:00
Mario Zechner
b944827623 fix(agent): stop tool preflight after extension abort
closes #4276
2026-05-19 13:10:03 +02:00
Armin Ronacher
f4f0ac7ada feat(coding-agent): show update notes (#4724) 2026-05-19 12:08:13 +02:00
Mario Zechner
2787b601d7 fix(ai): stop defaulting max token request caps
closes #4675
2026-05-19 11:49:44 +02:00
Mario Zechner
5e8f0cf45e chore: update issue analysis prompt 2026-05-19 11:27:35 +02:00
Mario Zechner
7be75baded fix(ai): clamp OpenAI prompt cache keys
closes #4720
2026-05-19 10:51:11 +02:00
Mario Zechner
725e2b7b2a docs(agent): clarify harness stream settlement 2026-05-19 10:43:06 +02:00
Mario Zechner
c685b27369 fix(coding-agent): mark retrying agent end events 2026-05-19 10:33:16 +02:00
Mario Zechner
32bcdc9739 fix(coding-agent): simplify agent session settlement 2026-05-19 09:53:20 +02:00
Mario Zechner
6a21a52d91 docs: add ad-hoc script guidance 2026-05-19 09:53:20 +02:00
Armin Ronacher
29b3417a7a fix(windows): hide bash helper consoles
closes #4699
2026-05-18 23:21:22 +02:00
Mario Zechner
b1893b3d77 fix(agent): preserve oversized tail output with trailing newline
closes #4715
2026-05-18 23:05:34 +02:00
Mario Zechner
93ecdbea35 fix(coding-agent): improve subagent parallel summaries closes #4710 2026-05-18 22:52:59 +02:00
Mario Zechner
7670563374 Merge pull request #4664 from yzhg1983/fix/share-tool-sidebar-navigation
fix(coding-agent): scroll shared tool entries to rendered tool calls
2026-05-18 22:21:41 +02:00
Mario Zechner
aad8cf6600 Merge pull request #4709 from herrnel/fix/apply-explicit-fences-to-default-prompt
Fix(coding-agent) Updated default prompt to also use xml boundaries instead of ## for clearer file seperation.
2026-05-18 22:21:01 +02:00
Armin Ronacher
dafcf61a34 fix: align theme truecolor detection 2026-05-18 21:56:48 +02:00
Armin Ronacher
f10cf57e96 feat(coding-agent): improve terminal theme detection 2026-05-18 18:28:08 +02:00
Armin Ronacher
479024603e fix(coding-agent): set explicit theme text colors 2026-05-18 18:04:55 +02:00
Nelson Herrera
7577d3b8d5 Updated the default system prompt to also use xml boundaries instead of using ## so that agents are less likely to ingest a prompt with unclear boundaries. 2026-05-18 08:54:16 -07:00
Armin Ronacher
137547a470 docs(coding-agent): add uninstall instructions 2026-05-18 12:47:33 +02:00
Mario Zechner
4943c1d62c Add [Unreleased] section for next cycle 2026-05-18 11:59:24 +02:00
Mario Zechner
a7d8dd3d5d Release v0.75.3 2026-05-18 11:58:34 +02:00
Mario Zechner
967fb4d8b9 fix(coding-agent): disable undici http2
Fixes #4681. Fixes #4682.
2026-05-18 11:57:51 +02:00
Mario Zechner
0c4d704a7b Add [Unreleased] section for next cycle 2026-05-18 11:47:16 +02:00
Mario Zechner
ea713ba174 Release v0.75.2 2026-05-18 11:46:23 +02:00
Mario Zechner
92e7f13dc0 chore(coding-agent): audit unreleased changelog 2026-05-18 11:45:39 +02:00
Mario Zechner
64150668b8 fix(coding-agent): use cross-spawn for Windows shims
closes #4665
2026-05-18 11:30:41 +02:00
Mario Zechner
b8f51957a0 fix(ai): add Xiaomi reasoning replay compat
closes #4678
2026-05-18 11:15:20 +02:00
Armin Ronacher
dbadffdfae chore(prompts): disclose wr-generated comments 2026-05-18 11:04:45 +02:00
github-actions[bot]
0f066367bf chore: approve contributor josephyoung 2026-05-18 08:52:47 +00:00
Mario Zechner
936f55ba9b Merge pull request #4661 from dmasiero/fix-bun-undici-install-clean
fix(coding-agent): guard undici install under Bun
2026-05-18 10:13:26 +02:00
Mario Zechner
ddb0f69a2f Merge branch 'main' into fix-bun-undici-install-clean 2026-05-18 10:13:16 +02:00
Armin Ronacher
a294cdafd9 fix(coding-agent): unblock Windows external editor
closes #4612
2026-05-18 09:49:24 +02:00
Armin Ronacher
c65177370b fix(coding-agent): allow Windows pnpm self-update
closes #4157
2026-05-18 09:14:12 +02:00
Armin Ronacher
96cad24a07 fix(coding-agent): detect pnpm v11 self-update installs
closes #4647
2026-05-18 08:56:24 +02:00
Armin Ronacher
89b64d9dd5 fix(coding-agent): avoid Windows self-update native locks
closes #4157
2026-05-18 08:39:07 +02:00
Michael Yu
64248840f8 fix(coding-agent): scroll shared tool entries to rendered tool calls 2026-05-18 10:43:21 +08:00
Doug Masiero
363214bd32 fix(coding-agent): guard undici install under Bun
fixes #4657
2026-05-17 21:58:27 -04:00
Mario Zechner
93b2e7fae7 Add [Unreleased] section for next cycle 2026-05-18 02:01:57 +02:00
Mario Zechner
73a61654af Release v0.75.1 2026-05-18 02:01:03 +02:00
Mario Zechner
f1218fa8aa chore: audit unreleased changelogs 2026-05-18 02:00:07 +02:00
Mario Zechner
085230373f fix(coding-agent): align undici fetch globals
closes #4650

closes #4652

closes #4653
2026-05-18 01:56:14 +02:00
Mario Zechner
9e2bfc7c40 fix(ai): skip unknown bedrock content blocks
closes #4223
2026-05-18 01:42:18 +02:00
Mario Zechner
730f2f1046 Merge pull request #4243 from samjonester/samjonester/config-selector-dynamic-height
config selector: scale maxVisible to terminal height
2026-05-18 01:20:11 +02:00
Mario Zechner
52e13870a1 fix(ai): prefix HTTP status codes onto Azure OpenAI and OpenAI Responses error messages so auto-retry fires on 5xx/429
closes #4232
2026-05-18 01:11:23 +02:00
Mario Zechner
21d80deda2 fix(ai): normalize opencode go reasoning replay
closes #4251
2026-05-18 01:11:23 +02:00
Armin Ronacher
ceacacaa45 chore: add pi-test batch wrapper 2026-05-18 00:55:01 +02:00
Armin Ronacher
6b872be2b9 fix(coding-agent): spawn Windows npm shims directly
closes #4623
2026-05-18 00:21:37 +02:00
Mario Zechner
ed3904ddd3 fix(ai): switch xiaomi models to openai completions
closes #4505
2026-05-18 00:14:31 +02:00
Mario Zechner
266234047a Remove openai-codex fast model variants, they do not work 2026-05-18 00:02:52 +02:00
Mario Zechner
b256ac7d77 Closes #4342 2026-05-17 23:55:25 +02:00
Mario Zechner
7f3c340dc6 Add [Unreleased] section for next cycle 2026-05-17 21:03:41 +02:00
Mario Zechner
12f5c00cc1 Release v0.75.0 2026-05-17 21:02:49 +02:00
Mario Zechner
d5ebc973d0 chore: audit unreleased changelog entries 2026-05-17 21:01:03 +02:00
Mario Zechner
2ad5890bb5 Merge pull request #4600 from earendil-works/stream-fn-in-compaction
fix(coding-agent): route compaction through streamFn
2026-05-17 21:00:01 +02:00
Mario Zechner
238d7530fe Merge branch 'main' into stream-fn-in-compaction 2026-05-17 20:59:53 +02:00
Mario Zechner
8e6913711a Merge pull request #4541 from herrnel/fix/explicit-fences
Fix(coding-agent) Updated system-prompt.ts to use xml boundaries
2026-05-17 20:55:46 +02:00
Mario Zechner
a01cf7afae Merge pull request #4603 from mattiacerutti/fix/openai-codex-model-list
fix(ai): update OpenAI Codex model list
2026-05-17 20:54:53 +02:00
Mario Zechner
6730c04a65 Merge pull request #4622 from mattiacerutti/fix/copilot-gpt-thinking-map-fix
fix(ai): map copilot gpt 5 minimal thinking to low
2026-05-17 20:53:42 +02:00
Mario Zechner
c9e7049212 fix(coding-agent): remove global fetch override closes #4619 2026-05-17 20:52:06 +02:00
Armin Ronacher
6f931797ca fix(coding-agent): install npm packages in managed root closes #4587 2026-05-17 20:48:00 +02:00
Mario Zechner
6d474f8c1a fix(ai): cap context-sized default output budgets
closes #4614
2026-05-17 20:06:59 +02:00
Mario Zechner
6236ad5077 chore: restore normal issue gate message 2026-05-17 18:32:44 +02:00
Mattia Cerutti
485afc9c3f fix(ai): map copilot gpt minimal thinking to low 2026-05-17 12:18:50 +02:00
Nelson Herrera
e2fd651eb9 Updated system-prompt.ts to use xml boundaries during system and context file merging rather than using ## so that agents are less likely to ingest a prompt with inconsistent boundaries. 2026-05-16 19:59:42 -07:00
Mattia Cerutti
1af823be9d fix(ai): update OpenAI Codex model list 2026-05-17 03:12:19 +02:00
github-actions[bot]
734e08edf8 chore: approve contributor mattiacerutti 2026-05-17 00:31:05 +00:00
Armin Ronacher
35f807cfaf fix(coding-agent): route compaction through streamFn
closes #4484
2026-05-17 01:57:03 +02:00
Mario Zechner
2c708492e3 Release v0.74.1 2026-05-17 01:35:45 +02:00
Mario Zechner
72104d88f9 docs: audit unreleased changelogs 2026-05-17 01:29:35 +02:00
Mario Zechner
e26fbb3d46 fix(coding-agent): route global fetch through undici dispatcher closes #4519 2026-05-17 01:26:05 +02:00
Mario Zechner
bfd6535f7b fix(coding-agent): update clipboard native package
closes #4492
2026-05-17 01:26:05 +02:00
Mario Zechner
7bdc1d7944 Merge pull request #4482 from Felixoid/fix-wezterm-kitty-esc
Address edge-case with kitty protocol in wezterm
2026-05-17 00:36:59 +02:00
Armin Ronacher
d803768f6a fix(deps): restore lockfile registry metadata
Fixes #4315
2026-05-17 00:30:32 +02:00
Armin Ronacher
27a193070d fix(coding-agent): resolve pnpm global packages
Fixes #4501
2026-05-17 00:09:11 +02:00
Mario Zechner
43d3cdd48a fix(coding-agent): use configured model scope cycle hint closes #4508 2026-05-17 00:03:03 +02:00
Mario Zechner
c5831df689 fix(ai): vendor proxy env resolution
closes #4513
2026-05-17 00:02:37 +02:00
Mario Zechner
7afd80d788 fix(coding-agent): pin fd for macos x64
closes #4559
2026-05-16 23:39:35 +02:00
Mario Zechner
5d31e70b8a fix(coding-agent): allow skill names to differ from directories
closes #4534
2026-05-16 23:38:46 +02:00
Mario Zechner
d0fe8570e9 fix(coding-agent): parse multiline prompt template args
closes #4553
2026-05-16 23:36:25 +02:00
Mario Zechner
22a9c484e7 fix(ai): respect model output token limits
closes #4539
2026-05-16 23:33:06 +02:00
Mario Zechner
7c5c3d6fd6 fix(ai): detect litellm context overflow errors
closes #4563
2026-05-16 23:29:22 +02:00
Mario Zechner
758004d1bb fix(coding-agent): cap resume session metadata loads
closes #4583

closes #4591
2026-05-16 23:08:12 +02:00
github-actions[bot]
163fd35fd8 chore: approve contributor abhinavmathur-atlan 2026-05-16 21:06:50 +00:00
Mario Zechner
0412f62f9e Merge pull request #4558 from earendil-works/fix/openai-completions-throw-on-missing-finish_reason
fix(ai): openai-completions - throw error on missing finish-reason
2026-05-16 22:54:42 +02:00
Mario Zechner
943236c9e6 Merge pull request #4567 from julien-c/fix/extensions-docs-notify-type
docs(coding-agent): fix invalid notify type in extensions example
2026-05-16 22:52:31 +02:00
Mario Zechner
9b65c24c44 Merge pull request #4574 from aliou/docs/custom-provider-overflow-detection
docs(coding-agent): document overflow normalization for custom providers
2026-05-16 22:52:08 +02:00
Mario Zechner
f8d2c8aa6e docs(agent): organize harness design todos 2026-05-16 22:07:20 +02:00
Armin Ronacher
87881ca686 fix(ai): preserve OpenRouter cached token semantics 2026-05-16 11:55:16 +02:00
Aliou Diallo
b9ae2590f5 docs(coding-agent): document overflow normalization for custom providers
Adds a Context Overflow Errors subsection to custom-provider.md showing how a custom provider extension can rewrite unrecognized overflow errors via a message_end handler so pi's native compact-and-retry path triggers. Includes guard rails for provider scoping, idempotency, and avoiding rate-limit phrases.
2026-05-16 01:50:12 +02:00
Mario Zechner
b4ee3aaeea docs(agent): document harness hook design 2026-05-16 01:06:27 +02:00
Mario Zechner
4f40f62b7b refactor(agent): harden harness session semantics 2026-05-16 00:32:16 +02:00
Julien Chaumond
656bd2cadf docs(coding-agent): fix invalid notify type in extensions example
The notify() API only accepts "info" | "warning" | "error" — "success"
was a copy-paste error in the example.

Co-Authored-By: julien-agent <Agents+cyolo@huggingface.co>
2026-05-15 20:23:34 +02:00
Ramiz Wachtler
98ffad0437 fix(ai): openai-completions - throw error on missing finish-reason
- require \ before treating \ streams as successful
- add regression coverage for truncated streams without \
- closes #4345
2026-05-15 15:26:57 +02:00
Mario Zechner
a8af0b5e99 fix(ai): ignore generic GitHub tokens for Copilot auth
closes #4485
2026-05-15 01:26:31 +02:00
Mario Zechner
7ddc7dd1ec fix(coding-agent): space interactive errors
closes #4510
2026-05-15 01:10:56 +02:00
Mario Zechner
3ca474b15b fix(coding-agent): fix Termux docs code fence
closes #4503
2026-05-15 01:02:36 +02:00
Mario Zechner
80c918c247 refactor(agent): isolate node filesystem session dependencies 2026-05-15 00:53:33 +02:00
Mario Zechner
0b54c87e24 fix(release): finalize Windows ARM64 binary support
closes #4410
2026-05-14 22:44:28 +02:00
Mario Zechner
93708de65d Merge pull request #4458 from brianmichel/brian/add-windows-arm-binary
Add Windows ARM64 Binary Output
2026-05-14 22:41:13 +02:00
Mario Zechner
ac75abee43 Merge pull request #4486 from earendil-works/fix/openai-429-prefer-retry-after-header-over-exponential-backoff
fix(ai): OpenAI Codex SSE - prefer retry-after for retry timeout over default fallback
2026-05-14 21:59:38 +02:00
Mario Zechner
a31ce0f47f refactor(agent): return results from compaction helpers 2026-05-14 21:56:45 +02:00
Mario Zechner
b7ea82105a refactor(agent): run harness loop directly 2026-05-14 21:33:34 +02:00
Mario Zechner
846906e4d1 refactor(agent): add result-based execution env 2026-05-14 18:26:29 +02:00
Mario Zechner
9fcf924ac9 docs: add helper inlining rule 2026-05-14 18:03:31 +02:00
Ramiz Wachtler
0ae909316a fix(ai): honor retry-after for OpenAI Codex SSE retries
- honor `retry-after-ms` and `retry-after` for OpenAI Codex SSE retries
- add SSE retry coverage for millisecond, seconds, date, and fallback delays
2026-05-13 19:44:07 +02:00
Mario Zechner
40c05f5539 Merge pull request #4473 from apoorvumang/fix/inception-mercury-2-reasoning-off
fix(ai): mark inception/mercury-2 thinkingLevelMap.off as null
2026-05-13 18:48:49 +02:00
Mikhail f. Shiryaev
7f30fb6136 Address edge-case with kitty protocol in wezterm
packages/tui/src/stdin-buffer.ts — in extractCompleteSequences, after finding \x1b\x1b as "complete" (legacy meta-key), check if the next character in the buffer would start a new escape sequence ([, ], O, P, _). If so, emit only the first \x1b and let the second ESC begin a new parse iteration.

packages/tui/test/stdin-buffer.test.ts — three new cases in the Kitty section:
- \x1b\x1b[27;129:3u (num_lock) splits into ["\x1b", "\x1b[27;129:3u"]
- \x1b\x1b[27;1:3u (no num_lock) splits into ["\x1b", "\x1b[27;1:3u"]
- \x1b\x1b alone (no following CSI) still emits as ["\x1b\x1b"] — preserves ctrl+alt+[
2026-05-13 18:43:42 +02:00
Mario Zechner
a5cca409d8 refactor(ai): use HTTP proxy agents for Bedrock 2026-05-13 16:12:48 +02:00
Mario Zechner
e3faf41801 fix(tui): cap portrait image render height 2026-05-13 16:12:48 +02:00
Armin Ronacher
c00f1cf130 fix(agent): correct uuidv7 sequence handling 2026-05-13 15:53:53 +02:00
Armin Ronacher
8bf2bfab58 fix(coding-agent): align stripAnsi with strip-ansi 2026-05-13 15:52:33 +02:00
Apoorv Saxena
e2b69a0bb1 fix(ai): mark inception/mercury-2 thinkingLevelMap.off as null
Mercury 2 in instant mode (reasoning_effort: "none") disables tool calling.
The openai-completions provider hardcodes {reasoning:{effort:"none"}} when no
explicit reasoning level is passed and thinkingLevelMap.off isn't null
(openai-completions.ts:575), so every caller that doesn't opt in to a level
silently breaks Mercury 2's agentic use cases.

Setting thinkingLevelMap.off = null on the Mercury 2 catalog entry causes the
provider to omit the reasoning param entirely, letting Mercury 2's own default
take over. Low/medium/high pass through verbatim; OpenRouter normalizes them
to Mercury's vocabulary.

Prefix-matched on "inception/mercury-2" so future Mercury 2 variants on
OpenRouter inherit the fix.
2026-05-13 19:17:36 +05:30
xu0o0
c08c462461 fix(tui): place image correctly when viewport height < image height (#4461)
fixes #4415
2026-05-13 14:37:48 +02:00
Armin Ronacher
e0b5d27af2 chore(deps): replace cli-highlight (#4468) 2026-05-13 12:19:45 +02:00
Armin Ronacher
2829146dde chore(deps): Kill small dependencies (#4467) 2026-05-13 10:44:56 +02:00
Nelson Herrera
8da1f3d131 fix(tui): Make markdown.ts more robust to large markdown files (#4463) 2026-05-13 10:31:09 +02:00
Brian Michel
c786efda9d Update bun version to minimum required version for Windows ARM 2026-05-12 21:42:25 -04:00
Brian Michel
a5cbd8e1aa Add Windows arm64 binary support
Update build-binaries workflow and shell script to allow for packaging
of both arm64 and x64 versions of the Windows application.
2026-05-12 21:42:25 -04:00
Mario Zechner
f2b105dd56 Merge pull request #4379 from Perlence/fix-tui-todo-checkboxes
fix(tui): render the checkboxes in the to-do list items
2026-05-13 00:17:25 +02:00
Mario Zechner
6ff6dc6278 Merge pull request #4383 from maximilianzuern/docs/fixToolConfig
fix(coding-agent) docs: update tool configuration API in SDK docs
2026-05-13 00:16:50 +02:00
Mario Zechner
2b895e20cd Merge pull request #4391 from yzhg1983/fix/sdk-example-session-dispose
fix(coding-agent): dispose SDK example sessions
2026-05-12 23:50:08 +02:00
Mario Zechner
5998b57958 Merge pull request #4426 from ofa1/fix/restore-tty-on-uncaught-exception
fix(coding-agent): restore terminal on uncaught exception
2026-05-12 23:48:28 +02:00
Armin Ronacher
64882c6a51 chore(deps): remove unused dependencies (#4453) 2026-05-12 23:35:41 +02:00
Mario Zechner
24e370fde5 Merge remote-tracking branch 'origin/main' 2026-05-12 23:35:17 +02:00
Mario Zechner
d62c416cfd fix(coding-agent): allow tool expansion during extension confirms
closes #4429
2026-05-12 23:34:57 +02:00
Mario Zechner
5ac874c849 fix(coding-agent): retry Anthropic message_stop stream endings
closes #4433
2026-05-12 23:25:15 +02:00
github-actions[bot]
2726e57cda chore: approve contributor brianmichel 2026-05-12 20:56:58 +00:00
Omair Ahmed
9d84e28692 fix(coding-agent): restore terminal on uncaught exception
When an uncaught exception fires in interactive mode, the process dies with
stdin still in raw mode and the cursor hidden, leaving the user with a
"borked" terminal that needs `stty sane && reset` to recover. The most
common trigger is an extension's async ChildProcess `exit` callback that
throws (e.g. from accessing a stale ctx after session replacement), but
this affects any uncaught throw from anywhere in pi.

Add an uncaughtException handler in registerSignalHandlers that calls
ui.stop() before exiting, mirroring the existing emergencyTerminalExit
pattern. The handler is registered with prependListener and tracked in
signalCleanupHandlers, so it is removed on graceful shutdown the same way
the other handlers are.

Unlike emergencyTerminalExit (used for SIGHUP / dead-terminal EIO), the
terminal is still alive on uncaughtException, so we run the normal
ui.stop() to restore cooked mode, the cursor, bracketed paste mode, and
Kitty / modifyOtherKeys sequences.
2026-05-11 16:27:27 -04:00
Armin Ronacher
3d9e14d748 fix(compaction): clamp summary output tokens
Fixes #4390.
2026-05-11 16:36:27 +02:00
Michael Yu
d68011dab1 fix(coding-agent): dispose SDK example sessions 2026-05-11 13:26:08 +08:00
Maximilian
68deecfb4f minor fix in body 2026-05-11 00:45:30 +02:00
Maximilian
74739567b9 fix tool config in SDK docs 2026-05-11 00:32:19 +02:00
Maximilian
df72d5eefe fix inconsistency in README 2026-05-11 00:16:46 +02:00
Maximilian
4eadc8fd3e fix tool config in example in SDK README.md 2026-05-10 23:34:28 +02:00
Maximilian
c3ce1d33d4 fix tool config in example in sdk.ts 2026-05-10 23:32:15 +02:00
Sviatoslav Abakumov
863341fbf5 fix(tui): render the checkboxes in the to-do list items 2026-05-10 22:20:40 +04:00
Mario Zechner
f348a06294 test(agent): cover harness stream configuration 2026-05-10 19:10:54 +02:00
Mario Zechner
c0f416aabc feat(agent): add harness stream configuration 2026-05-10 18:13:16 +02:00
Mario Zechner
f8d0fa67dc fix(coding-agent): share theme across package scopes
closes #4333
2026-05-10 18:13:16 +02:00
Mario Zechner
f6b6b1f052 Merge pull request #4354 from haoqixu/fix-bun-ws-proxy
fix(ai): respect proxy envs in bun's websocket
2026-05-10 18:03:48 +02:00
Mario Zechner
cb3c42ecf5 Merge pull request #4358 from yanirz/fix/fireworks-session-affinity-cache
fix(ai): add session affinity and compat fixes for Fireworks provider caching
2026-05-10 18:00:52 +02:00
github-actions[bot]
f2231fa5f7 chore: approve contributor maximilianzuern 2026-05-10 15:55:29 +00:00
Mario Zechner
533d37305c fix(ai): align copilot claude adaptive test 2026-05-10 17:51:08 +02:00
Mario Zechner
cf7f2e3dbb fix(ai): update copilot claude test model 2026-05-10 17:47:45 +02:00
yanirz
99dc6fcec8 fix(ai): add session affinity and compat fixes for Fireworks provider caching
Fireworks prompt caching is enabled by default (automatic prefix matching),
but on serverless infrastructure, requests hit random replicas. Without
session affinity, the per-replica cache misses, negating cache hit rates
and the discounted cacheRead pricing.

Changes:

- Add sendSessionAffinityHeaders and supportsCacheControlOnTools
  to AnthropicMessagesCompat interface
- Send x-session-affinity header for Fireworks (and Cloudflare AI
  Gateway Anthropic) when sessionId is available and caching is enabled
- Omit cache_control on tool definitions for Fireworks (unsupported
  per https://docs.fireworks.ai/tools-sdks/anthropic-compatibility)
- Default supportsEagerToolInputStreaming to false for Fireworks
  (unsupported field)
- Default supportsLongCacheRetention to false for Fireworks
  (cache_control.ttl not supported)
- Add compat settings to Fireworks models in generate-models.ts
- Update generated models with Fireworks compat settings
- Add integration tests for session affinity and tool compat

Refs: https://docs.fireworks.ai/guides/prompt-caching
Refs: https://docs.fireworks.ai/tools-sdks/anthropic-compatibility
2026-05-10 00:11:36 +02:00
haoqixu
8c2e3eddec fix(ai): respect proxy envs in bun's websocket
fixes #4346
2026-05-10 03:43:07 +08:00
Sam Jones
fcd96df6f2 config selector: scale maxVisible to terminal height
ResourceList hardcoded maxVisible=15, leaving most of the screen
empty on tall terminals. Pass terminalHeight through from
ProcessTerminal.rows and compute maxVisible dynamically, matching
the pattern tree-selector already uses.

Closes #4241
2026-05-06 14:45:13 -04:00
786 changed files with 46644 additions and 32559 deletions

View File

@@ -203,3 +203,41 @@ pandada8 pr
npupko issue
chrisvariety pr
maximilianzuern pr
brianmichel pr
abhinavmathur-atlan pr
mattiacerutti pr
josephyoung pr
mbazso pr
AJM10565 pr
DanielThomas pr
MichaelYochpaz pr
stephanmck pr
rolfvreijdenberger pr
psoukie pr
vastxie pr
ItsumoSeito pr
davidlifschitz pr
vdxz pr
dangooddd pr
Mearman pr
dodiego pr

View File

@@ -11,6 +11,8 @@ body:
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
**Important:** before reporting an issue in core, please validate first with `pi -ne` that this is not caused by an extension you loaded.
- type: textarea
id: description
attributes:

View File

@@ -0,0 +1,49 @@
name: Package Report
description: Report a problematic Pi package listed on pi.dev
labels: ["package-report"]
body:
- type: markdown
attributes:
value: |
Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead.
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
- type: input
id: package-name
attributes:
label: Package name
description: The npm package name from pi.dev.
placeholder: "@scope/package"
validations:
required: true
- type: input
id: package-version
attributes:
label: Version
description: The package version shown on pi.dev.
placeholder: "0.1.0"
validations:
required: false
- type: dropdown
id: report-type
attributes:
label: What are you reporting?
options:
- Malicious or unsafe behavior
- Impersonation
- Trademark / TOS Violations
validations:
required: true
- type: textarea
id: details
attributes:
label: Details
description: Describe the concern and include links, logs, or screenshots if helpful.
validations:
required: true

View File

@@ -10,25 +10,32 @@ on:
description: 'Tag to build (e.g., v0.12.0)'
required: true
type: string
source_ref:
description: 'Source ref to build/publish (defaults to tag; use only for release recovery)'
required: false
type: string
permissions:
contents: write
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ env.RELEASE_TAG }}
ref: ${{ env.SOURCE_REF }}
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.2.20
bun-version: 1.3.10
- name: Setup Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
@@ -44,36 +51,86 @@ jobs:
run: |
VERSION="${RELEASE_TAG}"
VERSION="${VERSION#v}" # Remove 'v' prefix
# Extract changelog section for this version
cd packages/coding-agent
awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md > /tmp/release-notes.md
# If empty, use a default message
if [ ! -s /tmp/release-notes.md ]; then
echo "Release ${VERSION}" > /tmp/release-notes.md
fi
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md
- name: Create GitHub Release and upload binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd packages/coding-agent/binaries
# Create release with changelog notes (or update if exists)
gh release create "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md \
pi-darwin-arm64.tar.gz \
pi-darwin-x64.tar.gz \
pi-linux-x64.tar.gz \
pi-linux-arm64.tar.gz \
pi-windows-x64.zip \
2>/dev/null || \
gh release upload "${RELEASE_TAG}" \
pi-darwin-arm64.tar.gz \
pi-darwin-x64.tar.gz \
pi-linux-x64.tar.gz \
pi-linux-arm64.tar.gz \
pi-windows-x64.zip \
--clobber
release_assets=(
pi-darwin-arm64.tar.gz
pi-darwin-x64.tar.gz
pi-linux-x64.tar.gz
pi-linux-arm64.tar.gz
pi-windows-x64.zip
pi-windows-arm64.zip
)
sha256sum "${release_assets[@]}" > SHA256SUMS
release_assets+=(SHA256SUMS)
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md
gh release upload "${RELEASE_TAG}" "${release_assets[@]}" --clobber
else
gh release create "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md \
"${release_assets[@]}"
fi
publish-npm:
runs-on: ubuntu-latest
needs: build
environment: npm-publish
permissions:
contents: read
id-token: write
env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ env.SOURCE_REF }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: npm
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
sudo ln -s $(which fdfind) /usr/local/bin/fd
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Build
run: npm run build
- name: Check
run: npm run check
- name: Test
run: npm test
- name: Verify release artifacts are committed
run: git diff --exit-code
- name: Upgrade npm for trusted publishing
run: |
npm install -g npm@11.16.0 --ignore-scripts
npm --version
- name: Publish npm packages
run: node scripts/publish.mjs

View File

@@ -30,7 +30,7 @@ jobs:
sudo ln -s $(which fdfind) /usr/local/bin/fd
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Build
run: npm run build

View File

@@ -17,11 +17,8 @@ jobs:
script: |
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
const ISSUE_GATE_MESSAGE_MODE = 'refactor'; // Switch to 'normal' to restore the standard auto-close message.
const issueAuthor = context.payload.issue.user.login;
const defaultBranch = context.payload.repository.default_branch;
const issueCreatedDay = new Date(context.payload.issue.created_at).getUTCDay();
const isFridayThroughSunday = issueCreatedDay === 5 || issueCreatedDay === 6 || issueCreatedDay === 0;
if (issueAuthor.endsWith('[bot]') || issueAuthor === 'dependabot[bot]') {
console.log(`Skipping bot: ${issueAuthor}`);
@@ -97,36 +94,15 @@ jobs:
return;
}
function buildNormalGateMessage() {
return [
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
...(isFridayThroughSunday
? [
'Issues submitted Friday through Sunday are not reviewed. If this is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx',
]
: []),
'',
`Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
}
function buildRefactorGateMessage() {
return [
'This issue was auto-closed. All issues will be closed until 2026-05-17 while the project refactor is being completed.',
'',
`The refactor is happening on \`${defaultBranch}\`: https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${defaultBranch}`,
'',
'Issues closed during this period will not be reviewed. The reason is that the refactor will not get done otherwise, because issue triage has been taking about 8 hours per day.',
'',
'In case of emergency, ask on Discord: https://discord.com/invite/3cU7Bz4UPx',
].join('\n');
}
const message = ISSUE_GATE_MESSAGE_MODE === 'refactor' ? buildRefactorGateMessage() : buildNormalGateMessage();
const message = [
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
'',
`Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
'',
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
'',
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
@@ -135,36 +111,17 @@ jobs:
body: message,
});
const labelsToAdd = [];
if (isFridayThroughSunday) labelsToAdd.push('closed-because-weekend');
if (ISSUE_GATE_MESSAGE_MODE === 'refactor') labelsToAdd.push('closed-because-refactor');
if (labelsToAdd.includes('closed-because-refactor')) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'closed-because-refactor',
color: '5319e7',
description: 'Closed while the project refactor is in progress',
});
} catch (error) {
if (error.status !== 422) throw error;
}
}
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: labelsToAdd,
});
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['untriaged'],
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
});

View File

@@ -0,0 +1,137 @@
name: Issue Triage Labels
on:
issues:
types: [reopened, labeled]
jobs:
update-labels:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Update triage labels
uses: actions/github-script@v7
with:
script: |
const UNTRIAGED_LABEL = 'untriaged';
const NO_ACTION_LABEL = 'no-action';
const LAST_READ_LABEL = 'last-read';
const TO_DISCUSS_LABEL = 'to-discuss';
const INPROGRESS_LABEL = 'inprogress';
function issueHasLabel(issue, labelName) {
return (issue.labels ?? []).some((label) => label.name === labelName);
}
async function removeLabelIfPresent(issueNumber, issue, labelName) {
if (!issueHasLabel(issue, labelName)) {
console.log(`Issue #${issueNumber} does not have ${labelName}`);
return;
}
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: labelName,
});
console.log(`Removed ${labelName} from #${issueNumber}`);
} catch (error) {
if (error.status === 404) {
console.log(`Label ${labelName} was already absent from #${issueNumber}`);
return;
}
throw error;
}
}
if (context.payload.action === 'reopened') {
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL);
return;
}
if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) {
console.log('Not a last-read label event');
return;
}
const currentIssueNumber = context.issue.number;
const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: LAST_READ_LABEL,
per_page: 100,
});
const previousIssueNumbers = lastReadIssues
.filter((issue) => !issue.pull_request)
.map((issue) => issue.number)
.filter((issueNumber) => issueNumber !== currentIssueNumber);
if (previousIssueNumbers.length === 0) {
console.log('No previous last-read issue found');
return;
}
const previousIssueNumber = Math.max(...previousIssueNumbers);
if (currentIssueNumber <= previousIssueNumber) {
console.log(
`Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`,
);
return;
}
const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'all',
labels: UNTRIAGED_LABEL,
per_page: 100,
});
const issuesToMark = untriagedIssues
.filter((issue) => !issue.pull_request)
.filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber)
.sort((a, b) => a.number - b.number);
if (issuesToMark.length === 0) {
console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`);
return;
}
for (const issue of issuesToMark) {
if (issueHasLabel(issue, TO_DISCUSS_LABEL)) {
console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`);
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [NO_ACTION_LABEL],
});
console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`);
}
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
console.log(`Closed #${issue.number} as not planned`);
await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL);
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: UNTRIAGED_LABEL,
});
console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`);
}

29
.github/workflows/npm-audit.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: npm audit
on:
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies without lifecycle scripts
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Audit production vulnerabilities
run: npm audit --omit=dev --audit-level=moderate
- name: Verify registry signatures
run: npm audit signatures --omit=dev

View File

@@ -0,0 +1,31 @@
name: Remove In Progress Label On Close
on:
issues:
types: [closed]
jobs:
remove-label:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Remove inprogress label
uses: actions/github-script@v7
with:
script: |
const labelName = 'inprogress';
const labels = context.payload.issue.labels ?? [];
const hasLabel = labels.some((label) => label.name === labelName);
if (!hasLabel) {
console.log(`Issue does not have ${labelName} label`);
return;
}
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: labelName,
});

46
.gitignore vendored
View File

@@ -1,5 +1,8 @@
node_modules/
dist/
# SproutClaw persistent config — never commit
.sproutclaw/
*.log
.DS_Store
*.tsbuildinfo
@@ -25,7 +28,7 @@ packages/*/dist-firefox/
.npm/
coverage/
.nyc_output/
.pi_config/
.sproutclaw_config/
tui-debug.log
compaction-results/
.opencode/
@@ -36,12 +39,41 @@ out.html
packages/coding-agent/binaries/
todo.md
plans/
.pi/hf-sessions/
.pi/hf-sessions-backup/
.pi/agent/
.pi/sessions/
.pi/extensions/webui/.webui.pid
.pi/extensions/webui/.webui.log
.sproutclaw/hf-sessions/
.sproutclaw/hf-sessions-backup/
.sproutclaw/sessions/
.sproutclaw/extensions/webui/.webui.pid
.sproutclaw/extensions/webui/.webui.log
# Local runtime / secrets (do not commit)
.local/
progress.md
# SproutClaw agent: track extensions, ignore local runtime secrets and sessions
.sproutclaw/agent/sessions/
.sproutclaw/agent/git/
.sproutclaw/agent/npm/
.sproutclaw/agent/bin/
.sproutclaw/agent/data/
.sproutclaw/agent/auth.json
.sproutclaw/agent/models.json
.sproutclaw/agent/settings.json
.sproutclaw/agent/mcp.json
.sproutclaw/agent/mcp-cache.json
.sproutclaw/agent/mcp-npx-cache.json
.sproutclaw/agent/run-history.jsonl
.sproutclaw/agent/taskplane/
.sproutclaw/agent/skills/
.sproutclaw/agent/skills-disabled/
.sproutclaw/agent/extensions/webui/frontend/node_modules/
.sproutclaw/agent/extensions/webui/.webui.pid
.sproutclaw/agent/extensions/webui/data/
tmp/
bun.lock
collect.sh
# Root-level binary/build artifacts (do not commit)
/*.wasm
/llvm-*.js
/rustc-*.js
/util_cmd-*.js
/wasm32-*.tar.br

View File

@@ -3,6 +3,11 @@
# Get list of staged files before running check
STAGED_FILES=$(git diff --cached --name-only)
node scripts/check-lockfile-commit.mjs
if [ $? -ne 0 ]; then
exit 1
fi
# Run the check script (formatting, linting, and type checking)
echo "Running formatting, linting, and type checking..."
npm run check
@@ -33,7 +38,7 @@ fi
# Restage files that were previously staged and may have been modified by formatting
for file in $STAGED_FILES; do
if [ -f "$file" ]; then
git add "$file"
git add "$file" 2>/dev/null || true
fi
done

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
save-exact=true
min-release-age=2

303
AGENTS.md
View File

@@ -4,244 +4,159 @@
- Keep answers short and concise
- No emojis in commits, issues, PR comments, or code
- No fluff or cheerful filler text
- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!")
- No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!")
- Technical prose only, be direct
- When the user asks a question, answer it first before making edits or running implementation commands.
- When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed.
## Code Quality
- Read files in full before making wide-ranging changes, before editing files you have not already fully inspected, and when the user asks you to investigate or audit something. Do not rely only on search snippets for broad changes.
- No `any` types unless absolutely necessary
- Check node_modules for external API type definitions instead of guessing
- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports.
- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead
- Always ask before removing functionality or code that appears to be intentional
- Do not preserve backward compatibility unless the user explicitly asks for it
- Never hardcode key checks with, eg. `matchesKey(keyData, "ctrl+x")`. All keybindings must be configurable. Add default to matching object (`DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS`)
- NEVER modify `packages/ai/src/models.generated.ts` directly. Update `packages/ai/scripts/generate-models.ts` instead.
- Read files in full before wide-ranging changes, before editing files you have not fully inspected, and when asked to investigate or audit. Do not rely on search snippets for broad changes.
- No `any` unless absolutely necessary.
- Inline single-line helpers that have only one call site.
- Check node_modules for external API types; don't guess.
- **No inline imports** (`await import()`, `import("pkg").Type`, dynamic type imports). Top-level imports only.
- Never remove or downgrade code to fix type errors from outdated deps; upgrade the dep instead.
- Use only erasable TypeScript syntax (Node strip-only mode) in code checked by the root config (`packages/*/src`, `packages/*/test`, `packages/coding-agent/examples`): no parameter properties, `enum`, `namespace`/`module`, `import =`, `export =`, or other constructs needing JS emit. Use explicit fields with constructor assignments.
- Always ask before removing functionality or code that appears intentional.
- Do not preserve backward compatibility unless the user asks for it.
- Never hardcode key checks (e.g. `matchesKey(keyData, "ctrl+x")`). Add defaults to `DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS` so they stay configurable.
- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead, then regenerate. Including the resulting `models.generated.ts` diff is always OK, even if regeneration includes unrelated upstream model metadata changes.
## Commands
- After code changes (not documentation changes): `npm run check` (get full output, no tail). Fix all errors, warnings, and infos before committing.
- Note: `npm run check` does not run tests.
- NEVER run: `npm run dev`, `npm run build`, `npm test`
- Only run specific tests if user instructs: `npx tsx ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`
- Run tests from the package root, not the repo root.
- If you create or modify a test file, you MUST run that test file and iterate until it passes.
- When writing tests, run them, identify issues in either the test or implementation, and iterate until fixed.
- For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` plus the faux provider. Do not use real provider APIs, real API keys, or paid tokens.
- Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` and name them `<issue-number>-<short-slug>.test.ts`.
- NEVER commit unless user asks
- After code changes (not docs): `npm run check` (full output, no tail). Fix all errors, warnings, and infos before committing. Does not run tests.
- Never run `npm run build` or `npm test` unless requested by the user.
- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./test.sh` from the repo root. Otherwise run specific tests from the package root: `node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`.
- If you create or modify a test file, run it and iterate on test or implementation until it passes.
- For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` + the faux provider. No real provider APIs, keys, or paid tokens.
- Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` named `<issue-number>-<short-slug>.test.ts`.
- For ad-hoc scripts, `write` them to a temp file (e.g. `/tmp`), run, edit if needed, remove when done. Don't embed multi-line scripts in `bash` commands.
- Never commit unless the user asks.
## Contribution Gate
## Dependency and Install Security
- New issues from new contributors are auto-closed by `.github/workflows/issue-gate.yml`
- New PRs from new contributors without PR rights are auto-closed by `.github/workflows/pr-gate.yml`
- Maintainer approval comments are handled by `.github/workflows/approve-contributor.yml`
- Maintainers review auto-closed issues daily
- Issues that do not meet the quality bar in `CONTRIBUTING.md` are not reopened and do not receive a reply
- `lgtmi` approves future issues
- `lgtm` approves future issues and rights to submit PRs
- Treat npm dep and lockfile changes as reviewed code. Direct external deps stay pinned to exact versions.
- Hydrate/update locally with `npm install --ignore-scripts`; clean/CI-style with `npm ci --ignore-scripts`. Don't run lifecycle scripts unless the user asks.
- If dep metadata changes, refresh `package-lock.json` with `npm install --package-lock-only --ignore-scripts`.
- If `packages/coding-agent/npm-shrinkwrap.json` needs regen, run `node scripts/generate-coding-agent-shrinkwrap.mjs` (verify with `--check` or `npm run check`). New deps with lifecycle scripts require review and an explicit allowlist entry in that script; never add one silently.
- Pre-commit blocks lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1`. Don't bypass unless the user wants the lockfile change committed.
## Git
Multiple pi sessions may be running in this cwd at the same time, each modifying different files. Git operations that touch unstaged, staged, or untracked files outside your own changes will stomp on other sessions' work. Follow these rules:
Committing:
- Only commit files YOU changed in THIS session.
- Stage explicit paths (`git add <path1> <path2>`); never `git add -A` / `git add .`.
- Before committing, run `git status` and verify you are only staging your files.
- `packages/ai/src/models.generated.ts` may always be included alongside your files.
- Message format: `{feat,fix,docs}[(ai,tui,agent,coding-agent)]: <commit message> (optionally multiple lines)`. Message is informative and concise.
Never run (destroys other agents' work or bypasses checks):
- `git reset --hard`, `git checkout .`, `git clean -fd`, `git stash`, `git add -A`, `git add .`, `git commit --no-verify`.
If rebase conflicts occur:
- Resolve conflicts only in files you modified.
- If a conflict is in a file you did not modify, abort and ask the user.
- Never force push.
## Issues and PRs
See `CONTRIBUTING.md` for the contributor gate (auto-close workflows, `lgtm`/`lgtmi`, quality bar).
When reviewing PRs:
- Do not run `gh pr checkout`, `git switch`, or otherwise move the worktree to the PR branch unless the user explicitly asks.
- Use `gh pr view`, `gh pr diff`, `gh api`, and local `git show`/`git diff` against fetched refs to inspect PR metadata, commits, and patches without changing branches.
- If you need PR file contents, fetch/read them into temporary files or use `git show <ref>:<path>` without switching branches.
When creating issues:
- Add `pkg:*` labels to indicate which package(s) the issue affects
- Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`, `pkg:web-ui`
- If an issue spans multiple packages, add all relevant labels
- Add `pkg:*` labels for affected packages (`pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`); use all that apply.
When posting issue/PR comments:
- Write the full comment to a temp file and use `gh issue comment --body-file` or `gh pr comment --body-file`
- Never pass multi-line markdown directly via `--body` in shell commands
- Preview the exact comment text before posting
- Post exactly one final comment unless the user explicitly asks for multiple comments
- If a comment is malformed, delete it immediately, then post one corrected comment
- Keep comments concise, technical, and in the user's tone
- Write the comment to a temp file and post with `gh issue/pr comment --body-file` (never multi-line markdown via `--body`).
- Keep comments concise, technical, in the user's tone.
- End every AI-posted comment with the AI-generated disclaimer line specified by the originating prompt (e.g. `This comment is AI-generated by `/wr``).
When closing issues via commit:
- Include `fixes #<number>` or `closes #<number>` in the commit message
- This automatically closes the issue when the commit is merged
## PR Workflow
- Analyze PRs without pulling locally first
- If the user approves: create a feature branch, pull PR, rebase on main, apply adjustments, commit, merge into main, push, close PR, and leave a comment in the user's tone
- You never open PRs yourself. We work in feature branches until everything is according to the user's requirements, then merge into main, and push.
- Include `fixes #<number>` or `closes #<number>` in the message so merging auto-closes the issue. For multiple issues, repeat the keyword per issue (`closes #1, closes #2`); a shared keyword (`closes #1, #2`) only closes the first.
## Testing pi Interactive Mode with tmux
To test pi's TUI in a controlled terminal environment:
Run the TUI in a controlled terminal (from the repo root):
```bash
# Create tmux session with specific dimensions
tmux new-session -d -s pi-test -x 80 -y 24
# Start pi from source
tmux send-keys -t pi-test "cd /Users/badlogic/workspaces/pi-mono && ./pi-test.sh" Enter
# Wait for startup, then capture output
sleep 3 && tmux capture-pane -t pi-test -p
# Send input
tmux send-keys -t pi-test "./test.sh" Enter
sleep 3 && tmux capture-pane -t pi-test -p # capture after startup
tmux send-keys -t pi-test "your prompt here" Enter
# Send special keys
tmux send-keys -t pi-test Escape
tmux send-keys -t pi-test C-o # ctrl+o
# Cleanup
tmux send-keys -t pi-test Escape # special keys (also C-o for ctrl+o, etc.)
tmux kill-session -t pi-test
```
## Changelog
Location: `packages/*/CHANGELOG.md` (each package has its own)
Location: `packages/*/CHANGELOG.md` (one per package).
### Format
Sections under `## [Unreleased]`: `### Breaking Changes` (API changes requiring migration), `### Added`, `### Changed`, `### Fixed`, `### Removed`.
Use these sections under `## [Unreleased]`:
Rules:
- `### Breaking Changes` - API changes requiring migration
- `### Added` - New features
- `### Changed` - Changes to existing functionality
- `### Fixed` - Bug fixes
- `### Removed` - Removed features
- All new entries go under `## [Unreleased]`. Read the full section first and append to existing subsections; never duplicate them.
- Released version sections (e.g. `## [0.12.2]`) are immutable; never modify them.
### Rules
Attribution:
- Before adding entries, read the full `[Unreleased]` section to see which subsections already exist
- New entries ALWAYS go under `## [Unreleased]` section
- Append to existing subsections (e.g., `### Fixed`), do not create duplicates
- NEVER modify already-released version sections (e.g., `## [0.12.2]`)
- Each version section is immutable once released
### Attribution
- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
- **External contributions**: `Added feature X ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@username](https://github.com/username))`
## Adding a New LLM Provider (packages/ai)
Adding a new provider requires changes across multiple files:
### 1. Core Types (`packages/ai/src/types.ts`)
- Add API identifier to `Api` type union (e.g., `"bedrock-converse-stream"`)
- Create options interface extending `StreamOptions`
- Add mapping to `ApiOptionsMap`
- Add provider name to `KnownProvider` type union
### 2. Provider Implementation (`packages/ai/src/providers/`)
Create provider file exporting:
- `stream<Provider>()` function returning `AssistantMessageEventStream`
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping
- Provider-specific options interface
- Message/tool conversion functions
- Response parsing emitting standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`)
### 3. Provider Exports and Lazy Registration
- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/<provider>.js`
- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry
- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers, do not statically import provider implementation modules there
- Add credential detection in `packages/ai/src/env-api-keys.ts`
### 4. Model Generation (`packages/ai/scripts/generate-models.ts`)
- Add logic to fetch/parse models from provider source
- Map to standardized `Model` interface
### 5. Tests (`packages/ai/test/`)
- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API implementation such as `openai-completions`.
- Add the provider to the broader provider matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`.
- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (for example GPT and Claude), add at least one pair per family.
- For non-standard auth, create utility (e.g., `bedrock-utils.ts`) with credential detection.
### 6. Coding Agent (`packages/coding-agent/`)
- `src/core/model-resolver.ts`: Add default model ID to `defaultModelPerProvider`
- `src/core/provider-display-names.ts`: Add API-key login display name so `/login` and related UI show the provider for built-in API-key auth.
- `src/cli/args.ts`: Add env var documentation
- `README.md`: Add provider setup instructions
- `docs/providers.md`: Add setup instructions, env var, and `auth.json` key
### 7. Documentation
- `packages/ai/README.md`: Add to providers table, document options/auth, add env vars
- `packages/ai/CHANGELOG.md`: Add entry under `## [Unreleased]`
- Internal (from issues): `Fixed foo bar ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
- External contributions: `Added feature X ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@username](https://github.com/username))`
## Releasing
**Lockstep versioning**: All packages always share the same version number. Every release updates all packages together.
**Lockstep versioning**: all packages share one version; every release updates all together. `patch` = fixes + additions, `minor` = breaking changes. No major releases.
**Version semantics** (no major releases):
1. **Update CHANGELOGs**: ask the user whether they ran the `/cl` prompt on the latest commit on `main`. If not, they must run `/cl` first to audit and update each package's `[Unreleased]` section before releasing.
- `patch`: Bug fixes and new features
- `minor`: API breaking changes
### Steps
1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md
2. **Run release script**:
2. **Local smoke test**: build an unpublished release and smoke test from outside the repo (so it can't resolve workspace files):
```bash
npm run release:patch # Fixes and additions
npm run release:minor # API breaking changes
npm run release:local -- --out /tmp/pi-local-release --force
cd /tmp
# Node package install smoke tests
/tmp/pi-local-release/node/pi --help
/tmp/pi-local-release/node/pi --version
/tmp/pi-local-release/node/pi --list-models
/tmp/pi-local-release/node/pi -p "Say exactly: ok"
/tmp/pi-local-release/node/pi
# Bun binary smoke tests
/tmp/pi-local-release/bun/pi --help
/tmp/pi-local-release/bun/pi --version
/tmp/pi-local-release/bun/pi --list-models
/tmp/pi-local-release/bun/pi -p "Say exactly: ok"
/tmp/pi-local-release/bun/pi
```
Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/pi-local-release/node/pi` and `/tmp/pi-local-release/bun/pi` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk.
The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections.
3. **Run the release script**:
```bash
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:patch # fixes + additions
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:minor # breaking changes
```
Use `npm_config_min_release_age=0` only for the release command. The repo's normal npm age gate can otherwise block the release lockfile refresh when the current workspace package version was published recently. Review any lockfile or shrinkwrap diffs the release creates before push.
## **CRITICAL** Git Rules for Parallel Agents **CRITICAL**
The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed.
Multiple agents may work on different files in the same worktree simultaneously. You MUST follow these rules:
4. **CI publishes npm packages**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required.
### Committing
5. **If CI publish fails**: inspect the failed `publish-npm` job. The publish helper is idempotent and skips package versions already present on npm, so rerun the tag workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version.
- **ONLY commit files YOU changed in THIS session**
- ALWAYS include `fixes #<number>` or `closes #<number>` in the commit message when there is a related issue or PR
- NEVER use `git add -A` or `git add .` - these sweep up changes from other agents
- ALWAYS use `git add <specific-file-paths>` listing only files you modified
- Before committing, run `git status` and verify you are only staging YOUR files
- Track which files you created/modified/deleted during the session
- It is always fine to include `packages/ai/src/models.generated.ts` in a commit alongside the actual files you want to commit
## User Override
### Forbidden Git Operations
These commands can destroy other agents' work:
- `git reset --hard` - destroys uncommitted changes
- `git checkout .` - destroys uncommitted changes
- `git clean -fd` - deletes untracked files
- `git stash` - stashes ALL changes including other agents' work
- `git add -A` / `git add .` - stages other agents' uncommitted work
- `git commit --no-verify` - bypasses required checks and is never allowed
### Safe Workflow
```bash
# 1. Check status first
git status
# 2. Add ONLY your specific files
git add packages/ai/src/providers/transform-messages.ts
git add packages/ai/CHANGELOG.md
# 3. Commit
git commit -m "fix(ai): description"
# 4. Push (pull --rebase if needed, but NEVER reset/checkout)
git pull --rebase && git push
```
### If Rebase Conflicts Occur
- Resolve conflicts in YOUR files only
- If conflict is in a file you didn't modify, abort and ask the user
- NEVER force push
### User override
If the user instructions conflict with rules set out here, ask for confirmation that they want to override the rules. Only then execute their instructions.
If the user's instructions conflict with any rule in this document, ask for explicit confirmation before overriding. Only then execute their instructions.

View File

@@ -1,93 +1,97 @@
# Contributing to pi
# 贡献指南
This guide exists to save both sides time.
本指南旨在为双方节省时间。
## The One Rule
## 设计理念
**You must understand your code.** If you cannot explain what your changes do and how they interact with the rest of the system, your PR will be closed.
首先明确一点:**SproutClaw 的核心保持精简**。
Using AI to write code is fine. Submitting AI-generated slop without understanding it is not.
如果你的功能不属于核心,它应该以扩展的形式实现。会让核心臃肿的 PR 大概率会被拒绝。
If you use an agent, run it from the `pi-mono` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file.
SproutClaw 的核心存在的意义在于精简且可扩展,以便扩展能够影响和定制其行为。即便是扩展的 hook 点也需要经过充分考量和讨论,避免引入难以维护的冗余代码和复杂交互。
## Contribution Gate
## 唯一准则
All issues and PRs from new contributors are auto-closed by default.
**你必须理解自己提交的代码。** 如果你无法解释你的改动做了什么、以及它与系统其他部分如何交互,你的 PR 将被关闭。
Issues submitted Friday through Sunday are not reviewed. If something is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx
使用 AI 辅助编写代码没有问题,但在不理解代码的情况下直接提交 AI 生成的内容是不被接受的。
Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar below will not be reopened or receive a reply.
如果你使用 Agent请在 `sproutclaw` 根目录运行,这样它会自动读取 `AGENTS.md`。你的 Agent 必须遵循该文件中的规则和指南。
Approval happens through maintainer replies on issues:
## 贡献门槛
- `lgtmi`: your future issues will not be auto-closed
- `lgtm`: your future issues and PRs will not be auto-closed
来自新贡献者的 issue 和 PR 默认会被自动关闭。
`lgtmi` does not grant rights to submit PRs. Only `lgtm` grants rights to submit PRs.
周五至周日提交的 issue 不保证会被审查。如有紧急问题,请在内部联系维护者。
## Quality Bar For Issues
维护者会每日审查被自动关闭的 issue并重新开放值得处理的内容。不符合以下质量标准的 issue 将不会被重新开放或收到回复。
If you open an issue, you must use one of the two GitHub issue templates.
审批通过由维护者在 issue 中回复:
If you open an issue, keep it short, concrete, and worth reading.
- `lgtmi`:你未来提交的 issue 将不再被自动关闭
- `lgtm`:你未来提交的 issue 和 PR 将不再被自动关闭
- Keep it concise. If it does not fit on one screen, it is too long.
- Write in your own voice.
- State the bug or request clearly.
- Explain why it matters.
- If you want to implement the change yourself, say so.
`lgtmi` 不授予提交 PR 的权限,只有 `lgtm` 才授予该权限。
If the issue is real and written well, a maintainer may reopen it, reply `lgtmi`, or reply `lgtm`.
## Issue 质量标准
## Blocking
提交 issue 时,必须使用两个 GitHub issue 模板之一。
If you ignore this document twice, or if you spam the tracker with agent-generated issues, your GitHub account will be permanently blocked.
提交的 issue 应简洁、具体、有阅读价值:
If you send a large volume of issues through automation, your GitHub account will be permanently blocked. No taksies backsies.
- 保持简短。如果超过一屏,就太长了。
- 用自己的语言表达(不要用 LLM 生成文本;如果必须,请在后续评论中明确标注为 AI 生成)。
- 清晰说明 bug 或需求。
- 解释其重要性。
- 如果你希望自己来实现,请说明。
## Before Submitting a PR
如果 issue 真实且表述良好,维护者可能会重新开放、回复 `lgtmi` 或回复 `lgtm`
Do not open a PR unless you have already been approved with `lgtm`.
## 封禁规则
Before submitting a PR:
如果你两次无视本文档,或通过 Agent 批量刷 issue你的账号将被永久封禁。
如果你通过自动化手段大量提交 issue你的账号将被永久封禁且不可申诉。
## 提交 PR 前
未获得 `lgtm` 批准前,请勿提交 PR。
提交 PR 前,请先执行:
```bash
npm run check
./test.sh
```
Both must pass.
两项均须通过。
Do not edit `CHANGELOG.md`. Changelog entries are added by maintainers.
不要修改 `CHANGELOG.md`,更新日志条目由维护者添加。
If you are adding a new provider to `packages/ai`, see `AGENTS.md` for required tests.
如果你要在 `packages/ai` 中添加新的 provider请参阅 `AGENTS.md` 中关于必要测试的说明。
## Philosophy
## 有疑问?
pi's core is minimal. If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected.
请联系项目维护者。
## Questions?
## 常见问题
Ask on [Discord](https://discord.com/invite/nKXTsAcmbT).
### 为什么新 issue 和 PR 会被自动关闭?
## FAQ
SproutClaw 收到的 issue 数量超过维护者能够实时处理的上限。许多报告不符合本指南的质量标准,或者是通过 Agent 随意提交而未经本人审查和整理的。自动关闭机制让维护者可以按自己的节奏审查,并重新开放符合质量要求的 issue。
### Why are new issues and PRs auto-closed?
### 为什么周末的 issue 优先级较低?
pi receives more issues than the maintainers can responsibly review in real time. Many reports do not meet the quality bar in this guide or do not follow CONTRIBUTING.md. Some are slung at the repository mindlessly via an agent instead of being reviewed and shaped by the person submitting them. Auto-closing creates a buffer so maintainers can review the tracker on their own schedule and reopen the issues that meet the quality bar.
维护者在工作时间处理 issue。周末可能积累更多未处理内容周五至周日提交的 issue 可能在周一审查时被遗漏或优先级降低。如有紧急问题,请直接联系维护者并附上简要描述、复现步骤和相关日志。
### Why are weekend issues not reviewed?
### 为什么有些 issue 没有收到回复?
Maintainers need uninterrupted time away from the issue tracker. Issues submitted Friday through Sunday are auto-closed and are not part of the Monday review queue. If a problem is urgent, ask on Discord and include the short version, a repro, and the relevant logs.
回复本身也是一种维护成本。低质量 issue、表述不清的报告、重复 issue 以及不遵循本指南的 issue 可能被直接关闭而不作说明。这样可以将时间留给可复现的 bug、深思熟虑的需求以及真正用心整理报告的贡献者。
### Why do some issues get no reply?
### 为什么不用 AI 来做所有分类工作?
A reply is maintenance work too. Low-signal issues, unclear reports, duplicates, and issues that do not follow this guide may be closed without discussion. This keeps time available for reproducible bugs, thoughtful requests, and contributors who have done the work to make their report actionable.
AI 可以帮助归纳重复内容、汇总报告、发现缺失信息,但不被信任来做最终的维护决策。经过润色的 AI 生成 issue 仍然可能是错误的、有误导性的,或需要大量时间排查。人工审查始终是最终把关环节。
### Why not let AI triage everything?
### 这对贡献者是否过于苛刻?
AI can help group duplicates, summarize reports, and spot missing information. It is not trusted to make final maintainer decisions. Polished AI-generated issues can still be wrong, misleading, or expensive to investigate. Human review remains the final gate.
### Is this hostile to contributors?
No. It is a guardrail against burnout and tracker spam. Short, concrete, reproducible issues are welcome. Thoughtful contributions are welcome. Automated slop, entitlement, and large volumes of low-effort reports are not.
不是。这是防止维护者精力耗尽和 issue 刷屏的保护机制。简洁、具体、可复现的 issue 是受欢迎的,有深度的贡献同样受欢迎。自动化生成的低质量内容、无理取闹以及大量低价值报告则不被接受。

183
README.md
View File

@@ -1,6 +1,6 @@
# sproutclaw
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 pi-mono 改造重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 [pi-mono](https://github.com/badlogic/pi-mono) 改造重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
## 定位
@@ -8,51 +8,192 @@
- 开发协作助手:理解本地项目结构,执行代码修改、测试验证和 Git 工作流。
- Docker 部署助手:按项目目录组织 `docker compose` 服务,关注数据持久化、端口规划和资源限制。
- 内网服务助手:适配 smallmengya、bigmengya、alycd 等内网服务器使用习惯。
- WebUI/TUI 双入口:保留控制台 TUI同时扩展网页前端,方便在浏览器里管理会话和模型
- WebUI/TUI 双入口:保留控制台 TUI同时提供独立的网页前端(`sproutclaw-web`
## 项目结构
| 路径 | 说明 |
| --- | --- |
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体 |
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体(含 sproutclaw 定制补丁) |
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
| `packages/tui` | 终端 UI 渲染库 |
| `packages/web-ui` | Web UI 组件 |
| `.pi/extensions/webui` | 本地扩展的网页对话入口 |
| `sproutclaw` / `sproutclaw.bat` | 启动入口(从已构建 `dist/cli.js` 启动) |
| `build.sh` / `build.bat` | 同上,使用已构建版启动 |
| `test.sh` / `test.bat` | 从源码tsx启动用于开发调试 |
| `scripts/sync-upstream.sh` | 合并上游 pi-mono 更新 |
| `scripts/push-sproutclaw.sh` | 推送到 GitHub / Gitea |
> `.sproutclaw/agent/` 为本地持久化配置目录settings、auth、sessions、extensions、skills 等),已整体加入 `.gitignore`,不进入版本控制。与原版 pi 的 `~/.pi/agent/` 完全隔离。
## 常用命令
安装依赖并构建:
```bash
npm install
npm install --ignore-scripts
npm run build
npm run check
./test.sh
./pi-test.sh
```
运行本地源码版 Agent
从源码运行(开发调试,无需 build
```bash
./pi-test.sh
./test.sh
```
启动 WebUI 时,在 TUI 中执行
从已构建版运行(日常更快)
```bash
./build.sh
# 或
./sproutclaw
```
检查代码:
```bash
npm run check
```
## WebUIsproutclaw-web
WebUI 是独立项目,位于 `../sproutclaw-web/`,通过 systemd 以服务形式运行。
构建后端:
```bash
cd ../sproutclaw-web
bash build.sh
```
构建前端:
```bash
cd ../sproutclaw-web/frontend
npm install && npm run build
```
管理服务:
```bash
systemctl start sproutclaw-web
systemctl stop sproutclaw-web
systemctl restart sproutclaw-web
systemctl status sproutclaw-web
```
## Git 分支策略
本仓库是 pi-mono 的 fork同时维护**上游同步**和 **sproutclaw 定制**
| 分支 | 用途 |
| --- | --- |
| `main` | 稳定可跑版本:已合并的上游 + sproutclaw 定制,推送到 GitHub / Gitea |
| `feature/*` | 日常开发WebUI、扩展、补丁等完成后 merge 进 `main` |
### Remote 配置
```text
/webui on 19133
origin → https://github.com/shumengya/sproutclaw.git
upstream → https://github.com/badlogic/pi-mono.git
gitea → ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git
```
关闭 WebUI
首次配置 upstream
```text
/webui off
```bash
git remote add upstream https://github.com/badlogic/pi-mono.git
git remote add gitea ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git
```
### 日常开发
```bash
git checkout -b feature/my-fix
# ... 开发、测试 ...
git add <改动的具体文件>
git commit -m "fix: ..."
git checkout main
git merge feature/my-fix
./scripts/push-sproutclaw.sh
git branch -d feature/my-fix
```
### 推送到 GitHub / Gitea
```bash
./scripts/push-sproutclaw.sh
# 或手动
git push origin main && git push gitea main
```
### 合并上游 pi-mono 更新
```bash
git fetch upstream
git merge upstream/main --no-commit --no-ff
# 解决冲突(见下方惯例),然后:
git add <resolved-files>
PI_ALLOW_LOCKFILE_CHANGE=1 git commit
./scripts/push-sproutclaw.sh
```
**合并冲突处理惯例:**
| 文件类型 | 处理方式 |
| --- | --- |
| `README.md`、sproutclaw 定制代码 | 保留本仓库版本 |
| `*.generated.ts` | 采用 upstream |
| `package-lock.json` | 采用 upstream`npm install --ignore-scripts` |
| 测试/文档/新增上游功能 | 采用 upstream |
合并后必做:
```bash
npm install --ignore-scripts
npm run build
./test.sh
```
### sproutclaw 相对 upstream 的定制改动
合并上游时,以下内容**不要误删**
- `packages/coding-agent/package.json``piConfig.name = sproutclaw``configDir = .sproutclaw`(命名空间隔离)
- `showChangelogOnStartup` 设置项(关闭启动 What's New
- RPC 扩展:`reload` 命令、bash 流式输出(`bash_update`)、`get_extensions` 接口
- `sproutclaw` / `build.sh` / `test.sh` 等启动脚本
- `scripts/sync-upstream.sh``scripts/push-sproutclaw.sh` 等工具脚本
- 本 README 及中文版 CONTRIBUTING.md / SECURITY.md
## 本地配置
`.sproutclaw/agent/` 中以下内容不进 Git
- `settings.json` → 参考 `settings.example.json` 本地复制
- `models.json``auth.json``mcp.json`
- `sessions/``skills/``extensions/``prompts/`
环境变量(可选):
- `SPROUTCLAW_CODING_AGENT_DIR` — 覆盖 agent 配置目录,默认 `<repo>/.sproutclaw/agent`
首次部署:
```bash
cp .sproutclaw/agent/settings.example.json .sproutclaw/agent/settings.json
# 编辑 settings.json 填入 provider / model / auth 信息
```
### 与原版 pi 并存
| | 原版 pi | SproutClaw |
|---|---|---|
| 命令 | `pi` | `sproutclaw` / `mengya` |
| 全局配置 | `~/.pi/agent/` | `~/.sproutclaw/agent/` |
| 项目配置 | `.pi/` | `.sproutclaw/` |
| 环境变量 | `PI_CODING_AGENT_DIR` | `SPROUTCLAW_CODING_AGENT_DIR` |
## 说明
这个仓库是自用分支,默认围绕树萌芽的服务器环境、部署规范和日常开发习惯进行调整。上游能力来自 pi-mono后续会继续精简、增强并沉淀适合服务器运维开发场景的 Agent 工作流。
## License
MIT
这个仓库是自用 fork,默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono通过 `git fetch upstream && git merge upstream/main` 定期同步sproutclaw 定制在 `feature/*` 开发后合入 `main`

58
SECURITY.md Normal file
View File

@@ -0,0 +1,58 @@
# 安全政策
本文档旨在帮助你了解 SproutClaw 的安全设计理念及其边界范围。
## 安全模型概述
SproutClaw 是一个在本地运行的 Coding Agent运行于使用者账号的安全边界之内。监控其操作或将其限制在容器、虚拟机或其他沙箱环境中是使用者自身的责任。
SproutClaw 将本地用户账号及该账号可写的文件视为与 SproutClaw 进程处于同一信任边界内。如果攻击者能够修改用户主目录、工作区、Shell 启动文件、环境变量或 SproutClaw 配置文件,通常即可影响 SproutClaw 或其他本地开发工具的行为。依赖此类本地写入权限的报告不属于安全漏洞,除非其能证明 SproutClaw 本身授予了该写入权限,或越过了操作系统的权限边界。
SproutClaw 依赖使用者仅安装可信扩展、加载可信技能,并仅在可信仓库中使用。这是因为 `AGENTS.md` 等文件中的内容或代码注释可被用于对 Coding Agent 进行提示词注入,且此类攻击无法从根本上防御。
## 报告漏洞
如果你认为在 SproutClaw 中发现了安全漏洞,请通过以下方式私下报告:
- 直接联系项目维护者
报告时请包含:
- 问题描述及其影响
- 复现步骤、概念验证或相关日志
- 受影响的包、版本、提交哈希或配置信息
- 已知的缓解措施(如有)
请勿为安全敏感问题创建公开 issue。
## 范围内In Scope
以下属于安全问题的受理范围:
- 分发包、命令行工具、API 及仓库代码中的安全问题
## 范围外Out of Scope
以下内容不在安全问题受理范围之内:
- 本地代码执行或沙箱行为SproutClaw Coding Agent 有意不提供沙箱)
- 用户自行安装的扩展或技能的行为
- 在不可信仓库中工作所带来的风险
- 安装不可信扩展、技能、包或工具所带来的风险
- 由不可信中间人代理MITM Proxy导致的问题
- 将 SproutClaw 暴露在公网上的风险
- 提示词注入攻击
- 第三方或用户自行控制的凭据泄露
- 需要在目标机器上具备创建、修改、删除或替换文件、目录、符号链接、环境变量、Shell 配置或其他用户可控本地状态的能力才能复现的报告(包括 `~/.pi``~/.pi/agent/models.json`、工作区文件、`AGENTS.md`、技能、扩展、扩展配置、dotfiles 及通过 NFS/漫游配置/dotfile 管理器同步的文件),除非报告能证明 SproutClaw 本身授予了该访问权限
- 由用户有意弱化配置导致的问题
- 针对 SproutClaw Coding Agent 使用可信本地输入/配置的资源耗尽/DoS 声明
- 关于恶意模型输出的报告
- 用户批准或用户主动发起的本地操作被当作漏洞提交的报告
## 报告者须知
最有价值的报告应展示当前可复现的安全边界绕过,并附有已证明的实际影响。仅展示预期本地 Agent 行为、提示词注入或恶意可信扩展/技能的报告,在本安全模型下不构成安全漏洞。
例如,若报告显示向可信 SproutClaw 配置文件写入恶意内容会导致 SproutClaw 执行命令、加载攻击者控制的工具、向攻击者控制的端点发送凭据或改变其他行为,此类报告属于范围之外。
提交报告时,请尽量包含以下信息:受影响的确切路径、包版本或提交 SHA、配置信息以及针对最新版本或最新 `main` 分支的概念验证。对于依赖项报告,请提供证据说明已分发的依赖项确实受影响,且该问题可通过 SproutClaw 触达。

View File

@@ -28,12 +28,9 @@
"packages/*/src/**/*.ts",
"packages/*/test/**/*.ts",
"packages/coding-agent/examples/**/*.ts",
"packages/web-ui/src/**/*.ts",
"packages/web-ui/example/**/*.ts",
"!**/node_modules/**/*",
"!**/test-sessions.ts",
"!**/models.generated.ts",
"!packages/web-ui/src/app.css",
"!packages/mom/data/**/*",
"!!**/node_modules"
]

77
build.bat Normal file
View File

@@ -0,0 +1,77 @@
@echo off
setlocal EnableDelayedExpansion
set "SCRIPT_DIR=%~dp0"
if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
set "PI_CLI_DIST=%SCRIPT_DIR%\packages\coding-agent\dist\cli.js"
if not defined SPROUTCLAW_CODING_AGENT_DIR (
set "SPROUTCLAW_CODING_AGENT_DIR=%SCRIPT_DIR%\.sproutclaw\agent"
)
set "NO_ENV=false"
set "ARGS="
:loop
if "%~1"=="" goto endloop
if "%~1"=="--no-env" (
set "NO_ENV=true"
) else (
if "%ARGS%"=="" (
set "ARGS=%1"
) else (
set "ARGS=%ARGS% %1"
)
)
shift
goto loop
:endloop
if "%NO_ENV%"=="true" (
set "ANTHROPIC_API_KEY="
set "ANTHROPIC_OAUTH_TOKEN="
set "OPENAI_API_KEY="
set "GEMINI_API_KEY="
set "GROQ_API_KEY="
set "CEREBRAS_API_KEY="
set "XAI_API_KEY="
set "OPENROUTER_API_KEY="
set "ZAI_API_KEY="
set "MISTRAL_API_KEY="
set "MINIMAX_API_KEY="
set "MINIMAX_CN_API_KEY="
set "AI_GATEWAY_API_KEY="
set "OPENCODE_API_KEY="
set "COPILOT_GITHUB_TOKEN="
set "GH_TOKEN="
set "GITHUB_TOKEN="
set "HF_TOKEN="
set "GOOGLE_APPLICATION_CREDENTIALS="
set "GOOGLE_CLOUD_PROJECT="
set "GCLOUD_PROJECT="
set "GOOGLE_CLOUD_LOCATION="
set "AWS_PROFILE="
set "AWS_ACCESS_KEY_ID="
set "AWS_SECRET_ACCESS_KEY="
set "AWS_SESSION_TOKEN="
set "AWS_REGION="
set "AWS_DEFAULT_REGION="
set "AWS_BEARER_TOKEN_BEDROCK="
set "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI="
set "AWS_CONTAINER_CREDENTIALS_FULL_URI="
set "AWS_WEB_IDENTITY_TOKEN_FILE="
set "AZURE_OPENAI_API_KEY="
set "AZURE_OPENAI_BASE_URL="
set "AZURE_OPENAI_RESOURCE_NAME="
echo Running without API keys...
)
if not exist "%PI_CLI_DIST%" (
echo Build output not found: %PI_CLI_DIST% >&2
echo Run from repo root: npm run build >&2
exit /b 1
)
node "%PI_CLI_DIST%" %ARGS%

View File

@@ -2,12 +2,13 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PI_CLI_DIST="$SCRIPT_DIR/packages/coding-agent/dist/cli.js"
# Default to the repository-local pi config so this source checkout uses the
# copied learning config instead of ~/.pi.
export PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$SCRIPT_DIR/.pi/agent}"
export SPROUTCLAW_CODING_AGENT_DIR="${SPROUTCLAW_CODING_AGENT_DIR:-$SCRIPT_DIR/.sproutclaw/agent}"
# Align pi-mcp-adapter (and other third-party packages that only read PI_CODING_AGENT_DIR)
# with SproutClaw's agent dir, so MCP configs in .sproutclaw/agent/mcp.json are loaded.
export PI_CODING_AGENT_DIR="$SPROUTCLAW_CODING_AGENT_DIR"
# Check for --no-env flag
NO_ENV=false
ARGS=()
for arg in "$@"; do
@@ -19,7 +20,6 @@ for arg in "$@"; do
done
if [[ "$NO_ENV" == "true" ]]; then
# Unset API keys (see packages/ai/src/env-api-keys.ts)
unset ANTHROPIC_API_KEY
unset ANTHROPIC_OAUTH_TOKEN
unset OPENAI_API_KEY
@@ -58,10 +58,10 @@ if [[ "$NO_ENV" == "true" ]]; then
echo "Running without API keys..."
fi
TSX_BIN="$SCRIPT_DIR/node_modules/.bin/tsx"
if [[ ! -x "$TSX_BIN" ]]; then
echo "tsx not found at $TSX_BIN. Run npm install from the repo root first." >&2
if [[ ! -f "$PI_CLI_DIST" ]]; then
echo "Build output not found: $PI_CLI_DIST" >&2
echo "Run from repo root: npm run build" >&2
exit 1
fi
"$TSX_BIN" "$SCRIPT_DIR/packages/coding-agent/src/cli.ts" ${ARGS[@]+"${ARGS[@]}"}
exec node "$PI_CLI_DIST" ${ARGS[@]+"${ARGS[@]}"}

View File

@@ -1,142 +0,0 @@
# Code Context
## Top-Level Structure
```
.sproutclaw/
├── .claude/ # Claude configuration
├── .github/ # Issue templates, workflows (issue-gate, pr-gate, approve-contributor)
├── .husky/ # Git hooks
├── .pi/ # pi/pi-mono agent configuration (AGENTS.md)
├── node_modules/
├── packages/ # 5 monorepo workspaces
│ ├── ai/ # @earendil-works/pi-ai v0.74.0
│ ├── agent/ # @earendil-works/pi-agent-core v0.74.0
│ ├── coding-agent/ # @earendil-works/pi-coding-agent v0.74.0
│ ├── tui/ # @earendil-works/pi-tui v0.74.0
│ └── web-ui/ # @earendil-works/pi-web-ui v0.74.0
├── scripts/ # CI/release/utility scripts
├── tmp/ # Temp files
├── pi-test.sh # Interactive TUI test script
├── README.md
├── CONTRIBUTING.md
├── AGENTS.md
├── biome.json # Linting/formatting config
├── tsconfig.base.json # Shared TS config
├── tsconfig.json # Root TS config
├── package.json # Workspace root (npm workspaces)
└── bun.lock / package-lock.json
```
## Packages
### 1. `packages/ai` — `@earendil-works/pi-ai`
**Unified LLM API** — automatic model discovery, provider configuration, streaming.
- `src/index.ts` — package entry
- `src/types.ts` — core types (`Api`, `StreamOptions`, `Model`, `KnownProvider`)
- `src/stream.ts` — main streaming implementation
- `src/models.ts` / `src/models.generated.ts` — model registry + auto-generated
- `src/providers/` — individual provider implementations (Anthropic, Google, OpenAI, Mistral, etc.)
- `src/cli.ts` — CLI for model listing etc.
- `src/env-api-keys.ts` — credential detection from env vars
- `src/oauth.ts` — OAuth support
- `src/api-registry.ts` — API/stream provider registration
- `scripts/generate-models.ts` — model list generation script
- Subpath exports per provider (e.g. `./anthropic`, `./google`, etc.)
### 2. `packages/agent` — `@earendil-works/pi-agent-core`
**General-purpose agent** — transport abstraction, state management, attachment support.
- `src/index.ts` — package entry
- `src/types.ts` — core types (agent state, attachments, transports)
- `src/agent.ts` — agent implementation
- `src/agent-loop.ts` — agent main loop
- `src/proxy.ts` — proxy support
- `src/harness/` — test harness utilities
### 3. `packages/coding-agent` — `@earendil-works/pi-coding-agent`
**Coding agent CLI** — the `pi` CLI binary with read, bash, edit, write tools and session management.
- `src/cli.ts` / `src/cli/` — CLI argument parsing / entry point
- `src/main.ts` — main agent runner
- `src/index.ts` — package entry
- `src/config.ts` — configuration management
- `src/core/` — core logic (model resolution, hooks, tool execution, session management)
- `src/modes/` — operational modes
- `src/bun/` — Bun-specific support
- `src/migrations.ts` — data migration utilities
- `src/package-manager-cli.ts` — package manager interaction
- `docs/` — provider setup docs
- `examples/` — extension examples (custom provider, sandbox)
- Binary name: `pi`
### 4. `packages/tui` — `@earendil-works/pi-tui`
**Terminal UI library** — differential rendering for text-based terminal applications.
- `src/index.ts` — package entry
- `src/tui.ts` — core TUI rendering engine
- `src/terminal.ts` — low-level terminal I/O
- `src/keys.ts` / `src/keybindings.ts` — key input handling
- `src/components/` — reusable UI components
- `src/editor-component.ts` — text editor component
- `src/autocomplete.ts` — autocomplete support
- `src/fuzzy.ts` — fuzzy matching
- `src/terminal-image.ts` — image rendering in terminal
- `src/kill-ring.ts` / `src/undo-stack.ts` — editing helpers
- `src/stdin-buffer.ts` / `src/utils.ts` — utilities
### 5. `packages/web-ui` — `@earendil-works/pi-web-ui`
**Reusable web UI components** — AI chat interfaces powered by pi-ai.
- `src/index.ts` — package entry
- `src/app.css` — Tailwind CSS stylesheet
- `src/ChatPanel.ts` — main chat panel component
- `src/components/` — reusable React components
- `src/dialogs/` — dialog components
- `src/prompts/` — prompt templates/management
- `src/storage/` — client-side storage abstraction
- `src/tools/` — tool integration
- `src/utils/` — utilities
- Build: TypeScript + Tailwind CSS
## Root Scripts (`scripts/`)
- `release.mjs` — automated release workflow (version + changelog + publish)
- `sync-versions.js` — lockstep version sync across all packages
- `cost.ts` / `stats.ts` — analytics/telemetry stats
- `tool-stats.ts` / `edit-tool-stats.mjs` / `read-tool-stats.mjs` — tool usage statistics
- `profile-coding-agent-node.mjs` — Node.js profiling for coding agent
- `build-binaries.sh` — binary build script
- `browser-smoke-entry.ts` / `check-browser-smoke.mjs` — browser smoke tests
- `session-transcripts.ts` / `session-context-stats.mjs` — session logging
## Architecture Summary
```
pi-tui (terminal UI) ─── pi-coding-agent (CLI + tools) ─── pi-agent-core (agent loop)
pi-web-ui (web UI) ────────────┤
pi-ai (LLM provider abstraction)
```
- **pi-ai** is the foundation layer providing unified LLM API access (Anthropic, Google, OpenAI, Azure, Mistral, Bedrock, etc.)
- **pi-agent-core** builds on pi-ai with agent loop, transport abstraction, and state management
- **pi-coding-agent** is the main product: a coding agent CLI (`pi` command) with read/bash/edit/write tools and session management
- **pi-tui** is a standalone TUI rendering library used by pi-coding-agent
- **pi-web-ui** provides reusable chat UI components (React) powered by pi-ai
## Key Facts
- **Lockstep versioning**: all packages share the same version (currently 0.74.0)
- **Release automation**: `npm run release:patch` / `release:minor` via `scripts/release.mjs`
- **TypeScript with tsgo**: uses `tsgo` (TypeScript native preview) for fast builds; web-ui uses `tsc`
- **Linting**: Biome (v2.3.5) for formatting and linting (`npm run check`)
- **Testing**: vitest for ai/agent/coding-agent; node --test for tui
- **No `any` types allowed**, standard top-level imports only (no inline `import()`)
- **GitHub CI**: issue-gate, pr-gate, approve-contributor workflows for contributor management
## Start Here
Open `package.json` (root) for workspace structure and available scripts. For a specific package, start with its `package.json` then `src/index.ts` for the public API surface.

6516
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,58 +4,61 @@
"type": "module",
"workspaces": [
"packages/*",
"packages/web-ui/example",
"packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
"packages/coding-agent/examples/extensions/sandbox"
"packages/coding-agent/examples/extensions/sandbox",
"packages/coding-agent/examples/extensions/gondolin"
],
"scripts": {
"clean": "npm run clean --workspaces",
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../web-ui && npm run build",
"dev": "concurrently --names \"ai,agent,coding-agent,web-ui,tui\" --prefix-colors \"cyan,yellow,red,green,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/web-ui && npm run dev\" \"cd packages/tui && npm run dev\"",
"dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"cd packages/ai && npm run dev:tsc\" \"cd packages/web-ui && npm run dev:tsc\"",
"check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke && cd packages/web-ui && npm run check",
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && tsgo --noEmit && npm run check:browser-smoke",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
"check:shrinkwrap": "node scripts/generate-coding-agent-shrinkwrap.mjs --check",
"check:ts-imports": "node scripts/check-ts-relative-imports.mjs",
"profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui",
"profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc",
"test": "npm run test --workspaces --if-present",
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
"version:set": "npm version -ws",
"prepublishOnly": "npm run clean && npm run build && npm run check",
"publish": "npm run prepublishOnly && npm publish -ws --access public",
"publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run",
"publish": "npm run prepublishOnly && node scripts/publish.mjs",
"publish:dry": "npm run prepublishOnly && node scripts/publish.mjs --dry-run",
"release:local": "node scripts/local-release.mjs",
"shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs",
"release:patch": "node scripts/release.mjs patch",
"release:minor": "node scripts/release.mjs minor",
"release:major": "node scripts/release.mjs major",
"release:fix-links": "node scripts/release-notes.mjs fix-github-releases",
"prepare": "husky"
},
"devDependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.26",
"@anthropic-ai/sandbox-runtime": "0.0.26",
"@biomejs/biome": "2.3.5",
"@types/node": "^22.10.5",
"@types/node": "22.19.19",
"@typescript/native-preview": "7.0.0-dev.20260120.1",
"concurrently": "^9.2.1",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"shx": "^0.4.0"
"esbuild": "0.28.1",
"husky": "9.1.7",
"jiti": "2.7.0",
"shx": "0.4.0",
"tsx": "4.22.1",
"typescript": "5.9.3"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.19.0"
},
"version": "0.0.3",
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.30.2",
"get-east-asian-width": "^1.4.0"
},
"overrides": {
"rimraf": "6.1.2",
"gaxios": {
"rimraf": "6.1.2"
}
},
"dependencies": {
"@mistralai/mistralai": "2.2.6"
}
}

View File

@@ -2,6 +2,91 @@
## [Unreleased]
## [0.79.9] - 2026-06-20
### Fixed
- Fixed Node execution environment commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)).
## [0.79.8] - 2026-06-19
### Added
- Added `@earendil-works/pi-agent-core/base` for bundlers that want to pair the agent core with selective `@earendil-works/pi-ai/base` provider registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)).
## [0.79.7] - 2026-06-18
## [0.79.6] - 2026-06-16
## [0.79.5] - 2026-06-16
## [0.79.4] - 2026-06-15
## [0.79.3] - 2026-06-13
## [0.79.2] - 2026-06-12
### Fixed
- Fixed late tool progress callbacks after tool settlement to be ignored instead of emitting stale `tool_execution_update` events ([#5573](https://github.com/earendil-works/pi/issues/5573)).
## [0.79.1] - 2026-06-09
## [0.79.0] - 2026-06-08
### Fixed
- Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)).
## [0.78.1] - 2026-06-04
## [0.78.0] - 2026-05-29
## [0.77.0] - 2026-05-28
### Breaking Changes
- Renamed agent harness `model_select` and `thinking_level_select` events to `model_update` and `thinking_level_update`.
### Added
- Added agent harness tool registry APIs, `tools_update` events, branch-scoped active-tool persistence, and duplicate tool validation.
## [0.76.0] - 2026-05-27
### Fixed
- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)).
## [0.75.5] - 2026-05-23
## [0.75.4] - 2026-05-20
### Changed
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks.
- Removed the package-level development watch script now that the root TypeScript check validates strip-only-compatible sources.
### Fixed
- Fixed tool-call preflight to stop preparing sibling tool calls after the run is aborted ([#4276](https://github.com/earendil-works/pi/issues/4276)).
- Fixed tail truncation for oversized single-line output that ends with a trailing newline ([#4715](https://github.com/earendil-works/pi/issues/4715)).
- Fixed Windows Node execution environment command spawns to hide helper console windows from background processes ([#4699](https://github.com/earendil-works/pi/issues/4699)).
## [0.75.3] - 2026-05-18
## [0.75.2] - 2026-05-18
## [0.75.1] - 2026-05-18
## [0.75.0] - 2026-05-17
### Breaking Changes
- Raised the minimum supported Node.js version to 22.19.0.
## [0.74.1] - 2026-05-16
## [0.74.0] - 2026-05-07
## [0.73.1] - 2026-05-07

View File

@@ -31,6 +31,24 @@ agent.subscribe((event) => {
await agent.prompt("Hello!");
```
## Base Entry Point
Use `@earendil-works/pi-agent-core/base` with `@earendil-works/pi-ai/base` when bundling applications that should include only selected provider transports:
```typescript
import { Agent } from "@earendil-works/pi-agent-core/base";
import { getModel } from "@earendil-works/pi-ai/base";
import { register } from "@earendil-works/pi-ai/anthropic";
register();
const agent = new Agent({
initialState: { model: getModel("anthropic", "claude-sonnet-4-6") },
});
```
The default `@earendil-works/pi-agent-core` entry point remains batteries-included and registers pi-ai's lazy built-in transports for backward compatibility.
## Core Concepts
### AgentMessage vs LLM Message

View File

@@ -1,6 +1,6 @@
# AgentHarness lifecycle
`AgentHarness` is the orchestration layer above the low-level `Agent`. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics.
`AgentHarness` is the orchestration layer above the low-level agent loop. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics.
This document describes the current direction and implemented behavior. Some extension/session-facade details are planned and called out explicitly.
@@ -15,9 +15,22 @@ The intended rule is:
- runtime config setters update future snapshots without mutating the current provider request
- session writes made while busy are durably queued and flushed in deterministic order
- getters return latest harness config, not in-flight snapshots
- listeners/hooks currently receive no facade; if they close over the raw harness and call settlement APIs such as `waitForIdle()` during the active run, they can deadlock. A future facade should expose `runWhenIdle()` instead.
`AssistantMessageStream` already decouples provider transport streaming, such as SSE or websocket reads, from downstream event consumption. The harness can therefore await listeners, extension hooks, persistence, and save-point work without blocking the provider transport reader or reintroducing ad hoc event queues. Lifecycle code should prefer explicit awaited sequencing at harness boundaries over fire-and-forget hook/event settlement.
A final lifecycle hardening pass should prove these guarantees with a broad listener/hook reentrancy test suite.
## Error handling
The current split is:
- low-level capabilities and helpers use `Result<TValue, TError>` where expected failures are contained and must not throw, such as `ExecutionEnv`, filesystem/shell operations, shell-output capture, resource loading, and compaction helpers
- high-level mutation/orchestration APIs such as `Session` and `AgentHarness` reject/throw instead of returning bare results that can be ignored
- public `AgentHarness` failures are normalized to `AgentHarnessError` where practical; subsystem errors are preserved as `cause`
Harness events observe committed state. Public mutators validate required input and persistence before committing when practical, then await notifications. If a hook or subscriber fails after commit, the state change is not rolled back and the public method rejects with `AgentHarnessError` code `"hook"`.
## State model
The harness separates state into four categories.
@@ -31,6 +44,7 @@ Harness config is the latest runtime configuration set by the application or ext
- tools
- active tool names
- resources
- stream options
- system prompt or system prompt provider
Getters return harness config. They do not return the snapshot used by an in-flight provider request.
@@ -52,15 +66,21 @@ A turn snapshot is the concrete state used for one LLM turn. It is created by `c
- thinking level
- all tools
- active tools
- stream options
- derived session id
Static option values are used directly. System-prompt provider callbacks are invoked once per `createTurnState()` call. All logic for that turn uses the same snapshot.
Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied.
Stream options are shallow-copied when a snapshot is created. `headers` and `metadata` maps are shallow-copied; their values are not deep-copied. Credentials from `getApiKeyAndHeaders()` are resolved per provider request so expiring tokens can refresh, but the configured stream options and derived session id come from the current turn snapshot.
### Session
The session contains persisted entries only. Session reads return persisted state and do not include queued writes.
Session storage implementations must persist leaf changes as `leaf` entries. `setLeafId()` is not an in-memory-only cursor update; it appends a durable entry whose `targetId` is the active tree leaf or `null` for root. Reopening storage must reconstruct the current leaf from the latest persisted leaf-affecting entry.
### Pending session writes
Session writes requested while an operation is active are queued as pending session writes. Pending writes are based on session-entry shapes without generated fields (`id`, `parentId`, `timestamp`).
@@ -85,7 +105,7 @@ Structural operations require `phase === "idle"` and synchronously set the phase
- `compact`
- `navigateTree`
Starting another structural operation while the harness is not idle throws.
Starting another structural operation while the harness is not idle rejects with `AgentHarnessError` code `"busy"`.
The following operations are allowed during a turn where appropriate:
@@ -112,8 +132,8 @@ Phase/settlement semantics are still provisional and need a full lifecycle pass.
Queue modes are live, not turn-snapshotted:
- `steeringMode`
- `followUpMode`
- `getSteeringMode()` / `setSteeringMode()`
- `getFollowUpMode()` / `setFollowUpMode()`
Changing a queue mode during a run affects the next queue drain. Queue drains happen at safe points.
@@ -125,9 +145,9 @@ At a save point the harness:
1. flushes pending session writes after the agent-emitted messages for that turn
2. creates a fresh turn snapshot if the low-level loop may continue
3. applies the fresh context/model/thinking-level state before the next provider request
3. applies the fresh context/model/thinking-level/stream-options/session-id state before the next provider request
This lets model, thinking level, tool, resource, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. The loop callbacks are not recreated at save points.
This lets model, thinking level, tool, resource, stream option, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. Because provider transport reading is already decoupled by `AssistantMessageStream`, save-point work and hook settlement can be awaited directly to keep transcript/session ordering deterministic. The loop callbacks are not recreated at save points.
The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at the provider boundary:
@@ -136,21 +156,26 @@ The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at t
No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review.
If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation throws and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message.
If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation rejects with `AgentHarnessError` and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message.
## Hooks and events
Current hooks receive only the event payload. There is no extension context object yet.
The target hook system is described in [hooks.md](./hooks.md).
Event payloads describe what is happening. Harness getters describe latest config for future snapshots.
Summary:
The split between harness-specific events (`AgentHarnessOwnEvent`) and the union of low-level plus harness events (`AgentHarnessEvent`) is provisional but useful for distinguishing hookable harness events from public subscription events.
- `AgentHarness` emits typed hook events and consumes typed results.
- A single hooks implementation owns registration, cleanup, provenance, and result reducers.
- Observational and mutation hooks use one event-specific `on()` API; the event result type determines whether a handler may return a result.
- Result-producing events are reduced by typed reducer tables; app-specific hooks add reducers only for app-specific result-producing events.
- Hook registration provenance is sidecar metadata on the registration. Resource and tool provenance belongs on app-specific concrete value types.
- Hook context should be a plain object of facades, not raw internals or late-bound getter mazes.
A future extension context may expose the harness and a queued-write session facade.
Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing.
## Planned session facade
Extensions should eventually interact with a harness-scoped session facade rather than the raw session.
Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes.
Planned read semantics:
@@ -172,7 +197,9 @@ Agent-emitted messages are persisted on `message_end` to preserve transcript ord
## Abort
Abort is allowed during a turn. It aborts the low-level run and clears low-level steering/follow-up queues.
Abort is allowed during a turn. It aborts the low-level run and clears steering/follow-up queues.
Abort does not clear `nextTurn` messages. Messages queued with `nextTurn()` survive abort and are inserted before the user message on the next user-initiated turn.
Abort does not discard pending session writes. Pending writes flush at the next save point if reached, at `agent_end`, or in operation failure cleanup.
@@ -188,17 +215,272 @@ Branch summary generation is part of the tree navigation operation.
Auto-compaction and retry decision points are not implemented in `AgentHarness` yet.
## Final lifecycle hardening todo
## Test organization
Before treating `AgentHarness` as migration-ready, add a broad test suite that exercises listeners and hooks closing over the harness and calling public APIs during every relevant event:
Harness tests should stay focused by area instead of growing one large catch-all file.
- runtime config setters from low-level lifecycle events and harness events
- resource/tool/model/thinking updates during active turns and save points
- session writes from listeners and hooks, including writes from `settled`
- queue operations from turn events, tool events, and provider hooks
- rejected structural operations while busy
- abort from listeners/hooks
- getter behavior during active operations
- deterministic ordering of agent-emitted messages and pending listener writes
- no deadlocks when async listeners call harness APIs and await them
- phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation
Current structure:
- `packages/agent/test/harness/agent-harness.test.ts`: core lifecycle and public API behavior.
- `packages/agent/test/harness/agent-harness-stream.test.ts`: stream options and provider hook semantics.
Preferred future structure:
- `agent-harness-resources.test.ts`: resource snapshot/loading semantics.
- `agent-harness-tools.test.ts`: tool registry getters, active-tool semantics, and update events.
- `agent-harness-lifecycle.test.ts`: phase/save-point/settled/reentrancy behavior.
Use the `pi-ai` faux provider (`registerFauxProvider`, `fauxAssistantMessage`) for deterministic harness/provider tests. Faux response factories can inspect `StreamOptions`, invoke `options.onPayload`, and return scripted assistant messages without real provider APIs or network access.
Harness coverage is configured separately from the default package test run:
```bash
npm run test:harness
npm run coverage:harness
```
`coverage:harness` runs `test/harness/**/*.test.ts` and reports coverage for `src/harness/**/*.ts` plus the non-harness runtime files it directly exercises (`src/agent.ts` and `src/agent-loop.ts`) into `coverage/harness`. Type-only dependencies such as `src/types.ts` are not included because they have no meaningful runtime coverage.
## Implementation todo
This list tracks the remaining work before treating `AgentHarness` as migration-ready. Active/planned items are ordered from easiest to hardest. Completed items are archived at the bottom.
### 1. Add explicit tool registry read/update semantics
Status: In progress
Done:
- Added `setTools(tools, activeToolNames?)`.
- Added `setActiveTools(toolNames)`.
- Invalid active tool names reject with `AgentHarnessError`.
- Added generic app tool shape via `AgentHarness<TSkill, TPromptTemplate, TTool>`.
- Exported `QueueMode` from core types.
- Added `AgentHarnessOptions.steeringMode` and `followUpMode`.
- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`.
- Added `getTools()` and `getActiveTools()`.
- Added `tools_update` observability events, including active-tool-only updates.
- Active tool changes are persisted as branch-scoped `active_tools_change` entries.
- Duplicate tool names and duplicate active tool names reject.
Remaining:
- None.
Notes:
- Observability design: [observability.md](./observability.md)
### 2. Design per-`AgentHarness` model registry
Status: Planned
Done:
- Current `setModel()` behavior is preserved.
Remaining:
- Decide how applications supply the model registry.
- Decide whether the harness stores concrete `Model` objects, model references, or both.
- Validate model selection against the registry.
- Define model change semantics during active turns and save points.
### 3. Full `AgentHarness` lifecycle/state pass
Status: In progress
Done:
- Removed constructor `void syncFromTree()`, `syncFromTree()`, `liveOperationId`, and `shell()`.
- Added `createTurnState()`, `applyTurnState()`, and `executeTurn()`.
- Added explicit `phase` in place of boolean idle state.
- Save points refresh context, model, thinking level, stream options, and session snapshot state.
- Pending session writes use session-entry shapes without generated fields.
- Pending session writes flush at save points, settlement, and failure cleanup.
- `steer`, `followUp`, and `nextTurn` create user messages from text plus optional images.
- `nextTurn` messages are inserted before the new user prompt.
- Structural compaction/tree operations restore phase with `finally`.
- Public harness failures normalize subsystem causes to `AgentHarnessError`.
- Pending session writes flush one-by-one and are not dropped on failure.
- Queue drains roll back if queue-update notification fails.
- `message_end` persistence happens before subscriber notification.
- `abort()` signals cancellation before notifications and still waits for idle through notification errors.
- Idle model/thinking/tool updates validate and persist before committing in-memory state.
- `setLeafId()` persists durable `leaf` entries so tree navigation survives storage reopen.
Remaining:
- Finalize phase/idle semantics.
- Audit whether `settled` can fire too early.
- Make session writes inside `settled` callbacks deterministic.
- Audit follow-up behavior around `agent_end`.
- Implement auto-compaction decision point.
- Implement retry handling.
- Verify `before_agent_start` hook semantics against coding-agent.
- Decide whether `before_agent_start` needs more turn info such as tools/tool snippets.
- Document or change runtime config event timing while busy.
- Audit `abort()` barrier semantics.
### 4. Implement generic hook/event extension mechanism
Status: Designed in [hooks.md](./hooks.md), not implemented
Done:
- Removed `AgentHarnessContext`.
- Hooks receive only event payloads.
- `emitHook(event)` derives the hook type from `event.type`.
- Provider request/payload hooks have ordered transform semantics.
Remaining:
- Add `HookEvent`, `ResultOf`, registration options with generic source metadata, and the single `AgentHarnessHooks` implementation.
- Move result chaining out of `AgentHarness` into reducer functions.
- Type-check base harness reducers so every result-producing `AgentHarnessEvent` has reducer semantics.
- Make `AgentHarness` accept and expose the concrete hooks instance with constructor inference for app-specific hooks.
- Define the initial harness/context facades exposed through hook context.
- Preserve current provider hook behavior, including stream option patch deletion semantics.
- Add parity tests for reducer semantics: transform chaining, patch chaining, early block/cancel, cleanup, source metadata, and typed app-specific reducer coverage.
Notes:
- Hook design: [hooks.md](./hooks.md)
### 5. Spike semi-durable harness/session recovery
Status: Planned
Done:
- Wrote durability design: [durable-harness.md](./durable-harness.md)
Remaining:
- Decide whether session owns all durable harness state or whether any sidecars are needed for large blobs.
- Define durable entries for queues, pending writes, operations, turns, provider requests, and tool calls.
- Define resume requirements for app-provided tools, models, extensions, resources, hooks, and auth providers.
- Define conservative recovery policy for unfinished agent turns, provider requests, tool calls, compaction, and tree navigation.
- Prototype reducer-based recovery from session entries.
- Decide whether interrupted operations append user-visible messages or only internal operation entries.
Notes:
- Provider streams are not resumable; recovery should restart from durable boundaries or mark operations interrupted.
- Unfinished tool calls are unsafe to retry unless tools declare idempotent/retry-safe behavior.
### 6. Final lifecycle hardening suite
Status: Planned
Done:
- None.
Remaining:
- Add broad listener/hook reentrancy tests across relevant events.
- Test runtime config setters from low-level lifecycle events and harness events.
- Test runtime config observability for model, thinking, resources, tools, active tools, and stream options.
- Test resource/tool/model/thinking/stream-option updates during active turns and save points.
- Test session writes from listeners and hooks, including `settled` writes.
- Test queue operations from turn events, tool events, and provider hooks.
- Test rejected structural operations while busy.
- Test abort from listeners/hooks.
- Test getter behavior during active operations.
- Test deterministic ordering of agent-emitted messages and pending listener writes.
- Test no deadlocks when async listeners call harness APIs and await them.
- Test phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation.
### 7. Later coding-agent migration plan
Status: Planned
Done:
- None.
Remaining:
- Map coding-agent resources to sourced loaders.
- Keep app-level resource dedupe/provenance outside the harness.
- Adapt extension loading to the future hook/session facade.
- Preserve UI/session behavior outside core.
- Move coding-agent stream/auth/retry/header behavior onto harness stream configuration and provider hooks.
---
## Completed implementation todo
### 8. Remove `Agent` dependency from `AgentHarness`
Status: Done
Done:
- `AgentHarness` calls `runAgentLoop()` directly.
- Harness owns run lifecycle, abort controller, queue draining, provider stream config, event reduction, session persistence, pending write flushing, and save-point snapshots.
- Harness tests cover prompt construction, queue draining, abort behavior, save-point refresh, pending write ordering, awaited listener settlement, tool hooks, and provider stream wrapping.
Remaining:
- None.
Notes:
- Broader listener/hook reentrancy coverage is tracked in item 6.
### 9. Finish curated provider/stream configuration
Status: Done
Done:
- Added curated `AgentHarnessOptions.streamOptions`, `getStreamOptions()`, and `setStreamOptions()`.
- Stream options, headers, metadata, and derived session id are snapshotted per turn.
- Harness-owned stream wrapper calls `streamSimple()` and keeps lifecycle-owned `signal` and `reasoning` from the low-level loop.
- `getApiKeyAndHeaders()` resolves credentials per provider request.
- `before_provider_request`, `before_provider_payload`, and `after_provider_response` hooks are implemented.
- Stream option patching supports explicit field deletion and ordered hook chaining.
- `agent-harness-stream.test.ts` covers forwarding, auth merge, hook patching/deletion/chaining, payload hooks, and busy/save-point snapshot behavior.
Remaining:
- None.
### 10. Complete low-level `Result` cleanup
Status: Done
Done:
- Added generic `Result<TValue, TError>` plus helpers.
- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations.
- Split filesystem and shell capabilities.
- Moved JSONL session storage/repo onto filesystem picks instead of direct Node imports.
- Added `ExecutionEnv.appendFile()` for streaming append use cases.
- Updated skill and prompt-template loaders to consume `ExecutionEnv` results.
- Updated shell output capture to return a result and use `ExecutionEnv`, including full-output spill via `appendFile()`.
- Removed `NodeExecutionEnv` from browser-safe root exports.
- Replaced `Buffer` usage in generic truncation utilities with runtime-neutral UTF-8 handling.
- Converted compaction and branch-summary helpers to typed result returns.
- Added `readTextLines()` so JSONL metadata loading reads only the header line.
- Removed no-op abort handling from Node filesystem methods where cancellation is not meaningful.
- Mapped filesystem errors crossing the session boundary to typed `SessionError`.
- Added typed branch-summary errors and cause-aware public harness error normalization.
- Resource loaders report structured diagnostics for non-`not_found` filesystem failures.
- Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output spill.
Remaining:
- None.
Notes:
- Keep low-level capability/helper APIs non-throwing where they return `Result`.
- Keep session storage/repo/session APIs throwing typed `SessionError`.
- Keep public structural harness failures normalized to `AgentHarnessError`.
- Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts`, Node-backed storage/session implementations, or explicit Node-only entry points.
- Audit generic harness utilities for Node globals as APIs are added.
- Audit package exports so browser/generic imports do not pull Node-only modules.
- Keep expanding `ExecutionEnv` and shell-output contract tests as APIs evolve.

View File

@@ -0,0 +1,212 @@
# Durable AgentHarness and session design
<!-- Synced from jot zmnps2zu. Edit this file in-repo going forward. -->
Durable AgentHarness / session design notes.
## Framing
A fully durable `AgentHarness` is not realistic by itself because important dependencies are runtime JS supplied by the host app:
- tool implementations
- model/auth providers
- extensions and hook handlers
- resource loaders
- system-prompt callbacks/modifiers
Tool registries are runtime dependencies. The harness should persist serializable tool configuration, such as active tool names, but not concrete tool implementations.
The practical target is a semi-durable harness:
- session is the durable append-only state tree
- harness persists the state it owns into session entries
- the host app is responsible for recreating compatible non-persistable dependencies on resume
- recovery restarts from durable boundaries, not from an in-flight provider stream
## Session owns durable state
Treat session as all durable agent state, not just transcript history.
Existing session state already includes harness state:
- model changes
- thinking-level changes
- active-tool changes
- leaf entries
- labels
- compactions and branch summaries
- custom messages and custom entries
That suggests continuing with one durable session log rather than adding harness sidecars. Sidecars may still be useful for large blobs, but the session entry should remain the source-of-truth reference.
## What the app must provide on resume
The app must recreate compatible runtime dependencies:
- model registry / model objects
- tool registry
- extension set, versions, and ordering
- resource loaders
- system prompt providers/hooks
- auth providers
- app-specific hooks
Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself.
## Runtime configuration and restore
Constructor options remain explicit runtime configuration and do not read session state. Hidden async restore in a constructor would make failure handling ambiguous.
A future async builder/factory should own durable restore:
```ts
const harness = await AgentHarness.builder()
.env(env)
.session(session)
.model(defaultModel)
.tools(runtimeTools)
.defaultActiveTools(["read", "edit"])
.restore({ missingActiveTools: "fail" });
```
`restore()` should read the active branch, reduce durable harness configuration, apply defaults for missing entries, validate against app-supplied runtime dependencies, construct the harness, and optionally emit `source: "restore"` update events after construction.
For active tools:
- `active_tools_change` entries are branch-scoped durable config.
- If no `active_tools_change` exists on the branch, restore uses builder defaults, or all registered tools if no default active names were supplied.
- Active tool names must be unique.
- Tool registry names must be unique.
- Missing restored active tool names should fail restore by default; permissive drop/disable policies can be added explicitly later.
- Concrete tools are never restored from session; the host app must provide compatible tools.
## What harness should persist
Minimum useful durability entries:
- branch-scoped active tool names
- queued steer/followUp/nextTurn messages
- queue consumption tied to a turn
- pending session writes accepted during active operations
- pending write application status
- operation start/finish/interruption
- turn start/finish
- provider request start/finish, if needed for recovery diagnostics
- tool call start/finish, if we want safe tool recovery
Potential entries:
```ts
type DurableHarnessEntry =
| QueueEnqueuedEntry
| QueueConsumedEntry
| PendingWriteEnqueuedEntry
| PendingWriteAppliedEntry
| OperationStartedEntry
| OperationFinishedEntry
| OperationInterruptedEntry
| TurnStartedEntry
| TurnFinishedEntry
| ProviderRequestStartedEntry
| ProviderRequestFinishedEntry
| ToolCallStartedEntry
| ToolCallFinishedEntry;
```
Every accepted mutation must be durable before the public API resolves.
## Recovery model
On startup:
1. Host app registers tools/models/extensions/resources/auth/hooks.
2. Harness opens session.
3. Harness reduces session entries into:
- current leaf
- conversation branch
- harness config, including active tool names
- queues
- pending writes
- active operation/turn/tool state
4. Harness validates required runtime dependencies, including restored active tool names against the app-provided tool registry.
5. Harness reconciles unfinished operation state.
Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted.
## Recovery policies
Default conservative policy:
- unfinished agent turn: mark interrupted, preserve durable queues/pending writes, return idle
- unfinished provider request: mark interrupted; do not retry automatically
- unfinished tool call: append interrupted/error tool result; retry only if the tool declares retry-safe/idempotent
- unfinished compaction: rerun if no compaction entry exists
- unfinished branch summary/tree navigation: rerun/apply missing summary or leaf entries if safe
Optional policy:
```ts
recovery: "mark_interrupted" | "retry_unfinished"
```
`retry_unfinished` must be guarded around non-idempotent tool calls.
## Critical scenarios
### Queues
- Crash before `queue_enqueued`: message was not accepted.
- Crash after `queue_enqueued`: message is restored.
- Crash after queue drain but before durable turn record: risk of loss/duplication.
- Required invariant: consumed queue IDs must be recorded in `turn_started` or equivalent before they are considered consumed.
### Pending writes
- Crash before `pending_write_enqueued`: write was not accepted.
- Crash after enqueue before apply: recovery applies it.
- Crash after apply before applied marker: deterministic target entry IDs let recovery detect the entry already exists and mark it applied.
### Agent loop turn
- Crash before provider request: retry or mark interrupted.
- Crash during provider request: mark interrupted by default.
- Crash after provider response before assistant message persisted: response is lost unless provider result was journaled.
- Crash after assistant message persisted: recover from durable message.
### Tool calls
- Crash after tool call starts but before result: external side effects may already have happened.
- Default recovery should not rerun non-idempotent tools.
- Tool calls need stable IDs and retry-safety metadata for automatic recovery.
### Compaction
- Crash before summary generation: rerun preparation/summary.
- Crash after generated summary but before compaction entry: rerun unless summary was journaled.
- Crash after compaction entry: operation is complete; append finish marker if missing.
### Branch summary / tree navigation
- Crash before summary: rerun or mark interrupted.
- Crash after summary entry before leaf entry: append missing leaf entry.
- Crash after leaf entry: operation is complete; append finish marker if missing.
## Minimum viable spike
1. Add durable queue entries.
2. Add durable pending write entries with deterministic target IDs.
3. Add operation start/finish/interrupted entries.
4. Add turn start with consumed queue IDs.
5. Recover by reducing the session log.
6. Mark unfinished agent turns interrupted by default.
7. Rerun unfinished compaction/tree operations only when no final entry exists.
8. Do not retry unfinished tool calls unless tool metadata says retry-safe.
## Open questions
- Which remaining harness config entries should move into session first: resources, stream options, system prompt refs?
- Should resolved system prompt text be snapshotted per turn for audit/debug?
- Do we require strict dependency ID/version matching on resume?
- How much provider request data should be journaled?
- Should recovery append user-visible assistant interruption messages or only internal operation entries?
- Should storage support truncating a final partial JSONL line during recovery?

View File

@@ -0,0 +1,445 @@
# AgentHarness hooks design
<!-- Synced from jot 3utlzkxy. Edit this file in-repo going forward. -->
Final design.
## Core model
Events carry their result type as a type-only phantom:
```ts
declare const HookResult: unique symbol;
interface HookEvent<TType extends string, TResult = void> {
type: TType;
readonly [HookResult]?: TResult;
}
type ResultOf<E> = E extends { readonly [HookResult]?: infer R } ? R : void;
type HookHandler<E, Ctx> = (
event: E,
ctx: Ctx,
signal?: AbortSignal,
) => ResultOf<E> | void | Promise<ResultOf<E> | void>;
type HookObserver<E, Ctx> = (
event: E,
ctx: Ctx,
signal?: AbortSignal,
) => void | Promise<void>;
```
Example:
```ts
interface ContextEvent extends HookEvent<"context", { messages?: AgentMessage[] }> {
type: "context";
messages: AgentMessage[];
}
interface ToolCallEvent extends HookEvent<"tool_call", { block?: boolean; reason?: string }> {
type: "tool_call";
toolName: string;
input: Record<string, unknown>;
}
interface MessageEndEvent extends HookEvent<"message_end"> {
type: "message_end";
message: AgentMessage;
}
```
No result map. No spec table. The event type defines its own result.
## Hooks interface
```ts
interface AgentHarnessHooks<E extends HookEvent<string, unknown>, Ctx> {
context: Ctx;
setContext(ctx: Ctx): void;
observe(handler: HookObserver<E, Ctx>): () => void;
on<TType extends E["type"]>(
type: TType,
handler: HookHandler<Extract<E, { type: TType }>, Ctx>,
): () => void;
emit<TEvent extends E>(
event: TEvent,
signal?: AbortSignal,
): Promise<ResultOf<TEvent> | undefined>;
addCleanup(cleanup: () => void | Promise<void>): () => void;
clear(): Promise<void>;
dispose(): Promise<void>;
}
```
Important split:
- `observe()` sees all events, read-only, return ignored.
- `on(type, handler)` participates in that events semantics.
- `emit(event)` is the only thing `AgentHarness` calls.
- `clear()` removes observers/handlers and runs cleanups.
## Default implementation internals
```ts
class DefaultAgentHarnessHooks<E extends HookEvent<string, unknown>, Ctx>
implements AgentHarnessHooks<E, Ctx> {
context: Ctx;
private observers = new Set<HookObserver<E, Ctx>>();
private handlers = new Map<string, Set<HookHandler<any, Ctx>>>();
private cleanups = new Set<() => void | Promise<void>>();
constructor(ctx: Ctx) {
this.context = ctx;
}
setContext(ctx: Ctx): void {
this.context = ctx;
}
observe(handler: HookObserver<E, Ctx>): () => void {
this.observers.add(handler);
return () => this.observers.delete(handler);
}
on(type, handler): () => void {
let handlers = this.handlers.get(type);
if (!handlers) {
handlers = new Set();
this.handlers.set(type, handlers);
}
handlers.add(handler);
return () => handlers.delete(handler);
}
async emit(event, signal?) {
for (const observer of this.observers) {
await observer(event, this.context, signal);
}
switch (event.type) {
case "context":
return this.emitContext(event, signal);
case "before_provider_request":
return this.emitBeforeProviderRequest(event, signal);
case "before_provider_payload":
return this.emitBeforeProviderPayload(event, signal);
case "before_agent_start":
return this.emitBeforeAgentStart(event, signal);
case "tool_call":
return this.emitToolCall(event, signal);
case "tool_result":
return this.emitToolResult(event, signal);
case "session_before_compact":
case "session_before_tree":
return this.emitFirstCancelOrLast(event, signal);
default:
await this.emitObservationHandlers(event, signal);
return undefined;
}
}
}
```
Internal casts are acceptable inside the implementation because `Map<string, ...>` loses specificity. Public API remains typed.
## Mutation semantics
### Observation
```ts
await hooks.emit({ type: "message_end", message }, signal);
```
Observers run. `message_end` handlers run. Return ignored unless that event later gets a result type.
### Context transform
Handlers run in order. Each sees current messages.
```ts
let current = event;
for (const handler of handlers("context")) {
const result = await handler(current, ctx, signal);
if (result?.messages) {
current = { ...current, messages: result.messages };
}
}
return current.messages === event.messages ? undefined : { messages: current.messages };
```
### Provider request / payload
Sequential transform. Each handler sees previous output.
```ts
let current = event;
for (const handler of handlers("before_provider_payload")) {
const result = await handler(current, ctx, signal);
if (result !== undefined) {
current = { ...current, payload: result.payload };
}
}
return changed ? { payload: current.payload } : undefined;
```
### Before agent start
Collect injected messages, chain system prompt.
```ts
let systemPrompt = event.systemPrompt;
const messages = [];
for (const handler of handlers("before_agent_start")) {
const result = await handler({ ...event, systemPrompt }, ctx, signal);
if (result?.messages) messages.push(...result.messages);
if (result?.systemPrompt !== undefined) systemPrompt = result.systemPrompt;
}
return messages.length || systemPrompt !== event.systemPrompt
? { messages, systemPrompt }
: undefined;
```
### Tool call
Sequential, early exit on block.
```ts
for (const handler of handlers("tool_call")) {
const result = await handler(event, ctx, signal);
if (result?.block) return result;
}
```
### Tool result
Sequential patch accumulation. Each handler sees current patched result.
```ts
let current = event;
let modified = false;
for (const handler of handlers("tool_result")) {
const result = await handler(current, ctx, signal);
if (!result) continue;
current = {
...current,
content: result.content ?? current.content,
details: result.details ?? current.details,
isError: result.isError ?? current.isError,
};
modified = true;
}
return modified
? { content: current.content, details: current.details, isError: current.isError }
: undefined;
```
### Session-before events
Sequential, early exit on cancel.
```ts
let last;
for (const handler of handlers(event.type)) {
const result = await handler(event, ctx, signal);
if (!result) continue;
last = result;
if (result.cancel) return result;
}
return last;
```
## Harness usage
Harness only does this:
```ts
await this.hooks.emit(event, signal);
```
or:
```ts
const result = await this.hooks.emit({ type: "context", messages }, signal);
return result?.messages ?? messages;
```
Harness does not store handlers, chain listeners, or know extension policy.
## Context
Context is a normal object, not rebuilt per emit.
```ts
const hooks = new CodingAgentHooks({
harness: harnessFacade,
session: sessionFacade,
ui: noUiFacade,
});
```
Later:
```ts
hooks.setContext({
...hooks.context,
ui: tuiFacade,
});
```
For dynamic state, prefer stable facades/methods over getter maze:
```ts
interface CodingAgentHookContext {
harness: HarnessFacade;
session: SessionFacade;
ui: UiFacade;
models: ModelFacade;
}
```
Per-run `signal` is passed as the third handler arg.
## Extension loading later
Extension loading can live next to harness and construct hooks:
```ts
const hooks = await loadExtensions({
paths,
context,
hooks: new CodingAgentHooks(context),
});
const harness = new AgentHarness({ ..., hooks });
```
The loader registers into hooks:
```ts
hooks.on("context", handler);
hooks.on("tool_call", handler);
hooks.addCleanup(cleanup);
```
For reload:
```ts
await hooks.clear();
const nextHooks = await loadExtensions(...);
harness.setHooks(nextHooks); // idle-only if supported
```
## Poking holes
### 1. Error policy must be explicit
Existing coding-agent catches extension errors, reports them, and continues. New hooks need the same policy, likely:
```ts
errorMode: "continue" | "throw"
onError(error)
```
For coding-agent, default should be `"continue"`.
### 2. Source metadata matters
Existing runner knows which extension produced an error/resource/tool. Plain `on()` loses that unless we add registration metadata or scopes.
Probably needed:
```ts
const scope = hooks.createScope({ sourceInfo });
scope.on("context", handler);
scope.addCleanup(...);
```
Or `on(type, handler, { sourceInfo })`.
### 3. Some extension capabilities are registries, not hooks
These are not covered by `emit()` and should stay as registries on `CodingAgentHooks` or an extension host:
- tools
- commands
- shortcuts
- flags
- message renderers
- provider registrations
- OAuth providers
- custom model providers
That is fine. They do not belong in `AgentHarness`.
### 4. Existing coding-agent events can be represented
No blocker for:
- `context`
- `before_provider_request`
- `after_provider_response`
- `before_agent_start`
- `message_end`
- `tool_call`
- `tool_result`
- `input`
- `user_bash`
- `resources_discover`
- `session_before_*`
- `session_*`
- model/thinking selection events
- agent/turn/message/tool lifecycle events
They become additional event types handled by `CodingAgentHooks`.
### 5. Need to preserve exact old semantics
When porting coding-agent, special cases must be copied:
- `input`: transform chain, `handled` short-circuits.
- `user_bash`: first meaningful result wins.
- `message_end`: replacement must keep same role.
- `before_agent_start`: `ctx.getSystemPrompt()` must reflect current chained prompt.
- `resources_discover`: aggregate paths and keep extension source.
- `tool_call`: argument mutation remains visible to later handlers.
- `tool_result`: later handlers see prior patches.
The design allows all of that, but the default/coding hooks implementation must encode it.
### 6. `emit()` switch can miss custom mutation events
If a subclass adds a result-producing event but forgets to override `emit()`, it will behave observationally. Tests should catch this. Could add a protected strategy registry later if this becomes error-prone, but not initially.
### 7. Observer semantics are intentionally limited
Observers see the original emitted event once. They do not see every intermediate mutation. If something needs final transformed state, emit a separate final event or use an event-specific handler.
## Verdict
This design can implement a new coding-agent. It is simpler than the current runner, keeps harness clean, and preserves the important extension capabilities as long as `CodingAgentHooks` adds source-aware scopes, registries, cleanup, and the exact old event semantics.
--- Comments ---
Thread hn2xk0tzhj on "addCleanup(cleanup"
[tmluyaub9v] Owner (2026-05-14T12:55:45.500Z): cleanup should be passed along optionally to on/observe

View File

@@ -0,0 +1,376 @@
<!-- Synced from jot qe0ikdqs. Edit this file in-repo going forward. -->
# Pi Observability Design Notes
## Goal
Make `packages/ai` and `packages/agent`/harness observable without depending on OpenTelemetry, Sentry, or any APM vendor.
Pi should emit stable, structured lifecycle events. External listeners can convert those events into OTel spans, Sentry spans, logs, metrics, or custom telemetry.
## Mental model
A trace is one causal tree of work, e.g. one user turn.
A span is one timed operation in that tree. It is normally represented by IDs, not object pointers:
```ts
interface SpanRecord {
traceId: string;
spanId: string;
parentSpanId?: string;
name: string;
startTime: number;
endTime?: number;
attributes: Record<string, unknown>;
status: "ok" | "error";
}
```
Example tree:
```text
traceId=t1 spanId=s1 parent=- name=pi.agent.prompt
traceId=t1 spanId=s2 parent=s1 name=pi.agent.turn
traceId=t1 spanId=s3 parent=s2 name=pi.ai.provider.request
traceId=t1 spanId=s4 parent=s2 name=pi.agent.tool_call
traceId=t1 spanId=s5 parent=s4 name=pi.session.append_entry
```
## Async context
JavaScript has one event loop but multiple async chains can interleave. A single global `currentContext` breaks under concurrency.
`AsyncLocalStorage` is the Node equivalent of `ThreadLocal` for async continuations. It lets concurrent operations keep distinct current contexts:
```ts
await Promise.all([
runWithPiContext({ userId: "alice" }, () => harness.prompt("A")),
runWithPiContext({ userId: "bob" }, () => harness.prompt("B")),
]);
```
Deep code can then read the correct current context for the active async chain.
Pi must run in Node, Bun, browser, workers, and other JS runtimes, so ALS cannot be the core abstraction. It should be a runtime adapter.
## Core design
Pi owns a small runtime-agnostic observability abstraction:
```ts
export interface PiObservabilityContext {
traceId?: string;
currentSpanId?: string;
userContext?: Record<string, unknown>;
}
export interface PiObservabilityEvent {
type: "start" | "end" | "error" | "event";
name: string;
traceId: string;
spanId?: string;
parentSpanId?: string;
timestamp: number;
durationMs?: number;
context?: Record<string, unknown>;
payload?: Record<string, unknown>;
error?: { name: string; message: string };
}
export interface PiObservability {
getContext(): PiObservabilityContext | undefined;
runWithContext<T>(context: PiObservabilityContext, fn: () => T): T;
emit(event: PiObservabilityEvent): void;
hasSubscribers(): boolean;
}
```
Public API:
```ts
export function configurePiObservability(observability: PiObservability): void;
export function subscribePiObservability(listener: (event: PiObservabilityEvent) => void): () => void;
export function runWithPiContext<T>(userContext: Record<string, unknown>, fn: () => T): T;
export function traceOperation<T>(name: string, payload: Record<string, unknown>, fn: () => T): T;
```
`traceOperation()`:
1. reads the current context
2. creates `traceId` if missing
3. creates a new `spanId`
4. uses current span as `parentSpanId`
5. emits `start`
6. runs callback under child context
7. emits `end` or `error`
8. rethrows on error
Pseudo-code:
```ts
function traceOperation<T>(name: string, payload: Record<string, unknown>, fn: () => T): T {
const parent = getContext();
const traceId = parent?.traceId ?? createId();
const spanId = createId();
const parentSpanId = parent?.currentSpanId;
const child = { ...parent, traceId, currentSpanId: spanId };
emit({ type: "start", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: parent?.userContext, payload });
return runWithContext(child, () => {
try {
const result = fn();
// Promise-aware implementation emits end/error after settlement.
emit({ type: "end", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload });
return result;
} catch (error) {
emit({ type: "error", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload, error: serializeError(error) });
throw error;
}
});
}
```
## Runtime adapters
Core packages should not import Node-only APIs.
Possible implementations:
- Node adapter: `AsyncLocalStorage` for context, optional `diagnostics_channel` publishing.
- Browser/workers fallback: local subscriber set and limited/manual context propagation.
- Bun/Deno adapters: use runtime-specific async context if available.
For Node, diagnostics channels can be used as a passive event bus:
```ts
import { channel } from "diagnostics_channel";
channel("pi.observability").publish(event);
```
Subscribers can create OTel/Sentry spans without monkey-patching pi.
## What pi emits
Pi emits what happened. It does not create OTel/Sentry spans directly.
Initial minimal event names:
```text
pi.agent.prompt
pi.agent.skill
pi.agent.prompt_template
pi.agent.compaction
pi.agent.branch_navigation
pi.agent.session.append_entry
pi.ai.provider.request
```
Each operation emits:
```text
start
end
error
```
Later additions:
```text
pi.agent.turn
pi.agent.tool_call
pi.agent.queue_update
pi.ai.provider.retry
pi.ai.provider.first_token
pi.ai.provider.usage
pi.session.read
pi.session.write
```
## Minimal instrumentation points
### packages/agent
Wrap:
- `AgentHarness.prompt()`
- `AgentHarness.skill()`
- `AgentHarness.promptFromTemplate()`
- `AgentHarness.compact()`
- `AgentHarness.navigateTree()`
- `Session.appendTypedEntry()` or storage append facade
Example:
```ts
return traceOperation(
"pi.agent.prompt",
{
sessionId: turnState.sessionId,
provider: turnState.model.provider,
model: turnState.model.id,
promptLength: text.length,
imageCount: options?.images?.length ?? 0,
},
() => this.executeTurn(turnState, text, options),
);
```
Session write:
```ts
return traceOperation(
"pi.agent.session.append_entry",
{ entryType: entry.type },
async () => {
await this.unwrap(this.storage.appendEntry(entry));
return entry.id;
},
);
```
### packages/ai
Wrap common provider boundaries:
- `streamSimple()`
- `completeSimple()`
Example:
```ts
return traceOperation(
"pi.ai.provider.request",
{
api: model.api,
provider: model.provider,
model: model.id,
sessionId: options.sessionId,
reasoning: options.reasoning,
},
() => actualStreamSimple(model, context, options),
);
```
End/error payloads can include safe metadata:
- stop reason
- status code
- retry count
- input/output/total tokens
- cost total
- aborted/timeout flag
## Safety and redaction
Default payloads must be safe.
Safe by default:
- provider
- model
- API identifier
- session id
- entry type
- tool name
- status code
- stop reason
- token counts
- costs
- durations
Unsafe by default:
- prompts
- completions
- tool args
- tool results
- shell output
- file contents
- provider request payloads
- provider response bodies
- API keys
- headers
Content capture can be opt-in later with explicit redaction hooks.
## Listener behavior
Observability must never affect pi execution.
Subscriber errors should be swallowed or isolated. Harness hooks are control-plane and may affect execution; observability subscribers are passive and must not.
## User context
Users can associate arbitrary context with a turn:
```ts
await runWithPiContext(
{
userId: "u123",
orgId: "acme",
region: "eu",
},
() => harness.prompt("fix this"),
);
```
Every emitted event inside that async chain includes the context:
```ts
{
type: "start",
name: "pi.ai.provider.request",
traceId: "t1",
spanId: "s3",
parentSpanId: "s1",
context: {
userId: "u123",
orgId: "acme",
region: "eu",
},
payload: {
provider: "anthropic",
model: "claude-sonnet-4",
},
}
```
An OTel adapter can map this to span attributes. A Sentry adapter can map it to Sentry context/spans. A custom user can log JSON.
## Package story
Minimal initial package:
```text
packages/observability
runtime-agnostic context + traceOperation + subscribe
```
Then:
```text
packages/ai
emits pi.ai.* events
packages/agent
emits pi.agent.* / pi.session.* events
```
Optional later:
```text
packages/observability-node
AsyncLocalStorage + diagnostics_channel bridge
packages/otel
subscribes to pi events and creates OpenTelemetry spans
```
## Thesis
Pi defines a stable, safe event contract. Adapters define where events go.
This makes ai/harness observable without binding core packages to OTel, Sentry, Node-only APIs, or monkey-patching.

View File

@@ -1,10 +1,25 @@
{
"name": "@earendil-works/pi-agent-core",
"version": "0.74.0",
"version": "0.79.9",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./base": {
"types": "./dist/base.d.ts",
"import": "./dist/base.js"
},
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md"
@@ -12,15 +27,16 @@
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json",
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"test": "vitest --run",
"test:harness": "vitest --run --config vitest.harness.config.ts",
"coverage:harness": "vitest --run --config vitest.harness.config.ts --coverage",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.74.0",
"ignore": "^7.0.5",
"typebox": "^1.1.24",
"yaml": "^2.8.2"
"@earendil-works/pi-ai": "^0.79.9",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
},
"keywords": [
"ai",
@@ -33,15 +49,16 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi-mono.git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/agent"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.19.0"
},
"devDependencies": {
"@types/node": "^24.3.0",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
"@types/node": "24.12.4",
"@vitest/coverage-v8": "4.1.9",
"typescript": "5.9.3",
"vitest": "4.1.9"
}
}

View File

@@ -10,7 +10,7 @@ import {
streamSimple,
type ToolResultMessage,
validateToolArguments,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
import type {
AgentContext,
AgentEvent,
@@ -20,7 +20,7 @@ import type {
AgentToolCall,
AgentToolResult,
StreamFn,
} from "./types.js";
} from "./types.ts";
export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
@@ -436,6 +436,10 @@ async function executeToolCallsSequential(
await emitToolResultMessage(toolResultMessage, emit);
finalizedCalls.push(finalized);
messages.push(toolResultMessage);
if (signal?.aborted) {
break;
}
}
return {
@@ -471,6 +475,9 @@ async function executeToolCallsParallel(
} satisfies FinalizedToolCallOutcome;
await emitToolExecutionEnd(finalized, emit);
finalizedCalls.push(finalized);
if (signal?.aborted) {
break;
}
continue;
}
@@ -487,6 +494,9 @@ async function executeToolCallsParallel(
await emitToolExecutionEnd(finalized, emit);
return finalized;
});
if (signal?.aborted) {
break;
}
}
const orderedFinalizedCalls = await Promise.all(
@@ -578,6 +588,13 @@ async function prepareToolCall(
},
signal,
);
if (signal?.aborted) {
return {
kind: "immediate",
result: createErrorToolResult("Operation aborted"),
isError: true,
};
}
if (beforeResult?.block) {
return {
kind: "immediate",
@@ -586,6 +603,13 @@ async function prepareToolCall(
};
}
}
if (signal?.aborted) {
return {
kind: "immediate",
result: createErrorToolResult("Operation aborted"),
isError: true,
};
}
return {
kind: "prepared",
toolCall,
@@ -607,6 +631,7 @@ async function executePreparedToolCall(
emit: AgentEventSink,
): Promise<ExecutedToolCallOutcome> {
const updateEvents: Promise<void>[] = [];
let acceptingUpdates = true;
try {
const result = await prepared.tool.execute(
@@ -614,6 +639,7 @@ async function executePreparedToolCall(
prepared.args as never,
signal,
(partialResult) => {
if (!acceptingUpdates) return;
updateEvents.push(
Promise.resolve(
emit({
@@ -627,14 +653,18 @@ async function executePreparedToolCall(
);
},
);
acceptingUpdates = false;
await Promise.all(updateEvents);
return { result, isError: false };
} catch (error) {
acceptingUpdates = false;
await Promise.all(updateEvents);
return {
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
isError: true,
};
} finally {
acceptingUpdates = false;
}
}

View File

@@ -7,8 +7,8 @@ import {
type TextContent,
type ThinkingBudgets,
type Transport,
} from "@earendil-works/pi-ai";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js";
} from "@earendil-works/pi-ai/base";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import type {
AfterToolCallContext,
AfterToolCallResult,
@@ -21,9 +21,12 @@ import type {
AgentTool,
BeforeToolCallContext,
BeforeToolCallResult,
QueueMode,
StreamFn,
ToolExecutionMode,
} from "./types.js";
} from "./types.ts";
export type { QueueMode } from "./types.ts";
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
return messages.filter(
@@ -53,8 +56,6 @@ const DEFAULT_MODEL = {
maxTokens: 0,
} satisfies Model<any>;
export type QueueMode = "all" | "one-at-a-time";
type MutableAgentState = Omit<AgentState, "isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage"> & {
isStreaming: boolean;
streamingMessage?: AgentMessage;
@@ -116,8 +117,11 @@ export interface AgentOptions {
class PendingMessageQueue {
private messages: AgentMessage[] = [];
public mode: QueueMode;
constructor(public mode: QueueMode) {}
constructor(mode: QueueMode) {
this.mode = mode;
}
enqueue(message: AgentMessage): void {
this.messages.push(message);

View File

@@ -0,0 +1,39 @@
export * from "./agent.ts";
export * from "./agent-loop.ts";
export * from "./harness/agent-harness.ts";
export {
type BranchPreparation,
type BranchSummaryDetails,
type CollectEntriesResult,
collectEntriesForBranchSummary,
generateBranchSummary,
prepareBranchEntries,
} from "./harness/compaction/branch-summarization.ts";
export {
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
estimateTokens,
findCutPoint,
findTurnStartIndex,
generateSummary,
getLastAssistantUsage,
prepareCompaction,
serializeConversation,
shouldCompact,
} from "./harness/compaction/compaction.ts";
export * from "./harness/messages.ts";
export * from "./harness/prompt-templates.ts";
export * from "./harness/session/jsonl-repo.ts";
export * from "./harness/session/memory-repo.ts";
export * from "./harness/session/repo-utils.ts";
export * from "./harness/session/session.ts";
export { uuidv7 } from "./harness/session/uuid.ts";
export * from "./harness/skills.ts";
export * from "./harness/system-prompt.ts";
export * from "./harness/types.ts";
export * from "./harness/utils/shell-output.ts";
export * from "./harness/utils/truncate.ts";
export * from "./proxy.ts";
export * from "./types.ts";

File diff suppressed because it is too large Load Diff

View File

@@ -1,115 +1,80 @@
/**
* Branch summarization for tree navigation.
*
* When navigating to a different point in the session tree, this generates
* a summary of the branch being left so context isn't lost.
*/
import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.js";
import { completeSimple, type Model } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../../types.ts";
import {
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import type { Session, SessionTreeEntry } from "../types.js";
import { estimateTokens } from "./compaction.js";
} from "../messages.ts";
import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts";
import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts";
import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
} from "./utils.ts";
// ============================================================================
// Types
// ============================================================================
export interface BranchSummaryResult {
summary?: string;
readFiles?: string[];
modifiedFiles?: string[];
aborted?: boolean;
error?: string;
}
/** Details stored in BranchSummaryEntry.details for file tracking */
/** File-operation details stored on generated branch summary entries. */
export interface BranchSummaryDetails {
/** Files read while exploring the summarized branch. */
readFiles: string[];
/** Files modified while exploring the summarized branch. */
modifiedFiles: string[];
}
export type { FileOperations } from "./utils.js";
export type { FileOperations } from "./utils.ts";
/** Prepared branch content for summarization. */
export interface BranchPreparation {
/** Messages extracted for summarization, in chronological order */
/** Messages selected for the branch summary. */
messages: AgentMessage[];
/** File operations extracted from tool calls */
/** File operations extracted from the branch. */
fileOps: FileOperations;
/** Total estimated tokens in messages */
/** Estimated token count for selected messages. */
totalTokens: number;
}
/** Entries selected for branch summarization. */
export interface CollectEntriesResult {
/** Entries to summarize, in chronological order */
/** Entries to summarize in chronological order. */
entries: SessionTreeEntry[];
/** Common ancestor between old and new position, if any */
/** Deepest common ancestor between the previous leaf and target entry. */
commonAncestorId: string | null;
}
/** Options for generating a branch summary. */
export interface GenerateBranchSummaryOptions {
/** Model to use for summarization */
/** Model used for summarization. */
model: Model<any>;
/** API key for the model */
/** API key forwarded to the provider. */
apiKey: string;
/** Request headers for the model */
/** Optional request headers forwarded to the provider. */
headers?: Record<string, string>;
/** Abort signal for cancellation */
/** Abort signal for the summarization request. */
signal: AbortSignal;
/** Optional custom instructions for summarization */
/** Optional instructions appended to or replacing the default prompt. */
customInstructions?: string;
/** If true, customInstructions replaces the default prompt instead of being appended */
/** Replace the default prompt with custom instructions instead of appending them. */
replaceInstructions?: boolean;
/** Tokens reserved for prompt + LLM response (default 16384) */
/** Tokens reserved for prompt and model output. Defaults to 16384. */
reserveTokens?: number;
}
// ============================================================================
// Entry Collection
// ============================================================================
/**
* Collect entries that should be summarized when navigating from one position to another.
*
* Walks from oldLeafId back to the common ancestor with targetId, collecting entries
* along the way. Does NOT stop at compaction boundaries - those are included and their
* summaries become context.
*
* @param session - Session manager (read-only access)
* @param oldLeafId - Current position (where we're navigating from)
* @param targetId - Target position (where we're navigating to)
* @returns Entries to summarize and the common ancestor
*/
/** Collect entries that should be summarized before navigating to a different session tree entry. */
export async function collectEntriesForBranchSummary(
session: Session,
oldLeafId: string | null,
targetId: string,
): Promise<CollectEntriesResult> {
// If no old position, nothing to summarize
if (!oldLeafId) {
return { entries: [], commonAncestorId: null };
}
// Find common ancestor (deepest node that's on both paths)
const oldPath = new Set((await session.getBranch(oldLeafId)).map((e) => e.id));
const targetPath = await session.getBranch(targetId);
// targetPath is root-first, so iterate backwards to find deepest common ancestor
let commonAncestorId: string | null = null;
for (let i = targetPath.length - 1; i >= 0; i--) {
if (oldPath.has(targetPath[i].id)) {
@@ -117,85 +82,49 @@ export async function collectEntriesForBranchSummary(
break;
}
}
// Collect entries from old leaf back to common ancestor
const entries: SessionTreeEntry[] = [];
let current: string | null = oldLeafId;
while (current && current !== commonAncestorId) {
const entry = await session.getEntry(current);
if (!entry) break;
if (!entry) throw new SessionError("invalid_session", `Entry ${current} not found`);
entries.push(entry as SessionTreeEntry);
current = entry.parentId;
}
// Reverse to get chronological order
entries.reverse();
return { entries, commonAncestorId };
}
// ============================================================================
// Entry to Message Conversion
// ============================================================================
/**
* Extract AgentMessage from a session entry.
* Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.
*/
function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined {
switch (entry.type) {
case "message":
// Skip tool results - context is in assistant's tool call
if (entry.message.role === "toolResult") return undefined;
return entry.message as AgentMessage;
return entry.message;
case "custom_message":
return createCustomMessage(
entry.customType,
entry.content as string | (TextContent | ImageContent)[],
entry.display,
entry.details,
entry.timestamp,
);
return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
case "branch_summary":
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
case "compaction":
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
// These don't contribute to conversation content
case "thinking_level_change":
case "model_change":
case "active_tools_change":
case "custom":
case "label":
case "session_info":
case "leaf":
return undefined;
}
}
/**
* Prepare entries for summarization with token budget.
*
* Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.
* This ensures we keep the most recent context when the branch is too long.
*
* Also collects file operations from:
* - Tool calls in assistant messages
* - Existing branch_summary entries' details (for cumulative tracking)
*
* @param entries - Entries in chronological order
* @param tokenBudget - Maximum tokens to include (0 = no limit)
*/
/** Prepare branch entries for summarization within an optional token budget. */
export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: number = 0): BranchPreparation {
const messages: AgentMessage[] = [];
const fileOps = createFileOps();
let totalTokens = 0;
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
// This ensures we capture cumulative file tracking from nested branch summaries
// Only extract from pi-generated summaries (fromHook !== true), not extension-generated ones
for (const entry of entries) {
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
const details = entry.details as BranchSummaryDetails;
@@ -203,35 +132,26 @@ export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: n
for (const f of details.readFiles) fileOps.read.add(f);
}
if (Array.isArray(details.modifiedFiles)) {
// Modified files go into both edited and written for proper deduplication
for (const f of details.modifiedFiles) {
fileOps.edited.add(f);
}
}
}
}
// Second pass: walk from newest to oldest, adding messages until token budget
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
const message = getMessageFromEntry(entry);
if (!message) continue;
// Extract file ops from assistant messages (tool calls)
extractFileOpsFromMessage(message, fileOps);
const tokens = estimateTokens(message);
// Check budget before adding
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
// If this is a summary entry, try to fit it anyway as it's important context
if (entry.type === "compaction" || entry.type === "branch_summary") {
if (totalTokens < tokenBudget * 0.9) {
messages.unshift(message);
totalTokens += tokens;
}
}
// Stop - we've hit the budget
break;
}
@@ -242,10 +162,6 @@ export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: n
return { messages, fileOps, totalTokens };
}
// ============================================================================
// Summary Generation
// ============================================================================
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
Summary of that exploration:
@@ -280,34 +196,22 @@ Use this EXACT format:
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
/**
* Generate a summary of abandoned branch entries.
*
* @param entries - Session entries to summarize (chronological order)
* @param options - Generation options
*/
/** Generate a summary for abandoned branch entries. */
export async function generateBranchSummary(
entries: SessionTreeEntry[],
options: GenerateBranchSummaryOptions,
): Promise<BranchSummaryResult> {
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
// Token budget = context window minus reserved space for prompt + response
const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens;
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
if (messages.length === 0) {
return { summary: "No content to summarize" };
return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] });
}
// Transform to LLM-compatible messages, then serialize to text
// Serialization prevents the model from treating it as a conversation to continue
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
// Build prompt
let instructions: string;
if (replaceInstructions && customInstructions) {
instructions = customInstructions;
@@ -325,37 +229,34 @@ export async function generateBranchSummary(
timestamp: Date.now(),
},
];
// Call LLM for summarization
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ apiKey, headers, signal, maxTokens: 2048 },
);
// Check if aborted or errored
if (response.stopReason === "aborted") {
return { aborted: true };
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));
}
if (response.stopReason === "error") {
return { error: response.errorMessage || "Summarization failed" };
return err(
new BranchSummaryError(
"summarization_failed",
`Branch summary failed: ${response.errorMessage || "Unknown error"}`,
),
);
}
let summary = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
// Prepend preamble to provide context about the branch summary
summary = BRANCH_SUMMARY_PREAMBLE + summary;
// Compute file lists and append to summary
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
return {
return ok({
summary: summary || "No summary generated",
readFiles,
modifiedFiles,
};
});
}

View File

@@ -1,56 +1,53 @@
/**
* Context compaction for long sessions.
*
* Pure functions for compaction logic. The session manager handles I/O,
* and after compaction the session is reloaded.
*/
import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import type { AgentMessage, ThinkingLevel } from "../../types.js";
import {
type AssistantMessage,
completeSimple,
type ImageContent,
type Model,
type TextContent,
type Usage,
} from "@earendil-works/pi-ai/base";
import type { AgentMessage, ThinkingLevel } from "../../types.ts";
import {
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import { buildSessionContext } from "../session/session.js";
import type { CompactionEntry, SessionTreeEntry } from "../types.js";
} from "../messages.ts";
import { buildSessionContext } from "../session/session.ts";
import { type CompactionEntry, CompactionError, err, ok, type Result, type SessionTreeEntry } from "../types.ts";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
} from "./utils.js";
} from "./utils.ts";
// ============================================================================
// File Operation Tracking
// ============================================================================
/** Details stored in CompactionEntry.details for file tracking */
/** File-operation details stored on generated compaction entries. */
export interface CompactionDetails {
/** Files read in the compacted history. */
readFiles: string[];
/** Files modified in the compacted history. */
modifiedFiles: string[];
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? "undefined";
} catch {
return "[unserializable]";
}
}
/**
* Extract file operations from messages and previous compaction entries.
*/
function extractFileOperations(
messages: AgentMessage[],
entries: SessionTreeEntry[],
prevCompactionIndex: number,
): FileOperations {
const fileOps = createFileOps();
// Collect from previous compaction's details (if pi-generated)
if (prevCompactionIndex >= 0) {
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
if (!prevCompaction.fromHook && prevCompaction.details) {
// fromHook field kept for session file compatibility
const details = prevCompaction.details as CompactionDetails;
if (Array.isArray(details.readFiles)) {
for (const f of details.readFiles) fileOps.read.add(f);
@@ -60,23 +57,12 @@ function extractFileOperations(
}
}
}
// Extract from tool calls in messages
for (const msg of messages) {
extractFileOpsFromMessage(msg, fileOps);
}
return fileOps;
}
// ============================================================================
// Message Extraction
// ============================================================================
/**
* Extract AgentMessage from an entry if it produces one.
* Returns undefined for entries that don't contribute to LLM context.
*/
function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined {
if (entry.type === "message") {
return entry.message as AgentMessage;
@@ -106,47 +92,39 @@ function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage
return getMessageFromEntry(entry);
}
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
/** Generated compaction data ready to be persisted as a compaction entry. */
export interface CompactionResult<T = unknown> {
/** Summary text that replaces compacted history in future context. */
summary: string;
/** Entry id where retained history starts. */
firstKeptEntryId: string;
/** Estimated context tokens before compaction. */
tokensBefore: number;
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
/** Optional implementation-specific details stored with the compaction entry. */
details?: T;
}
// ============================================================================
// Types
// ============================================================================
/** Compaction thresholds and retention settings. */
export interface CompactionSettings {
/** Enable automatic compaction decisions. */
enabled: boolean;
/** Tokens reserved for summary prompt and output. */
reserveTokens: number;
/** Approximate recent-context tokens to keep after compaction. */
keepRecentTokens: number;
}
/** Default compaction settings used by the harness. */
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384,
keepRecentTokens: 20000,
};
// ============================================================================
// Token calculation
// ============================================================================
/**
* Calculate total context tokens from usage.
* Uses the native totalTokens field when available, falls back to computing from components.
*/
/** Calculate total context tokens from provider usage. */
export function calculateContextTokens(usage: Usage): number {
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
/**
* Get usage from an assistant message if available.
* Skips aborted and error messages as they don't have valid usage data.
*/
function getAssistantUsage(msg: AgentMessage): Usage | undefined {
if (msg.role === "assistant" && "usage" in msg) {
const assistantMsg = msg as AssistantMessage;
@@ -157,9 +135,7 @@ function getAssistantUsage(msg: AgentMessage): Usage | undefined {
return undefined;
}
/**
* Find the last non-aborted assistant message usage from session entries.
*/
/** Return usage from the last successful assistant message in session entries. */
export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
@@ -171,10 +147,15 @@ export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | unde
return undefined;
}
/** Estimated context-token usage for a message list. */
export interface ContextUsageEstimate {
/** Estimated total context tokens. */
tokens: number;
/** Tokens reported by the most recent assistant usage block. */
usageTokens: number;
/** Estimated tokens after the most recent assistant usage block. */
trailingTokens: number;
/** Index of the message that provided usage, or null when none exists. */
lastUsageIndex: number | null;
}
@@ -186,10 +167,7 @@ function getLastAssistantUsageInfo(messages: AgentMessage[]): { usage: Usage; in
return undefined;
}
/**
* Estimate context tokens from messages, using the last assistant usage when available.
* If there are messages after the last usage, estimate their tokens with estimateTokens.
*/
/** Estimate context tokens for messages using provider usage when available. */
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {
const usageInfo = getLastAssistantUsageInfo(messages);
@@ -220,37 +198,39 @@ export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEst
};
}
/**
* Check if compaction should trigger based on context usage.
*/
/** Return whether context usage exceeds the configured compaction threshold. */
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
// ============================================================================
// Cut point detection
// ============================================================================
const ESTIMATED_IMAGE_CHARS = 4800;
/**
* Estimate token count for a message using chars/4 heuristic.
* This is conservative (overestimates tokens).
*/
function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number {
if (typeof content === "string") {
return content.length;
}
let chars = 0;
for (const block of content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
} else if (block.type === "image") {
chars += ESTIMATED_IMAGE_CHARS;
}
}
return chars;
}
/** Estimate token count for one message using a conservative character heuristic. */
export function estimateTokens(message: AgentMessage): number {
let chars = 0;
switch (message.role) {
case "user": {
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
if (typeof content === "string") {
chars = content.length;
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
}
}
chars = estimateTextAndImageContentChars(
(message as { content: string | Array<{ type: string; text?: string }> }).content,
);
return Math.ceil(chars / 4);
}
case "assistant": {
@@ -261,25 +241,14 @@ export function estimateTokens(message: AgentMessage): number {
} else if (block.type === "thinking") {
chars += block.thinking.length;
} else if (block.type === "toolCall") {
chars += block.name.length + JSON.stringify(block.arguments).length;
chars += block.name.length + safeJsonStringify(block.arguments).length;
}
}
return Math.ceil(chars / 4);
}
case "custom":
case "toolResult": {
if (typeof message.content === "string") {
chars = message.content.length;
} else {
for (const block of message.content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
if (block.type === "image") {
chars += 4800; // Estimate images as 4000 chars, or 1200 tokens
}
}
}
chars = estimateTextAndImageContentChars(message.content);
return Math.ceil(chars / 4);
}
case "bashExecution": {
@@ -295,14 +264,6 @@ export function estimateTokens(message: AgentMessage): number {
return 0;
}
/**
* Find valid cut points: indices of user, assistant, custom, or bashExecution messages.
* Never cut at tool results (they must follow their tool call).
* When we cut at an assistant message with tool calls, its tool results follow it
* and will be kept.
* BashExecutionMessage is treated like a user message (user-initiated context).
*/
function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, endIndex: number): number[] {
const cutPoints: number[] = [];
for (let i = startIndex; i < endIndex; i++) {
@@ -326,16 +287,16 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end
}
case "thinking_level_change":
case "model_change":
case "active_tools_change":
case "compaction":
case "branch_summary":
case "custom":
case "custom_message":
case "label":
case "session_info":
case "leaf":
break;
}
// branch_summary and custom_message are user-role messages, valid cut points
if (entry.type === "branch_summary" || entry.type === "custom_message") {
cutPoints.push(i);
}
@@ -343,15 +304,10 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end
return cutPoints;
}
/**
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
* Returns -1 if no turn start found before the index.
* BashExecutionMessage is treated like a user message for turn boundaries.
*/
/** Find the user-visible message that starts the turn containing an entry. */
export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: number, startIndex: number): number {
for (let i = entryIndex; i >= startIndex; i--) {
const entry = entries[i];
// branch_summary and custom_message are user-role messages, can start a turn
if (entry.type === "branch_summary" || entry.type === "custom_message") {
return i;
}
@@ -365,31 +321,17 @@ export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: numb
return -1;
}
/** Cut point selected for compaction. */
export interface CutPointResult {
/** Index of first entry to keep */
/** Index of the first entry retained after compaction. */
firstKeptEntryIndex: number;
/** Index of user message that starts the turn being split, or -1 if not splitting */
/** Index of the turn-start entry when the cut splits a turn, otherwise -1. */
turnStartIndex: number;
/** Whether this cut splits a turn (cut point is not a user message) */
/** Whether the selected cut point splits an in-progress turn. */
isSplitTurn: boolean;
}
/**
* Find the cut point in session entries that keeps approximately `keepRecentTokens`.
*
* Algorithm: Walk backwards from newest, accumulating estimated message sizes.
* Stop when we've accumulated >= keepRecentTokens. Cut at that point.
*
* Can cut at user OR assistant messages (never tool results). When cutting at an
* assistant message with tool calls, its tool results come after and will be kept.
*
* Returns CutPointResult with:
* - firstKeptEntryIndex: the entry index to start keeping from
* - turnStartIndex: if cutting mid-turn, the user message that started that turn
* - isSplitTurn: whether we're cutting in the middle of a turn
*
* Only considers entries between `startIndex` and `endIndex` (exclusive).
*/
/** Find the compaction cut point that keeps approximately the requested recent-token budget. */
export function findCutPoint(
entries: SessionTreeEntry[],
startIndex: number,
@@ -401,22 +343,15 @@ export function findCutPoint(
if (cutPoints.length === 0) {
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
}
// Walk backwards from newest, accumulating estimated message sizes
let accumulatedTokens = 0;
let cutIndex = cutPoints[0]; // Default: keep from first message (not header)
let cutIndex = cutPoints[0];
for (let i = endIndex - 1; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type !== "message") continue;
// Estimate this message's size
const messageTokens = estimateTokens(entry.message as AgentMessage);
accumulatedTokens += messageTokens;
// Check if we've exceeded the budget
if (accumulatedTokens >= keepRecentTokens) {
// Find the closest valid cut point at or after this entry
for (let c = 0; c < cutPoints.length; c++) {
if (cutPoints[c] >= i) {
cutIndex = cutPoints[c];
@@ -426,23 +361,16 @@ export function findCutPoint(
break;
}
}
// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
while (cutIndex > startIndex) {
const prevEntry = entries[cutIndex - 1];
// Stop at session header or compaction boundaries
if (prevEntry.type === "compaction") {
break;
}
if (prevEntry.type === "message") {
// Stop if we hit any message
break;
}
// Include this non-message entry (bash, settings change, etc.)
cutIndex--;
}
// Determine if this is a split turn
const cutEntry = entries[cutIndex];
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
@@ -454,9 +382,9 @@ export function findCutPoint(
};
}
// ============================================================================
// Summarization
// ============================================================================
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
@@ -530,10 +458,7 @@ Use this EXACT format:
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
/**
* Generate a summary of the conversation using the LLM.
* If previousSummary is provided, uses the update prompt to merge.
*/
/** Generate or update a conversation summary for compaction. */
export async function generateSummary(
currentMessages: AgentMessage[],
model: Model<any>,
@@ -544,21 +469,17 @@ export async function generateSummary(
customInstructions?: string,
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
): Promise<string> {
const maxTokens = Math.floor(0.8 * reserveTokens);
// Use update prompt if we have a previous summary, otherwise initial prompt
): Promise<Result<string, CompactionError>> {
const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
);
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
if (customInstructions) {
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
}
// Serialize conversation to text so model doesn't try to continue it
// Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)
const llmMessages = convertToLlm(currentMessages);
const conversationText = serializeConversation(llmMessages);
// Build the prompt with conversation wrapped in tags
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
if (previousSummary) {
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
@@ -583,9 +504,16 @@ export async function generateSummary(
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
);
if (response.stopReason === "aborted") {
return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted"));
}
if (response.stopReason === "error") {
throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`);
return err(
new CompactionError(
"summarization_failed",
`Summarization failed: ${response.errorMessage || "Unknown error"}`,
),
);
}
const textContent = response.content
@@ -593,37 +521,36 @@ export async function generateSummary(
.map((c) => c.text)
.join("\n");
return textContent;
return ok(textContent);
}
// ============================================================================
// Compaction Preparation (for extensions)
// ============================================================================
/** Prepared inputs for a compaction run. */
export interface CompactionPreparation {
/** UUID of first entry to keep */
/** Entry id where retained history starts. */
firstKeptEntryId: string;
/** Messages that will be summarized and discarded */
/** Messages summarized into the history summary. */
messagesToSummarize: AgentMessage[];
/** Messages that will be turned into turn prefix summary (if splitting) */
/** Prefix messages summarized separately when compaction splits a turn. */
turnPrefixMessages: AgentMessage[];
/** Whether this is a split turn (cut point in middle of turn) */
/** Whether compaction splits a turn. */
isSplitTurn: boolean;
/** Estimated context tokens before compaction. */
tokensBefore: number;
/** Summary from previous compaction, for iterative update */
/** Previous compaction summary used for iterative updates. */
previousSummary?: string;
/** File operations extracted from messagesToSummarize */
/** File operations extracted from summarized history. */
fileOps: FileOperations;
/** Compaction settions from settings.jsonl */
/** Settings used to prepare compaction. */
settings: CompactionSettings;
}
/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */
export function prepareCompaction(
pathEntries: SessionTreeEntry[],
settings: CompactionSettings,
): CompactionPreparation | undefined {
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
return undefined;
): Result<CompactionPreparation | undefined, CompactionError> {
if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
return ok(undefined);
}
let prevCompactionIndex = -1;
@@ -647,24 +574,18 @@ export function prepareCompaction(
const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens;
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
// Get UUID of first kept entry
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
if (!firstKeptEntry?.id) {
return undefined; // Session needs migration
return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration"));
}
const firstKeptEntryId = firstKeptEntry.id;
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
// Messages to summarize (will be discarded after summary)
const messagesToSummarize: AgentMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
if (msg) messagesToSummarize.push(msg);
}
// Messages for turn prefix summary (if splitting a turn)
const turnPrefixMessages: AgentMessage[] = [];
if (cutPoint.isSplitTurn) {
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
@@ -672,18 +593,14 @@ export function prepareCompaction(
if (msg) turnPrefixMessages.push(msg);
}
}
// Extract file operations from messages and previous compaction
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
// Also extract file ops from turn prefix if splitting
if (cutPoint.isSplitTurn) {
for (const msg of turnPrefixMessages) {
extractFileOpsFromMessage(msg, fileOps);
}
}
return {
return ok({
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
@@ -692,13 +609,9 @@ export function prepareCompaction(
previousSummary,
fileOps,
settings,
};
});
}
// ============================================================================
// Main compaction function
// ============================================================================
const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
Summarize the prefix to provide context for the retained suffix:
@@ -714,15 +627,9 @@ Summarize the prefix to provide context for the retained suffix:
Be concise. Focus on what's needed to understand the kept suffix.`;
/**
* Generate summaries for compaction using prepared data.
* Returns CompactionResult - SessionManager adds uuid/parentUuid when saving.
*
* @param preparation - Pre-calculated preparation from prepareCompaction()
* @param customInstructions - Optional custom focus for the summary
*/
export { serializeConversation } from "./utils.js";
export { serializeConversation } from "./utils.ts";
/** Generate compaction summary data from prepared session history. */
export async function compact(
preparation: CompactionPreparation,
model: Model<any>,
@@ -731,7 +638,7 @@ export async function compact(
customInstructions?: string,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
): Promise<CompactionResult> {
): Promise<Result<CompactionResult, CompactionError>> {
const {
firstKeptEntryId,
messagesToSummarize,
@@ -743,11 +650,13 @@ export async function compact(
settings,
} = preparation;
// Generate summaries (can be parallel if both needed) and merge into one
if (!firstKeptEntryId) {
return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration"));
}
let summary: string;
if (isSplitTurn && turnPrefixMessages.length > 0) {
// Generate both summaries in parallel
const [historyResult, turnPrefixResult] = await Promise.all([
messagesToSummarize.length > 0
? generateSummary(
@@ -761,7 +670,7 @@ export async function compact(
previousSummary,
thinkingLevel,
)
: Promise.resolve("No prior history."),
: Promise.resolve(ok<string, CompactionError>("No prior history.")),
generateTurnPrefixSummary(
turnPrefixMessages,
model,
@@ -772,11 +681,11 @@ export async function compact(
thinkingLevel,
),
]);
// Merge into single summary
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
if (!historyResult.ok) return err(historyResult.error);
if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`;
} else {
// Just generate history summary
summary = await generateSummary(
const summaryResult = await generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
@@ -787,27 +696,20 @@ export async function compact(
previousSummary,
thinkingLevel,
);
if (!summaryResult.ok) return err(summaryResult.error);
summary = summaryResult.value;
}
// Compute file lists and append to summary
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
if (!firstKeptEntryId) {
throw new Error("First kept entry has no UUID - session may need migration");
}
return {
return ok({
summary,
firstKeptEntryId,
tokensBefore,
details: { readFiles, modifiedFiles } as CompactionDetails,
};
});
}
/**
* Generate a summary for a turn prefix (when splitting a turn).
*/
async function generateTurnPrefixSummary(
messages: AgentMessage[],
model: Model<any>,
@@ -816,8 +718,11 @@ async function generateTurnPrefixSummary(
headers?: Record<string, string>,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
): Promise<string> {
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
): Promise<Result<string, CompactionError>> {
const maxTokens = Math.min(
Math.floor(0.5 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
);
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
@@ -836,13 +741,22 @@ async function generateTurnPrefixSummary(
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers },
);
if (response.stopReason === "aborted") {
return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"));
}
if (response.stopReason === "error") {
throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);
return err(
new CompactionError(
"summarization_failed",
`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`,
),
);
}
return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
return ok(
response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n"),
);
}

View File

@@ -1,20 +1,17 @@
/**
* Shared utilities for compaction and branch summarization.
*/
import type { Message } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.js";
// ============================================================================
// File Operation Tracking
// ============================================================================
import type { Message } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../../types.ts";
/** File paths touched by a session branch or compaction range. */
export interface FileOperations {
/** Files read but not necessarily modified. */
read: Set<string>;
/** Files written by full-file write operations. */
written: Set<string>;
/** Files modified by edit operations. */
edited: Set<string>;
}
/** Create an empty file-operation accumulator. */
export function createFileOps(): FileOperations {
return {
read: new Set(),
@@ -23,9 +20,7 @@ export function createFileOps(): FileOperations {
};
}
/**
* Extract file operations from tool calls in an assistant message.
*/
/** Add file operations from assistant tool calls to an accumulator. */
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
if (message.role !== "assistant") return;
if (!("content" in message) || !Array.isArray(message.content)) return;
@@ -55,10 +50,7 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
}
}
/**
* Compute final file lists from file operations.
* Returns readFiles (files only read, not modified) and modifiedFiles.
*/
/** Compute sorted read-only and modified file lists from accumulated operations. */
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
const modified = new Set([...fileOps.edited, ...fileOps.written]);
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();
@@ -66,9 +58,7 @@ export function computeFileLists(fileOps: FileOperations): { readFiles: string[]
return { readFiles: readOnly, modifiedFiles };
}
/**
* Format file operations as XML tags for summary.
*/
/** Format file lists as summary metadata tags. */
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
const sections: string[] = [];
if (readFiles.length > 0) {
@@ -81,31 +71,23 @@ export function formatFileOperations(readFiles: string[], modifiedFiles: string[
return `\n\n${sections.join("\n\n")}`;
}
// ============================================================================
// Message Serialization
// ============================================================================
/** Maximum characters for a tool result in serialized summaries. */
const TOOL_RESULT_MAX_CHARS = 2000;
/**
* Truncate text to a maximum character length for summarization.
* Keeps the beginning and appends a truncation marker.
*/
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? "undefined";
} catch {
return "[unserializable]";
}
}
function truncateForSummary(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
const truncatedChars = text.length - maxChars;
return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`;
}
/**
* Serialize LLM messages to text for summarization.
* This prevents the model from treating it as a conversation to continue.
* Call convertToLlm() first to handle custom message types.
*
* Tool results are truncated to keep the summarization request within
* reasonable token budgets. Full content is not needed for summarization.
*/
/** Serialize LLM messages to plain text for summarization prompts. */
export function serializeConversation(messages: Message[]): string {
const parts: string[] = [];
@@ -132,7 +114,7 @@ export function serializeConversation(messages: Message[]): string {
} else if (block.type === "toolCall") {
const args = block.arguments as Record<string, unknown>;
const argsStr = Object.entries(args)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.map(([k, v]) => `${k}=${safeJsonStringify(v)}`)
.join(", ");
toolCalls.push(`${block.name}(${argsStr})`);
}
@@ -160,11 +142,3 @@ export function serializeConversation(messages: Message[]): string {
return parts.join("\n\n");
}
// ============================================================================
// Summarization System Prompt
// ============================================================================
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;

View File

@@ -1,33 +1,61 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { access, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { constants, createReadStream } from "node:fs";
import {
access,
appendFile,
lstat,
mkdir,
mkdtemp,
readdir,
readFile,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { type ExecutionEnv, FileError, type FileInfo, type FileKind } from "../types.js";
import { createInterface } from "node:readline";
import {
type ExecutionEnv,
ExecutionError,
err,
FileError,
type FileInfo,
type FileKind,
ok,
type Result,
toError,
} from "../types.ts";
function resolvePath(cwd: string, path: string): string {
return isAbsolute(path) ? path : resolve(cwd, path);
}
function fileKindFromStats(stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileKind {
function fileKindFromStats(stats: {
isFile(): boolean;
isDirectory(): boolean;
isSymbolicLink(): boolean;
}): FileKind | undefined {
if (stats.isFile()) return "file";
if (stats.isDirectory()) return "directory";
if (stats.isSymbolicLink()) return "symlink";
throw new FileError("invalid", "Unsupported file type");
return undefined;
}
function fileInfoFromStats(
path: string,
stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; size: number; mtimeMs: number },
): FileInfo {
return {
): Result<FileInfo, FileError> {
const kind = fileKindFromStats(stats);
if (!kind) return err(new FileError("invalid", "Unsupported file type", path));
return ok({
name: path.replace(/\/+$/, "").split("/").pop() ?? path,
path,
kind: fileKindFromStats(stats),
kind,
size: stats.size,
mtimeMs: stats.mtimeMs,
};
});
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
@@ -36,23 +64,30 @@ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
function toFileError(error: unknown, path?: string): FileError {
if (error instanceof FileError) return error;
const cause = toError(error);
if (isNodeError(error)) {
const message = error.message;
switch (error.code) {
case "ABORT_ERR":
return new FileError("aborted", message, path, cause);
case "ENOENT":
return new FileError("not_found", message, path, { cause: error });
return new FileError("not_found", message, path, cause);
case "EACCES":
case "EPERM":
return new FileError("permission_denied", message, path, { cause: error });
return new FileError("permission_denied", message, path, cause);
case "ENOTDIR":
return new FileError("not_directory", message, path, { cause: error });
return new FileError("not_directory", message, path, cause);
case "EISDIR":
return new FileError("is_directory", message, path, { cause: error });
return new FileError("is_directory", message, path, cause);
case "EINVAL":
return new FileError("invalid", message, path, { cause: error });
return new FileError("invalid", message, path, cause);
}
}
return new FileError("unknown", error instanceof Error ? error.message : String(error), path, { cause: error });
return new FileError("unknown", cause.message, path, cause);
}
function abortResult<TValue>(signal: AbortSignal | undefined, path?: string): Result<TValue, FileError> | undefined {
return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined;
}
async function pathExists(path: string): Promise<boolean> {
@@ -71,7 +106,16 @@ async function runCommand(
): Promise<{ stdout: string; status: number | null }> {
return await new Promise((resolve) => {
let stdout = "";
const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
let child: ReturnType<typeof spawn>;
try {
child = spawn(command, args, {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
} catch {
resolve({ stdout: "", status: null });
return;
}
const timeout = setTimeout(() => {
if (child.pid) killProcessTree(child.pid);
}, timeoutMs);
@@ -100,12 +144,27 @@ async function findBashOnPath(): Promise<string | null> {
return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null;
}
async function getShellConfig(customShellPath?: string): Promise<{ shell: string; args: string[] }> {
interface ShellConfig {
shell: string;
args: string[];
commandTransport?: "argv" | "stdin";
}
function isLegacyWslBashPath(path: string): boolean {
const normalized = path.replace(/\//g, "\\").toLowerCase();
return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized);
}
function getBashShellConfig(shell: string): ShellConfig {
return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] };
}
async function getShellConfig(customShellPath?: string): Promise<Result<ShellConfig, ExecutionError>> {
if (customShellPath) {
if (await pathExists(customShellPath)) {
return { shell: customShellPath, args: ["-c"] };
return ok(getBashShellConfig(customShellPath));
}
throw new Error(`Custom shell path not found: ${customShellPath}`);
return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`));
}
if (process.platform === "win32") {
const candidates: string[] = [];
@@ -115,24 +174,24 @@ async function getShellConfig(customShellPath?: string): Promise<{ shell: string
if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
for (const candidate of candidates) {
if (await pathExists(candidate)) {
return { shell: candidate, args: ["-c"] };
return ok(getBashShellConfig(candidate));
}
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
return ok(getBashShellConfig(bashOnPath));
}
throw new Error("No bash shell found");
return err(new ExecutionError("shell_unavailable", "No bash shell found"));
}
if (await pathExists("/bin/bash")) {
return { shell: "/bin/bash", args: ["-c"] };
return ok(getBashShellConfig("/bin/bash"));
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
return ok(getBashShellConfig(bashOnPath));
}
return { shell: "sh", args: ["-c"] };
return ok({ shell: "sh", args: ["-c"] });
}
function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record<string, string>): NodeJS.ProcessEnv {
@@ -149,6 +208,7 @@ function killProcessTree(pid: number): void {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
windowsHide: true,
});
} catch {
// Ignore errors.
@@ -178,52 +238,92 @@ export class NodeExecutionEnv implements ExecutionEnv {
this.shellEnv = options.shellEnv;
}
async absolutePath(path: string): Promise<Result<string, FileError>> {
return ok(resolvePath(this.cwd, path));
}
async joinPath(parts: string[]): Promise<Result<string, FileError>> {
return ok(join(...parts));
}
async exec(
command: string,
options?: {
cwd?: string;
env?: Record<string, string>;
timeout?: number;
signal?: AbortSignal;
abortSignal?: AbortSignal;
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
},
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
const { shell, args } = await getShellConfig(this.shellPath);
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted"));
return await new Promise((resolvePromise, reject) => {
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
const shellConfig = await getShellConfig(this.shellPath);
if (!shellConfig.ok) return shellConfig;
return await new Promise((resolvePromise) => {
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
const child = spawn(shell, [...args, command], {
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.shellEnv, options?.env),
stdio: ["ignore", "pipe", "pipe"],
});
let callbackError: ExecutionError | undefined;
let child: ReturnType<typeof spawn> | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutId =
const onAbort = () => {
if (child?.pid) {
killProcessTree(child.pid);
}
};
const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
if (settled) return;
settled = true;
resolvePromise(result);
};
try {
const commandFromStdin = shellConfig.value.commandTransport === "stdin";
child = spawn(
shellConfig.value.shell,
commandFromStdin ? shellConfig.value.args : [...shellConfig.value.args, command],
{
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.shellEnv, options?.env),
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
windowsHide: true,
},
);
if (commandFromStdin) {
child.stdin?.on("error", () => {});
child.stdin?.end(command);
}
} catch (error) {
const cause = toError(error);
settle(err(new ExecutionError("spawn_error", cause.message, cause)));
return;
}
timeoutId =
typeof options?.timeout === "number"
? setTimeout(() => {
timedOut = true;
if (child.pid) {
if (child?.pid) {
killProcessTree(child.pid);
}
}, options.timeout * 1000)
: undefined;
const onAbort = () => {
if (child.pid) {
killProcessTree(child.pid);
}
};
if (options?.signal) {
if (options.signal.aborted) {
if (options?.abortSignal) {
if (options.abortSignal.aborted) {
onAbort();
} else {
options.signal.addEventListener("abort", onAbort, { once: true });
options.abortSignal.addEventListener("abort", onAbort, { once: true });
}
}
@@ -231,137 +331,217 @@ export class NodeExecutionEnv implements ExecutionEnv {
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
options?.onStdout?.(chunk);
try {
options?.onStdout?.(chunk);
} catch (error) {
const cause = toError(error);
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
});
child.stderr?.on("data", (chunk: string) => {
stderr += chunk;
options?.onStderr?.(chunk);
try {
options?.onStderr?.(chunk);
} catch (error) {
const cause = toError(error);
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
});
child.on("error", (error) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
if (settled) return;
settled = true;
reject(error);
settle(err(new ExecutionError("spawn_error", error.message, error)));
});
child.on("close", (code) => {
if (timeoutId) clearTimeout(timeoutId);
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
if (settled) return;
settled = true;
if (options?.signal?.aborted) {
reject(new Error("aborted"));
if (callbackError) {
settle(err(callbackError));
return;
}
if (timedOut) {
reject(new Error(`timeout:${options?.timeout}`));
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
return;
}
resolvePromise({ stdout, stderr, exitCode: code ?? 0 });
if (options?.abortSignal?.aborted) {
settle(err(new ExecutionError("aborted", "aborted")));
return;
}
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
});
});
}
async readTextFile(path: string): Promise<string> {
async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<string>(abortSignal, resolved);
if (aborted) return aborted;
try {
return await readFile(resolved, "utf8");
return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal }));
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async readBinaryFile(path: string): Promise<Uint8Array> {
async readTextLines(
path: string,
options?: { maxLines?: number; abortSignal?: AbortSignal },
): Promise<Result<string[], FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<string[]>(options?.abortSignal, resolved);
if (aborted) return aborted;
if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]);
let stream: ReturnType<typeof createReadStream> | undefined;
let lineReader: ReturnType<typeof createInterface> | undefined;
try {
return await readFile(resolved);
stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal });
lineReader = createInterface({ input: stream, crlfDelay: Infinity });
const lines: string[] = [];
for await (const line of lineReader) {
const loopAbort = abortResult<string[]>(options?.abortSignal, resolved);
if (loopAbort) return loopAbort;
lines.push(line);
if (options?.maxLines !== undefined && lines.length >= options.maxLines) break;
}
const afterReadAbort = abortResult<string[]>(options?.abortSignal, resolved);
if (afterReadAbort) return afterReadAbort;
return ok(lines);
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
} finally {
lineReader?.close();
stream?.destroy();
}
}
async writeFile(path: string, content: string | Uint8Array): Promise<void> {
async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<Uint8Array>(abortSignal, resolved);
if (aborted) return aborted;
try {
return ok(await readFile(resolved, { signal: abortSignal }));
} catch (error) {
return err(toFileError(error, resolved));
}
}
async writeFile(
path: string,
content: string | Uint8Array,
abortSignal?: AbortSignal,
): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<void>(abortSignal, resolved);
if (aborted) return aborted;
try {
await mkdir(resolve(resolved, ".."), { recursive: true });
const afterMkdirAbort = abortResult<void>(abortSignal, resolved);
if (afterMkdirAbort) return afterMkdirAbort;
await writeFile(resolved, content, { signal: abortSignal });
return ok(undefined);
} catch (error) {
return err(toFileError(error, resolved));
}
}
async appendFile(path: string, content: string | Uint8Array): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
await mkdir(resolve(resolved, ".."), { recursive: true });
await writeFile(resolved, content);
await appendFile(resolved, content);
return ok(undefined);
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async fileInfo(path: string): Promise<FileInfo> {
async fileInfo(path: string): Promise<Result<FileInfo, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
return fileInfoFromStats(resolved, await lstat(resolved));
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async listDir(path: string): Promise<FileInfo[]> {
async listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<FileInfo[]>(abortSignal, resolved);
if (aborted) return aborted;
try {
const entries = await readdir(resolved, { withFileTypes: true });
const infos: FileInfo[] = [];
for (const entry of entries) {
const loopAbort = abortResult<FileInfo[]>(abortSignal, resolved);
if (loopAbort) return loopAbort;
const entryPath = resolve(resolved, entry.name);
try {
infos.push(fileInfoFromStats(entryPath, await lstat(entryPath)));
const info = fileInfoFromStats(entryPath, await lstat(entryPath));
if (info.ok) infos.push(info.value);
} catch (error) {
if (error instanceof FileError && error.code === "invalid") continue;
throw error;
return err(toFileError(error, entryPath));
}
}
return infos;
return ok(infos);
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async realPath(path: string): Promise<string> {
async canonicalPath(path: string): Promise<Result<string, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
return await realpath(resolved);
return ok(await realpath(resolved));
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async exists(path: string): Promise<boolean> {
async exists(path: string): Promise<Result<boolean, FileError>> {
const result = await this.fileInfo(path);
if (result.ok) return ok(true);
if (result.error.code === "not_found") return ok(false);
return err(result.error);
}
async createDir(path: string, options?: { recursive?: boolean }): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
await this.fileInfo(path);
return true;
await mkdir(resolved, { recursive: options?.recursive ?? true });
return ok(undefined);
} catch (error) {
if (error instanceof FileError && error.code === "not_found") return false;
throw error;
return err(toFileError(error, resolved));
}
}
async createDir(path: string, options?: { recursive?: boolean }): Promise<void> {
await mkdir(resolvePath(this.cwd, path), { recursive: options?.recursive });
}
async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> {
async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
await rm(resolved, { recursive: options?.recursive ?? false, force: options?.force ?? false });
return ok(undefined);
} catch (error) {
throw toFileError(error, resolved);
return err(toFileError(error, resolved));
}
}
async createTempDir(prefix: string = "tmp-"): Promise<string> {
return await mkdtemp(join(tmpdir(), prefix));
async createTempDir(prefix: string = "tmp-"): Promise<Result<string, FileError>> {
try {
return ok(await mkdtemp(join(tmpdir(), prefix)));
} catch (error) {
return err(toFileError(error));
}
}
async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string> {
async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<Result<string, FileError>> {
const dir = await this.createTempDir("tmp-");
const filePath = join(dir, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
await writeFile(filePath, "");
return filePath;
if (!dir.ok) return dir;
const filePath = join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
try {
await writeFile(filePath, "");
return ok(filePath);
} catch (error) {
return err(toFileError(error, filePath));
}
}
async cleanup(): Promise<void> {

View File

@@ -1,3 +0,0 @@
export { NodeExecutionEnv } from "./env/nodejs.js";
export type { ExecutionEnv, ExecutionEnvExecOptions, FileErrorCode, FileInfo, FileKind } from "./types.js";
export { FileError } from "./types.js";

View File

@@ -1,5 +1,5 @@
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../types.js";
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../types.ts";
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
@@ -51,7 +51,7 @@ export interface CompactionSummaryMessage {
timestamp: number;
}
declare module "../types.js" {
declare module "../types.ts" {
interface CustomAgentMessages {
bashExecution: BashExecutionMessage;
custom: CustomMessage;

View File

@@ -1,10 +1,14 @@
import { parse } from "yaml";
import type { ExecutionEnv, FileInfo, PromptTemplate } from "./types.js";
import { type ExecutionEnv, type FileInfo, type PromptTemplate, type Result, toError } from "./types.ts";
export type PromptTemplateDiagnosticCode = "file_info_failed" | "list_failed" | "read_failed" | "parse_failed";
/** Warning produced while loading prompt templates. */
export interface PromptTemplateDiagnostic {
/** Diagnostic severity. Currently only warnings are emitted. */
type: "warning";
/** Stable diagnostic code. */
code: PromptTemplateDiagnosticCode;
/** Human-readable diagnostic message. */
message: string;
/** Path associated with the diagnostic. */
@@ -30,9 +34,20 @@ export async function loadPromptTemplates(
const promptTemplates: PromptTemplate[] = [];
const diagnostics: PromptTemplateDiagnostic[] = [];
for (const path of Array.isArray(paths) ? paths : [paths]) {
const info = await safeFileInfo(env, path);
if (!info) continue;
const kind = await resolveKind(env, info);
const infoResult = await env.fileInfo(path);
if (!infoResult.ok) {
if (infoResult.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: infoResult.error.message,
path,
});
}
continue;
}
const info = infoResult.value;
const kind = await resolveKind(env, info, diagnostics);
if (kind === "directory") {
const result = await loadTemplatesFromDir(env, info.path);
promptTemplates.push(...result.promptTemplates);
@@ -83,20 +98,20 @@ async function loadTemplatesFromDir(
): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> {
const promptTemplates: PromptTemplate[] = [];
const diagnostics: PromptTemplateDiagnostic[] = [];
let entries: FileInfo[];
try {
entries = await env.listDir(dir);
} catch (error) {
const entriesResult = await env.listDir(dir);
if (!entriesResult.ok) {
diagnostics.push({
type: "warning",
message: errorMessage(error, "failed to list prompt template directory"),
code: "list_failed",
message: entriesResult.error.message,
path: dir,
});
return { promptTemplates, diagnostics };
}
const entries = entriesResult.value;
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const kind = await resolveKind(env, entry);
const kind = await resolveKind(env, entry, diagnostics);
if (kind !== "file" || !entry.name.endsWith(".md")) continue;
const result = await loadTemplateFromFile(env, entry.path);
if (result.promptTemplate) promptTemplates.push(result.promptTemplate);
@@ -110,60 +125,92 @@ async function loadTemplateFromFile(
filePath: string,
): Promise<{ promptTemplate: PromptTemplate | null; diagnostics: PromptTemplateDiagnostic[] }> {
const diagnostics: PromptTemplateDiagnostic[] = [];
try {
const rawContent = await env.readTextFile(filePath);
const { frontmatter, body } = parseFrontmatter<PromptTemplateFrontmatter>(rawContent);
const firstLine = body.split("\n").find((line) => line.trim());
let description = typeof frontmatter.description === "string" ? frontmatter.description : "";
if (!description && firstLine) {
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
return {
promptTemplate: {
name: basenameEnvPath(filePath).replace(/\.md$/i, ""),
description,
content: body,
},
diagnostics,
};
} catch (error) {
const rawContent = await env.readTextFile(filePath);
if (!rawContent.ok) {
diagnostics.push({
type: "warning",
message: errorMessage(error, "failed to load prompt template"),
code: "read_failed",
message: rawContent.error.message,
path: filePath,
});
return { promptTemplate: null, diagnostics };
}
}
async function safeFileInfo(env: ExecutionEnv, path: string): Promise<FileInfo | undefined> {
try {
return await env.fileInfo(path);
} catch {
return undefined;
const parsed = parseFrontmatter<PromptTemplateFrontmatter>(rawContent.value);
if (!parsed.ok) {
diagnostics.push({
type: "warning",
code: "parse_failed",
message: parsed.error.message,
path: filePath,
});
return { promptTemplate: null, diagnostics };
}
const { frontmatter, body } = parsed.value;
const firstLine = body.split("\n").find((line) => line.trim());
let description = typeof frontmatter.description === "string" ? frontmatter.description : "";
if (!description && firstLine) {
description = firstLine.slice(0, 60);
if (firstLine.length > 60) description += "...";
}
return {
promptTemplate: {
name: basenameEnvPath(filePath).replace(/\.md$/i, ""),
description,
content: body,
},
diagnostics,
};
}
async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> {
async function resolveKind(
env: ExecutionEnv,
info: FileInfo,
diagnostics: PromptTemplateDiagnostic[],
): Promise<"file" | "directory" | undefined> {
if (info.kind === "file" || info.kind === "directory") return info.kind;
try {
const realPath = await env.realPath(info.path);
const target = await env.fileInfo(realPath);
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
} catch {
const canonicalPath = await env.canonicalPath(info.path);
if (!canonicalPath.ok) {
if (canonicalPath.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: canonicalPath.error.message,
path: info.path,
});
}
return undefined;
}
const target = await env.fileInfo(canonicalPath.value);
if (!target.ok) {
if (target.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: target.error.message,
path: info.path,
});
}
return undefined;
}
return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined;
}
function parseFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized };
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { frontmatter: {} as T, body: normalized };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { frontmatter: (parse(yamlString) ?? {}) as T, body };
function parseFrontmatter<T extends Record<string, unknown>>(
content: string,
): Result<{ frontmatter: T; body: string }, Error> {
try {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } };
} catch (error) {
return { ok: false, error: toError(error) };
}
}
function basenameEnvPath(path: string): string {
@@ -172,10 +219,6 @@ function basenameEnvPath(path: string): string {
return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1);
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
/** Parse an argument string using simple shell-style single and double quotes. */
export function parseCommandArgs(argsString: string): string[] {
const args: string[] = [];

View File

@@ -0,0 +1,177 @@
import type {
FileSystem,
JsonlSessionCreateOptions,
JsonlSessionListOptions,
JsonlSessionMetadata,
JsonlSessionRepoApi,
Session,
} from "../types.ts";
import { SessionError, toError } from "../types.ts";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.ts";
import {
createSessionId,
createTimestamp,
getEntriesToFork,
getFileSystemResultOrThrow,
toSession,
} from "./repo-utils.ts";
type JsonlSessionRepoFileSystem = Pick<
FileSystem,
| "cwd"
| "absolutePath"
| "joinPath"
| "readTextFile"
| "readTextLines"
| "writeFile"
| "appendFile"
| "listDir"
| "exists"
| "createDir"
| "remove"
>;
function encodeCwd(cwd: string): string {
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
export class JsonlSessionRepo implements JsonlSessionRepoApi {
private readonly fs: JsonlSessionRepoFileSystem;
private readonly sessionsRootInput: string;
private sessionsRoot: string | undefined;
constructor(options: { fs: JsonlSessionRepoFileSystem; sessionsRoot: string }) {
this.fs = options.fs;
this.sessionsRootInput = options.sessionsRoot;
}
private async getSessionsRoot(): Promise<string> {
if (!this.sessionsRoot) {
this.sessionsRoot = getFileSystemResultOrThrow(
await this.fs.absolutePath(this.sessionsRootInput),
`Failed to resolve sessions root ${this.sessionsRootInput}`,
);
}
return this.sessionsRoot;
}
private async getSessionDir(cwd: string): Promise<string> {
return getFileSystemResultOrThrow(
await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]),
`Failed to resolve session directory for ${cwd}`,
);
}
private async createSessionFilePath(cwd: string, sessionId: string, timestamp: string): Promise<string> {
return getFileSystemResultOrThrow(
await this.fs.joinPath([
await this.getSessionDir(cwd),
`${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`,
]),
`Failed to resolve session file path for ${sessionId}`,
);
}
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const sessionDir = await this.getSessionDir(options.cwd);
getFileSystemResultOrThrow(
await this.fs.createDir(sessionDir, { recursive: true }),
`Failed to create session directory ${sessionDir}`,
);
const filePath = await this.createSessionFilePath(options.cwd, id, createdAt);
const storage = await JsonlSessionStorage.create(this.fs, filePath, {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath,
});
return toSession(storage);
}
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
if (
!getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)
) {
throw new SessionError("not_found", `Session not found: ${metadata.path}`);
}
const storage = await JsonlSessionStorage.open(this.fs, metadata.path);
return toSession(storage);
}
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs();
const sessions: JsonlSessionMetadata[] = [];
for (const dir of dirs) {
if (!getFileSystemResultOrThrow(await this.fs.exists(dir), `Failed to check session directory ${dir}`)) {
continue;
}
const files = getFileSystemResultOrThrow(
await this.fs.listDir(dir),
`Failed to list sessions in ${dir}`,
).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl"));
for (const file of files) {
try {
sessions.push(await loadJsonlSessionMetadata(this.fs, file.path));
} catch (error) {
const cause = toError(error);
if (!(cause instanceof SessionError) || cause.code !== "invalid_session") throw cause;
}
}
}
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return sessions;
}
async delete(metadata: JsonlSessionMetadata): Promise<void> {
getFileSystemResultOrThrow(
await this.fs.remove(metadata.path, { force: true }),
`Failed to delete session ${metadata.path}`,
);
}
async fork(
sourceMetadata: JsonlSessionMetadata,
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
): Promise<Session<JsonlSessionMetadata>> {
const source = await this.open(sourceMetadata);
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const sessionDir = await this.getSessionDir(options.cwd);
getFileSystemResultOrThrow(
await this.fs.createDir(sessionDir, { recursive: true }),
`Failed to create session directory ${sessionDir}`,
);
const storage = await JsonlSessionStorage.create(
this.fs,
await this.createSessionFilePath(options.cwd, id, createdAt),
{
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath ?? sourceMetadata.path,
},
);
for (const entry of forkedEntries) {
await storage.appendEntry(entry);
}
return toSession(storage);
}
private async listSessionDirs(): Promise<string[]> {
const sessionsRoot = await this.getSessionsRoot();
if (
!getFileSystemResultOrThrow(
await this.fs.exists(sessionsRoot),
`Failed to check sessions root ${sessionsRoot}`,
)
) {
return [];
}
const entries = getFileSystemResultOrThrow(
await this.fs.listDir(sessionsRoot),
`Failed to list sessions root ${sessionsRoot}`,
);
return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path);
}
}

View File

@@ -0,0 +1,293 @@
import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types.ts";
import { SessionError, toError } from "../types.ts";
import { getFileSystemResultOrThrow } from "./repo-utils.ts";
import { uuidv7 } from "./uuid.ts";
type JsonlSessionStorageFileSystem = Pick<FileSystem, "readTextFile" | "readTextLines" | "writeFile" | "appendFile">;
interface SessionHeader {
type: "session";
version: 3;
id: string;
timestamp: string;
cwd: string;
parentSession?: string;
}
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
if (entry.type !== "label") return;
const label = entry.label?.trim();
if (label) {
labelsById.set(entry.targetId, label);
} else {
labelsById.delete(entry.targetId);
}
}
function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
const labelsById = new Map<string, string>();
for (const entry of entries) {
updateLabelCache(labelsById, entry);
}
return labelsById;
}
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = uuidv7().slice(0, 8);
if (!byId.has(id)) return id;
}
return uuidv7();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function invalidSession(filePath: string, message: string, cause?: Error): SessionError {
return new SessionError("invalid_session", `Invalid JSONL session file ${filePath}: ${message}`, cause);
}
function invalidEntry(filePath: string, lineNumber: number, message: string, cause?: Error): SessionError {
return new SessionError(
"invalid_entry",
`Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`,
cause,
);
}
function parseHeaderLine(line: string, filePath: string): SessionHeader {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
throw invalidSession(filePath, "first line is not a valid session header", toError(error));
}
if (!isRecord(parsed)) throw invalidSession(filePath, "first line is not a valid session header");
if (parsed.type !== "session") throw invalidSession(filePath, "first line is not a valid session header");
if (parsed.version !== 3) throw invalidSession(filePath, "unsupported session version");
if (typeof parsed.id !== "string" || !parsed.id) throw invalidSession(filePath, "session header is missing id");
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
throw invalidSession(filePath, "session header is missing timestamp");
}
if (typeof parsed.cwd !== "string" || !parsed.cwd) throw invalidSession(filePath, "session header is missing cwd");
if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") {
throw invalidSession(filePath, "session header parentSession must be a string");
}
return {
type: "session",
version: 3,
id: parsed.id,
timestamp: parsed.timestamp,
cwd: parsed.cwd,
parentSession: parsed.parentSession,
};
}
function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error));
}
if (!isRecord(parsed)) throw invalidEntry(filePath, lineNumber, "is not a valid session entry");
if (typeof parsed.type !== "string") throw invalidEntry(filePath, lineNumber, "is missing entry type");
if (typeof parsed.id !== "string" || !parsed.id) throw invalidEntry(filePath, lineNumber, "is missing entry id");
if (parsed.parentId !== null && typeof parsed.parentId !== "string") {
throw invalidEntry(filePath, lineNumber, "has invalid parentId");
}
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
throw invalidEntry(filePath, lineNumber, "is missing timestamp");
}
if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") {
throw invalidEntry(filePath, lineNumber, "has invalid targetId");
}
return parsed as unknown as SessionTreeEntry;
}
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
return {
id: header.id,
createdAt: header.timestamp,
cwd: header.cwd,
path,
parentSessionPath: header.parentSession,
};
}
export async function loadJsonlSessionMetadata(
fs: JsonlSessionStorageFileSystem,
filePath: string,
): Promise<JsonlSessionMetadata> {
const lines = getFileSystemResultOrThrow(
await fs.readTextLines(filePath, { maxLines: 1 }),
`Failed to read session header ${filePath}`,
);
const line = lines[0];
if (line?.trim()) return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath);
throw invalidSession(filePath, "missing session header");
}
async function loadJsonlStorage(
fs: JsonlSessionStorageFileSystem,
filePath: string,
): Promise<{
header: SessionHeader;
entries: SessionTreeEntry[];
leafId: string | null;
}> {
const content = getFileSystemResultOrThrow(await fs.readTextFile(filePath), `Failed to read session ${filePath}`);
const lines = content.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
throw invalidSession(filePath, "missing session header");
}
const header = parseHeaderLine(lines[0]!, filePath);
const entries: SessionTreeEntry[] = [];
let leafId: string | null = null;
for (let i = 1; i < lines.length; i++) {
const entry = parseEntryLine(lines[i]!, filePath, i + 1);
entries.push(entry);
leafId = leafIdAfterEntry(entry);
}
return { header, entries, leafId };
}
export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata> {
private readonly fs: JsonlSessionStorageFileSystem;
private readonly filePath: string;
private readonly metadata: JsonlSessionMetadata;
private entries: SessionTreeEntry[];
private byId: Map<string, SessionTreeEntry>;
private labelsById: Map<string, string>;
private currentLeafId: string | null;
private constructor(
fs: JsonlSessionStorageFileSystem,
filePath: string,
header: SessionHeader,
entries: SessionTreeEntry[],
leafId: string | null,
) {
this.fs = fs;
this.filePath = filePath;
this.metadata = headerToSessionMetadata(header, this.filePath);
this.entries = entries;
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
this.labelsById = buildLabelsById(entries);
this.currentLeafId = leafId;
}
static async open(fs: JsonlSessionStorageFileSystem, filePath: string): Promise<JsonlSessionStorage> {
const loaded = await loadJsonlStorage(fs, filePath);
return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId);
}
static async create(
fs: JsonlSessionStorageFileSystem,
filePath: string,
options: {
cwd: string;
sessionId: string;
parentSessionPath?: string;
},
): Promise<JsonlSessionStorage> {
const header: SessionHeader = {
type: "session",
version: 3,
id: options.sessionId,
timestamp: new Date().toISOString(),
cwd: options.cwd,
parentSession: options.parentSessionPath,
};
getFileSystemResultOrThrow(
await fs.writeFile(filePath, `${JSON.stringify(header)}\n`),
`Failed to create session ${filePath}`,
);
return new JsonlSessionStorage(fs, filePath, header, [], null);
}
async getMetadata(): Promise<JsonlSessionMetadata> {
return this.metadata;
}
async getLeafId(): Promise<string | null> {
if (this.currentLeafId !== null && !this.byId.has(this.currentLeafId)) {
throw new SessionError("invalid_session", `Entry ${this.currentLeafId} not found`);
}
return this.currentLeafId;
}
async setLeafId(leafId: string | null): Promise<void> {
if (leafId !== null && !this.byId.has(leafId)) {
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const entry: LeafEntry = {
type: "leaf",
id: generateEntryId(this.byId),
parentId: this.currentLeafId,
timestamp: new Date().toISOString(),
targetId: leafId,
};
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
`Failed to append session leaf ${entry.id}`,
);
this.entries.push(entry);
this.byId.set(entry.id, entry);
this.currentLeafId = leafId;
}
async createEntryId(): Promise<string> {
return generateEntryId(this.byId);
}
async appendEntry(entry: SessionTreeEntry): Promise<void> {
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
`Failed to append session entry ${entry.id}`,
);
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
this.currentLeafId = leafIdAfterEntry(entry);
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
return this.byId.get(id);
}
async findEntries<TType extends SessionTreeEntry["type"]>(
type: TType,
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
return this.entries.filter((entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type);
}
async getLabel(id: string): Promise<string | undefined> {
return this.labelsById.get(id);
}
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
while (current) {
path.unshift(current);
if (!current.parentId) break;
const parent = this.byId.get(current.parentId);
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
current = parent;
}
return path;
}
async getEntries(): Promise<SessionTreeEntry[]> {
return [...this.entries];
}
}

View File

@@ -1,6 +1,6 @@
import type { Session, SessionMetadata, SessionRepo } from "../../types.js";
import { InMemorySessionStorage } from "../storage/memory.js";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js";
import { type Session, SessionError, type SessionMetadata, type SessionRepo } from "../types.ts";
import { InMemorySessionStorage } from "./memory-storage.ts";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.ts";
export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?: string }, void> {
private sessions = new Map<string, Session<SessionMetadata>>();
@@ -19,7 +19,7 @@ export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?:
async open(metadata: SessionMetadata): Promise<Session<SessionMetadata>> {
const session = this.sessions.get(metadata.id);
if (!session) {
throw new Error(`Session not found: ${metadata.id}`);
throw new SessionError("not_found", `Session not found: ${metadata.id}`);
}
return session;
}
@@ -42,8 +42,7 @@ export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?:
id: options.id ?? createSessionId(),
createdAt: createTimestamp(),
};
const leafId = forkedEntries[forkedEntries.length - 1]?.id ?? null;
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries, leafId });
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries });
const session = toSession(storage);
this.sessions.set(metadata.id, session);
return session;

View File

@@ -1,6 +1,11 @@
import { randomUUID } from "node:crypto";
import { v7 as uuidv7 } from "uuid";
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
import {
type LeafEntry,
SessionError,
type SessionMetadata,
type SessionStorage,
type SessionTreeEntry,
} from "../types.ts";
import { uuidv7 } from "./uuid.ts";
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
if (entry.type !== "label") return;
@@ -22,42 +27,61 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = randomUUID().slice(0, 8);
const id = uuidv7().slice(0, 8);
if (!byId.has(id)) return id;
}
return randomUUID();
return uuidv7();
}
export class InMemorySessionStorage implements SessionStorage {
private readonly metadata: SessionMetadata;
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf" ? entry.targetId : entry.id;
}
export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionMetadata>
implements SessionStorage<TMetadata>
{
private readonly metadata: TMetadata;
private entries: SessionTreeEntry[];
private byId: Map<string, SessionTreeEntry>;
private labelsById: Map<string, string>;
private leafId: string | null;
constructor(options?: { entries?: SessionTreeEntry[]; leafId?: string | null; metadata?: SessionMetadata }) {
constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) {
this.entries = options?.entries ? [...options.entries] : [];
this.byId = new Map(this.entries.map((entry) => [entry.id, entry]));
this.labelsById = buildLabelsById(this.entries);
this.leafId = options?.leafId ?? this.entries[this.entries.length - 1]?.id ?? null;
this.leafId = null;
for (const entry of this.entries) this.leafId = leafIdAfterEntry(entry);
if (this.leafId !== null && !this.byId.has(this.leafId)) {
throw new Error(`Entry ${this.leafId} not found`);
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
}
this.metadata = options?.metadata ?? { id: uuidv7(), createdAt: new Date().toISOString() };
this.metadata = options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata);
}
async getMetadata(): Promise<SessionMetadata> {
async getMetadata(): Promise<TMetadata> {
return this.metadata;
}
async getLeafId(): Promise<string | null> {
if (this.leafId !== null && !this.byId.has(this.leafId)) {
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
}
return this.leafId;
}
async setLeafId(leafId: string | null): Promise<void> {
if (leafId !== null && !this.byId.has(leafId)) {
throw new Error(`Entry ${leafId} not found`);
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const entry: LeafEntry = {
type: "leaf",
id: generateEntryId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
targetId: leafId,
};
this.entries.push(entry);
this.byId.set(entry.id, entry);
this.leafId = leafId;
}
@@ -69,7 +93,7 @@ export class InMemorySessionStorage implements SessionStorage {
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
this.leafId = entry.id;
this.leafId = leafIdAfterEntry(entry);
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
@@ -90,9 +114,13 @@ export class InMemorySessionStorage implements SessionStorage {
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
while (current) {
path.unshift(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
if (!current.parentId) break;
const parent = this.byId.get(current.parentId);
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
current = parent;
}
return path;
}

View File

@@ -1,6 +1,13 @@
import { v7 as uuidv7 } from "uuid";
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
import { Session } from "../session.js";
import {
type FileError,
type Result,
SessionError,
type SessionMetadata,
type SessionStorage,
type SessionTreeEntry,
} from "../types.ts";
import { Session } from "./session.ts";
import { uuidv7 } from "./uuid.ts";
export function createSessionId(): string {
return uuidv7();
@@ -14,6 +21,14 @@ export function toSession<TMetadata extends SessionMetadata>(storage: SessionSto
return new Session(storage);
}
export function getFileSystemResultOrThrow<TValue>(result: Result<TValue, FileError>, message: string): TValue {
if (!result.ok) {
const code = result.error.code === "not_found" ? "not_found" : "storage";
throw new SessionError(code, `${message}: ${result.error.message}`, result.error);
}
return result.value;
}
export async function getEntriesToFork(
storage: SessionStorage,
options: { entryId?: string; position?: "before" | "at" },
@@ -21,14 +36,14 @@ export async function getEntriesToFork(
if (!options.entryId) return storage.getEntries();
const target = await storage.getEntry(options.entryId);
if (!target) {
throw new Error(`Entry ${options.entryId} not found`);
throw new SessionError("invalid_fork_target", `Entry ${options.entryId} not found`);
}
let effectiveLeafId: string | null;
if ((options.position ?? "before") === "at") {
effectiveLeafId = target.id;
} else {
if (target.type !== "message" || target.message.role !== "user") {
throw new Error(`Entry ${options.entryId} is not a user message`);
throw new SessionError("invalid_fork_target", `Entry ${options.entryId} is not a user message`);
}
effectiveLeafId = target.parentId;
}

View File

@@ -1,109 +0,0 @@
import { constants } from "node:fs";
import { access, mkdir, readdir, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import type {
JsonlSessionCreateOptions,
JsonlSessionListOptions,
JsonlSessionMetadata,
JsonlSessionRepoApi,
Session,
} from "../../types.js";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../storage/jsonl.js";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js";
async function exists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
function encodeCwd(cwd: string): string {
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
export class JsonlSessionRepo implements JsonlSessionRepoApi {
private sessionsRoot: string;
constructor(options: { sessionsRoot: string }) {
this.sessionsRoot = resolve(options.sessionsRoot);
}
private getSessionDir(cwd: string): string {
return join(this.sessionsRoot, encodeCwd(cwd));
}
private createSessionFilePath(cwd: string, sessionId: string, timestamp: string): string {
return join(this.getSessionDir(cwd), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
}
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
await mkdir(this.sessionsRoot, { recursive: true });
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const filePath = this.createSessionFilePath(options.cwd, id, createdAt);
const storage = await JsonlSessionStorage.create(filePath, {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath,
});
return toSession(storage);
}
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
if (!(await exists(metadata.path))) {
throw new Error(`Session not found: ${metadata.path}`);
}
const storage = await JsonlSessionStorage.open(metadata.path);
return toSession(storage);
}
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
const dirs = options.cwd ? [this.getSessionDir(options.cwd)] : await this.listSessionDirs();
const sessions: JsonlSessionMetadata[] = [];
for (const dir of dirs) {
if (!(await exists(dir))) continue;
const files = (await readdir(dir)).filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file));
for (const filePath of files) {
try {
sessions.push(await loadJsonlSessionMetadata(filePath));
} catch {
// Ignore invalid session files when listing a directory.
}
}
}
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return sessions;
}
async delete(metadata: JsonlSessionMetadata): Promise<void> {
await rm(metadata.path, { force: true });
}
async fork(
sourceMetadata: JsonlSessionMetadata,
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
): Promise<Session<JsonlSessionMetadata>> {
const source = await this.open(sourceMetadata);
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const storage = await JsonlSessionStorage.create(this.createSessionFilePath(options.cwd, id, createdAt), {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath ?? sourceMetadata.path,
});
for (const entry of forkedEntries) {
await storage.appendEntry(entry);
}
return toSession(storage);
}
private async listSessionDirs(): Promise<string[]> {
if (!(await exists(this.sessionsRoot))) return [];
const entries = await readdir(this.sessionsRoot, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => join(this.sessionsRoot, entry.name));
}
}

View File

@@ -1,7 +1,8 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.js";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.js";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../../types.ts";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts";
import type {
ActiveToolsChangeEntry,
BranchSummaryEntry,
CompactionEntry,
CustomEntry,
@@ -15,11 +16,13 @@ import type {
SessionStorage,
SessionTreeEntry,
ThinkingLevelChangeEntry,
} from "../types.js";
} from "../types.ts";
import { SessionError } from "../types.ts";
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
let activeToolNames: string[] | null = null;
let compaction: CompactionEntry | null = null;
for (const entry of pathEntries) {
@@ -29,6 +32,8 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
model = { provider: entry.provider, modelId: entry.modelId };
} else if (entry.type === "message" && entry.message.role === "assistant") {
model = { provider: entry.message.provider, modelId: entry.message.model };
} else if (entry.type === "active_tools_change") {
activeToolNames = [...entry.activeToolNames];
} else if (entry.type === "compaction") {
compaction = entry;
}
@@ -71,7 +76,7 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
}
}
return { messages, thinkingLevel, model };
return { messages, thinkingLevel, model, activeToolNames };
}
export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
@@ -155,6 +160,16 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
} satisfies ModelChangeEntry);
}
async appendActiveToolsChange(activeToolNames: string[]): Promise<string> {
return this.appendTypedEntry({
type: "active_tools_change",
id: await this.storage.createEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
activeToolNames: [...activeToolNames],
} satisfies ActiveToolsChangeEntry);
}
async appendCompaction<T = unknown>(
summary: string,
firstKeptEntryId: string,
@@ -206,7 +221,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
async appendLabel(targetId: string, label: string | undefined): Promise<string> {
if (!(await this.storage.getEntry(targetId))) {
throw new Error(`Entry ${targetId} not found`);
throw new SessionError("not_found", `Entry ${targetId} not found`);
}
return this.appendTypedEntry({
type: "label",
@@ -233,7 +248,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
summary?: { summary: string; details?: unknown; fromHook?: boolean },
): Promise<string | undefined> {
if (entryId !== null && !(await this.storage.getEntry(entryId))) {
throw new Error(`Entry ${entryId} not found`);
throw new SessionError("not_found", `Entry ${entryId} not found`);
}
await this.storage.setLeafId(entryId);
if (!summary) return undefined;

View File

@@ -1,205 +0,0 @@
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { createInterface } from "node:readline";
import type { JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
interface SessionHeader {
type: "session";
version: 3;
id: string;
timestamp: string;
cwd: string;
parentSession?: string;
}
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
if (entry.type !== "label") return;
const label = entry.label?.trim();
if (label) {
labelsById.set(entry.targetId, label);
} else {
labelsById.delete(entry.targetId);
}
}
function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
const labelsById = new Map<string, string>();
for (const entry of entries) {
updateLabelCache(labelsById, entry);
}
return labelsById;
}
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = randomUUID().slice(0, 8);
if (!byId.has(id)) return id;
}
return randomUUID();
}
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
return {
id: header.id,
createdAt: header.timestamp,
cwd: header.cwd,
path,
parentSessionPath: header.parentSession,
};
}
export async function loadJsonlSessionMetadata(filePath: string): Promise<JsonlSessionMetadata> {
const stream = createReadStream(filePath, { encoding: "utf8" });
const lines = createInterface({ input: stream, crlfDelay: Infinity });
try {
for await (const line of lines) {
if (!line.trim()) break;
try {
const header = JSON.parse(line) as SessionHeader;
return headerToSessionMetadata(header, resolve(filePath));
} catch {
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
}
}
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
} finally {
lines.close();
stream.destroy();
}
}
async function loadJsonlStorage(filePath: string): Promise<{
header: SessionHeader;
entries: SessionTreeEntry[];
leafId: string | null;
}> {
const content = await readFile(filePath, "utf8");
const lines = content.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
}
let header: SessionHeader;
try {
header = JSON.parse(lines[0]!) as SessionHeader;
} catch {
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
}
const entries: SessionTreeEntry[] = [];
let leafId: string | null = null;
for (const line of lines.slice(1)) {
try {
const entry = JSON.parse(line) as SessionTreeEntry;
entries.push(entry);
leafId = entry.id;
} catch {
// ignore malformed entry lines
}
}
return { header, entries, leafId };
}
export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata> {
private readonly filePath: string;
private readonly metadata: JsonlSessionMetadata;
private entries: SessionTreeEntry[];
private byId: Map<string, SessionTreeEntry>;
private labelsById: Map<string, string>;
private currentLeafId: string | null;
private constructor(filePath: string, header: SessionHeader, entries: SessionTreeEntry[], leafId: string | null) {
this.filePath = resolve(filePath);
this.metadata = headerToSessionMetadata(header, this.filePath);
this.entries = entries;
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
this.labelsById = buildLabelsById(entries);
this.currentLeafId = leafId;
}
static async open(filePath: string): Promise<JsonlSessionStorage> {
const resolvedPath = resolve(filePath);
const loaded = await loadJsonlStorage(resolvedPath);
return new JsonlSessionStorage(resolvedPath, loaded.header, loaded.entries, loaded.leafId);
}
static async create(
filePath: string,
options: {
cwd: string;
sessionId: string;
parentSessionPath?: string;
},
): Promise<JsonlSessionStorage> {
const resolvedPath = resolve(filePath);
const header: SessionHeader = {
type: "session",
version: 3,
id: options.sessionId,
timestamp: new Date().toISOString(),
cwd: options.cwd,
parentSession: options.parentSessionPath,
};
await mkdir(dirname(resolvedPath), { recursive: true });
await writeFile(resolvedPath, `${JSON.stringify(header)}\n`);
return new JsonlSessionStorage(resolvedPath, header, [], null);
}
async getMetadata(): Promise<JsonlSessionMetadata> {
return this.metadata;
}
async getLeafId(): Promise<string | null> {
return this.currentLeafId;
}
async setLeafId(leafId: string | null): Promise<void> {
if (leafId !== null && !this.byId.has(leafId)) {
throw new Error(`Entry ${leafId} not found`);
}
this.currentLeafId = leafId;
}
async createEntryId(): Promise<string> {
return generateEntryId(this.byId);
}
async appendEntry(entry: SessionTreeEntry): Promise<void> {
await appendFile(this.filePath, `${JSON.stringify(entry)}\n`);
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
this.currentLeafId = entry.id;
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
return this.byId.get(id);
}
async findEntries<TType extends SessionTreeEntry["type"]>(
type: TType,
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
return this.entries.filter((entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type);
}
async getLabel(id: string): Promise<string | undefined> {
return this.labelsById.get(id);
}
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) return [];
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
while (current) {
path.unshift(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
}
return path;
}
async getEntries(): Promise<SessionTreeEntry[]> {
return [...this.entries];
}
}

View File

@@ -0,0 +1,54 @@
let lastTimestamp = -Infinity;
let sequence = 0;
function fillRandomBytes(bytes: Uint8Array): void {
const crypto = globalThis.crypto;
if (crypto?.getRandomValues) {
crypto.getRandomValues(bytes);
return;
}
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
}
export function uuidv7(): string {
const random = new Uint8Array(16);
fillRandomBytes(random);
const timestamp = Date.now();
if (timestamp > lastTimestamp) {
sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9];
lastTimestamp = timestamp;
} else {
sequence = (sequence + 1) >>> 0;
if (sequence === 0) {
lastTimestamp++;
}
}
const bytes = new Uint8Array(16);
bytes[0] = (lastTimestamp / 0x10000000000) & 0xff;
bytes[1] = (lastTimestamp / 0x100000000) & 0xff;
bytes[2] = (lastTimestamp / 0x1000000) & 0xff;
bytes[3] = (lastTimestamp / 0x10000) & 0xff;
bytes[4] = (lastTimestamp / 0x100) & 0xff;
bytes[5] = lastTimestamp & 0xff;
bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f);
bytes[7] = (sequence >>> 20) & 0xff;
bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f);
bytes[9] = (sequence >>> 6) & 0xff;
bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03);
bytes[11] = random[11];
bytes[12] = random[12];
bytes[13] = random[13];
bytes[14] = random[14];
bytes[15] = random[15];
return formatUuid(bytes);
}
function formatUuid(bytes: Uint8Array): string {
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
}

View File

@@ -1,17 +1,31 @@
import ignore from "ignore";
import { parse } from "yaml";
import type { ExecutionEnv, Skill } from "./types.js";
import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types.ts";
const MAX_NAME_LENGTH = 64;
const MAX_DESCRIPTION_LENGTH = 1024;
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
const IGNORED_ROOT_SKILL_MARKDOWN = new Set(["README.md"]);
type IgnoreMatcher = ReturnType<typeof ignore>;
function isIgnoredRootSkillMarkdown(fileName: string): boolean {
return IGNORED_ROOT_SKILL_MARKDOWN.has(fileName) || fileName.toLowerCase() === "readme.md";
}
export type SkillDiagnosticCode =
| "file_info_failed"
| "list_failed"
| "read_failed"
| "parse_failed"
| "invalid_metadata";
/** Warning produced while loading skills. */
export interface SkillDiagnostic {
/** Diagnostic severity. Currently only warnings are emitted. */
type: "warning";
/** Stable diagnostic code. */
code: SkillDiagnosticCode;
/** Human-readable diagnostic message. */
message: string;
/** Path associated with the diagnostic. */
@@ -44,8 +58,20 @@ export async function loadSkills(
const skills: Skill[] = [];
const diagnostics: SkillDiagnostic[] = [];
for (const dir of Array.isArray(dirs) ? dirs : [dirs]) {
const rootInfo = await safeFileInfo(env, dir);
if (!rootInfo || (await resolveKind(env, rootInfo)) !== "directory") continue;
const rootInfoResult = await env.fileInfo(dir);
if (!rootInfoResult.ok) {
if (rootInfoResult.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: rootInfoResult.error.message,
path: dir,
});
}
continue;
}
const rootInfo = rootInfoResult.value;
if ((await resolveKind(env, rootInfo, diagnostics)) !== "directory") continue;
const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path);
skills.push(...result.skills);
diagnostics.push(...result.diagnostics);
@@ -89,23 +115,34 @@ async function loadSkillsFromDirInternal(
const skills: Skill[] = [];
const diagnostics: SkillDiagnostic[] = [];
if (!(await env.exists(dir))) return { skills, diagnostics };
const dirInfo = await safeFileInfo(env, dir);
if (!dirInfo || (await resolveKind(env, dirInfo)) !== "directory") return { skills, diagnostics };
await addIgnoreRules(env, ignoreMatcher, dir, rootDir);
let entries: Awaited<ReturnType<ExecutionEnv["listDir"]>>;
try {
entries = await env.listDir(dir);
} catch {
const dirInfoResult = await env.fileInfo(dir);
if (!dirInfoResult.ok) {
if (dirInfoResult.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: dirInfoResult.error.message,
path: dir,
});
}
return { skills, diagnostics };
}
const dirInfo = dirInfoResult.value;
if ((await resolveKind(env, dirInfo, diagnostics)) !== "directory") return { skills, diagnostics };
await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics);
const entriesResult = await env.listDir(dir);
if (!entriesResult.ok) {
diagnostics.push({ type: "warning", code: "list_failed", message: entriesResult.error.message, path: dir });
return { skills, diagnostics };
}
const entries = entriesResult.value;
for (const entry of entries) {
if (entry.name !== "SKILL.md") continue;
const fullPath = entry.path;
const kind = await resolveKind(env, entry);
const kind = await resolveKind(env, entry, diagnostics);
if (kind !== "file") continue;
const relPath = relativeEnvPath(rootDir, fullPath);
if (ignoreMatcher.ignores(relPath)) continue;
@@ -119,7 +156,7 @@ async function loadSkillsFromDirInternal(
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const fullPath = entry.path;
const kind = await resolveKind(env, entry);
const kind = await resolveKind(env, entry, diagnostics);
if (!kind) continue;
const relPath = relativeEnvPath(rootDir, fullPath);
@@ -133,7 +170,8 @@ async function loadSkillsFromDirInternal(
continue;
}
if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md")) continue;
if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md") || isIgnoredRootSkillMarkdown(entry.name))
continue;
const result = await loadSkillFromFile(env, fullPath);
if (result.skill) skills.push(result.skill);
diagnostics.push(...result.diagnostics);
@@ -142,22 +180,41 @@ async function loadSkillsFromDirInternal(
return { skills, diagnostics };
}
async function addIgnoreRules(env: ExecutionEnv, ig: IgnoreMatcher, dir: string, rootDir: string): Promise<void> {
async function addIgnoreRules(
env: ExecutionEnv,
ig: IgnoreMatcher,
dir: string,
rootDir: string,
diagnostics: SkillDiagnostic[],
): Promise<void> {
const relativeDir = relativeEnvPath(rootDir, dir);
const prefix = relativeDir ? `${relativeDir}/` : "";
for (const filename of IGNORE_FILE_NAMES) {
const ignorePath = joinEnvPath(dir, filename);
const info = await safeFileInfo(env, ignorePath);
if (info?.kind !== "file") continue;
try {
const content = await env.readTextFile(ignorePath);
const patterns = content
.split(/\r?\n/)
.map((line) => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line));
if (patterns.length > 0) ig.add(patterns);
} catch {}
const info = await env.fileInfo(ignorePath);
if (!info.ok) {
if (info.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: info.error.message,
path: ignorePath,
});
}
continue;
}
if (info.value.kind !== "file") continue;
const content = await env.readTextFile(ignorePath);
if (!content.ok) {
diagnostics.push({ type: "warning", code: "read_failed", message: content.error.message, path: ignorePath });
continue;
}
const patterns = content.value
.split(/\r?\n/)
.map((line) => prefixIgnorePattern(line, prefix))
.filter((line): line is string => Boolean(line));
if (patterns.length > 0) ig.add(patterns);
}
}
@@ -183,41 +240,52 @@ async function loadSkillFromFile(
env: ExecutionEnv,
filePath: string,
): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> {
if (isIgnoredRootSkillMarkdown(basenameEnvPath(filePath))) {
return { skill: null, diagnostics: [] };
}
const diagnostics: SkillDiagnostic[] = [];
try {
const rawContent = await env.readTextFile(filePath);
const { frontmatter, body } = parseFrontmatter<SkillFrontmatter>(rawContent);
const skillDir = dirnameEnvPath(filePath);
const parentDirName = basenameEnvPath(skillDir);
for (const error of validateDescription(frontmatter.description)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
const name = frontmatter.name || parentDirName;
for (const error of validateName(name, parentDirName)) {
diagnostics.push({ type: "warning", message: error, path: filePath });
}
if (!frontmatter.description || frontmatter.description.trim() === "") {
return { skill: null, diagnostics };
}
return {
skill: {
name,
description: frontmatter.description,
content: body,
filePath,
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
},
diagnostics,
};
} catch (error) {
const message = error instanceof Error ? error.message : "failed to parse skill file";
diagnostics.push({ type: "warning", message, path: filePath });
const rawContent = await env.readTextFile(filePath);
if (!rawContent.ok) {
diagnostics.push({ type: "warning", code: "read_failed", message: rawContent.error.message, path: filePath });
return { skill: null, diagnostics };
}
const parsed = parseFrontmatter<SkillFrontmatter>(rawContent.value);
if (!parsed.ok) {
diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath });
return { skill: null, diagnostics };
}
const { frontmatter, body } = parsed.value;
const skillDir = dirnameEnvPath(filePath);
const parentDirName = basenameEnvPath(skillDir);
const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
for (const error of validateDescription(description)) {
diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
}
const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined;
const name = frontmatterName || parentDirName;
for (const error of validateName(name, parentDirName)) {
diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
}
if (!description || description.trim() === "") {
return { skill: null, diagnostics };
}
return {
skill: {
name,
description,
content: body,
filePath,
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
},
diagnostics,
};
}
function validateName(name: string, parentDirName: string): string[] {
@@ -242,39 +310,53 @@ function validateDescription(description: string | undefined): string[] {
return errors;
}
function parseFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } {
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized };
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { frontmatter: {} as T, body: normalized };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { frontmatter: (parse(yamlString) ?? {}) as T, body };
}
async function safeFileInfo(
env: ExecutionEnv,
path: string,
): Promise<Awaited<ReturnType<ExecutionEnv["fileInfo"]>> | undefined> {
function parseFrontmatter<T extends Record<string, unknown>>(
content: string,
): Result<{ frontmatter: T; body: string }, Error> {
try {
return await env.fileInfo(path);
} catch {
return undefined;
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
const yamlString = normalized.slice(4, endIndex);
const body = normalized.slice(endIndex + 4).trim();
return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } };
} catch (error) {
return { ok: false, error: toError(error) };
}
}
async function resolveKind(
env: ExecutionEnv,
info: Awaited<ReturnType<ExecutionEnv["fileInfo"]>>,
info: FileInfo,
diagnostics: SkillDiagnostic[],
): Promise<"file" | "directory" | undefined> {
if (info.kind === "file" || info.kind === "directory") return info.kind;
try {
const realPath = await env.realPath(info.path);
const target = await env.fileInfo(realPath);
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
} catch {
const canonicalPath = await env.canonicalPath(info.path);
if (!canonicalPath.ok) {
if (canonicalPath.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: canonicalPath.error.message,
path: info.path,
});
}
return undefined;
}
const target = await env.fileInfo(canonicalPath.value);
if (!target.ok) {
if (target.error.code !== "not_found") {
diagnostics.push({
type: "warning",
code: "file_info_failed",
message: target.error.message,
path: info.path,
});
}
return undefined;
}
return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined;
}
function joinEnvPath(base: string, child: string): string {

View File

@@ -1,4 +1,4 @@
import type { Skill } from "./types.js";
import type { Skill } from "./types.ts";
export function formatSkillsForSystemPrompt(skills: Skill[]): string {
const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);

View File

@@ -1,7 +1,41 @@
import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
import type { QueueMode } from "../agent.js";
import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../index.js";
import type { Session } from "./session/session.js";
import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai/base";
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../types.ts";
import type { Session } from "./session/session.ts";
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
export type Result<TValue, TError> = { ok: true; value: TValue } | { ok: false; error: TError };
/** Create a successful {@link Result}. */
export function ok<TValue, TError>(value: TValue): Result<TValue, TError> {
return { ok: true, value };
}
/** Create a failed {@link Result}. */
export function err<TValue, TError>(error: TError): Result<TValue, TError> {
return { ok: false, error };
}
/** Return the success value or throw the failure error. Intended for tests and explicit adapter boundaries. */
export function getOrThrow<TValue, TError>(result: Result<TValue, TError>): TValue {
if (!result.ok) throw result.error;
return result.value;
}
/** Return the success value or `undefined`. Only object values are allowed to avoid truthiness bugs with primitives. */
export function getOrUndefined<TValue extends object, TError>(result: Result<TValue, TError>): TValue | undefined {
return result.ok ? result.value : undefined;
}
/** Normalize unknown thrown values into Error instances before using them as typed error causes. */
export function toError(error: unknown): Error {
if (error instanceof Error) return error;
if (typeof error === "string") return new Error(error);
try {
return new Error(JSON.stringify(error));
} catch {
return new Error(String(error));
}
}
/**
* Skill loaded from a `SKILL.md` file or provided by an application.
@@ -43,11 +77,39 @@ export interface AgentHarnessResources<
skills?: TSkill[];
}
/** Kind of filesystem object as addressed by an {@link ExecutionEnv}. Symlinks are not followed automatically. */
/** Curated provider request options owned by the harness and snapshotted per turn. */
export interface AgentHarnessStreamOptions {
/** Preferred transport forwarded to the stream function. */
transport?: Transport;
/** Provider request timeout in milliseconds. */
timeoutMs?: number;
/** Maximum provider retry attempts. */
maxRetries?: number;
/** Optional cap for provider-requested retry delays. */
maxRetryDelayMs?: number;
/** Additional request headers merged with auth and lifecycle headers. */
headers?: Record<string, string>;
/** Provider metadata forwarded with requests. */
metadata?: SimpleStreamOptions["metadata"];
/** Provider cache retention hint. */
cacheRetention?: SimpleStreamOptions["cacheRetention"];
}
/** Per-request stream option patch returned by provider hooks. */
export interface AgentHarnessStreamOptionsPatch
extends Omit<Partial<AgentHarnessStreamOptions>, "headers" | "metadata"> {
/** Header patch. `undefined` values delete keys; explicit `headers: undefined` clears all headers. */
headers?: Record<string, string | undefined>;
/** Metadata patch. `undefined` values delete keys; explicit `metadata: undefined` clears all metadata. */
metadata?: Record<string, unknown | undefined>;
}
/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */
export type FileKind = "file" | "directory" | "symlink";
/** Stable, backend-independent file error codes thrown by {@link ExecutionEnv} file operations. */
/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */
export type FileErrorCode =
| "aborted"
| "not_found"
| "permission_denied"
| "not_directory"
@@ -56,28 +118,121 @@ export type FileErrorCode =
| "not_supported"
| "unknown";
/** Error thrown by {@link ExecutionEnv} file operations. */
/** Error returned by {@link FileSystem} file operations. */
export class FileError extends Error {
constructor(
/** Backend-independent error code. */
public code: FileErrorCode,
message: string,
/** Absolute addressed path associated with the failure, when available. */
public path?: string,
options?: ErrorOptions,
) {
super(message, options);
/** Backend-independent error code. */
public code: FileErrorCode;
/** Absolute addressed path associated with the failure, when available. */
public path?: string;
constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "FileError";
this.code = code;
this.path = path;
}
}
/** Metadata for one filesystem object in an {@link ExecutionEnv}. */
/** Stable, backend-independent execution error codes returned by {@link ExecutionEnv.exec}. */
export type ExecutionErrorCode =
| "aborted"
| "timeout"
| "shell_unavailable"
| "spawn_error"
| "callback_error"
| "unknown";
/** Error returned by {@link ExecutionEnv.exec}. */
export class ExecutionError extends Error {
/** Backend-independent error code. */
public code: ExecutionErrorCode;
constructor(code: ExecutionErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "ExecutionError";
this.code = code;
}
}
/** Stable compaction error codes returned by compaction helpers. */
export type CompactionErrorCode = "aborted" | "summarization_failed" | "invalid_session" | "unknown";
/** Error returned by compaction helpers. */
export class CompactionError extends Error {
/** Backend-independent error code. */
public code: CompactionErrorCode;
constructor(code: CompactionErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "CompactionError";
this.code = code;
}
}
/** Stable branch-summary error codes returned by branch summarization helpers. */
export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session";
/** Error returned by branch summarization helpers. */
export class BranchSummaryError extends Error {
/** Backend-independent error code. */
public code: BranchSummaryErrorCode;
constructor(code: BranchSummaryErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "BranchSummaryError";
this.code = code;
}
}
export type SessionErrorCode =
| "not_found"
| "invalid_session"
| "invalid_entry"
| "invalid_fork_target"
| "storage"
| "unknown";
/** Error thrown by session storage, repositories, and session tree operations. */
export class SessionError extends Error {
/** Session subsystem error code. */
public code: SessionErrorCode;
constructor(code: SessionErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "SessionError";
this.code = code;
}
}
export type AgentHarnessErrorCode =
| "busy"
| "invalid_state"
| "invalid_argument"
| "session"
| "hook"
| "auth"
| "compaction"
| "branch_summary"
| "unknown";
/** Public AgentHarness failure with a stable top-level classification. */
export class AgentHarnessError extends Error {
public code: AgentHarnessErrorCode;
constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "AgentHarnessError";
this.code = code;
}
}
/** Metadata for one filesystem object in a {@link FileSystem}. */
export interface FileInfo {
/** Basename of {@link path}. */
name: string;
/** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */
path: string;
/** Object kind. Symlink targets are not followed; use {@link ExecutionEnv.resolvePath} explicitly. */
/** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */
kind: FileKind;
/** Size in bytes for the addressed filesystem object. */
size: number;
@@ -85,16 +240,16 @@ export interface FileInfo {
mtimeMs: number;
}
/** Options for {@link ExecutionEnv.exec}. */
/** Options for {@link Shell.exec}. */
export interface ExecutionEnvExecOptions {
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. */
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
cwd?: string;
/** Additional environment variables for the command. Values override the environment defaults. */
/** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */
env?: Record<string, string>;
/** Timeout in seconds. Implementations should reject when the command exceeds this duration. */
/** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */
timeout?: number;
/** Abort signal used to terminate the command. */
signal?: AbortSignal;
/** Abort signal used to terminate the command. Defaults to no abort signal. */
abortSignal?: AbortSignal;
/** Called with stdout chunks as they are produced. */
onStdout?: (chunk: string) => void;
/** Called with stderr chunks as they are produced. */
@@ -102,50 +257,80 @@ export interface ExecutionEnvExecOptions {
}
/**
* Filesystem and process execution environment used by the harness.
* Filesystem capability used by the harness.
*
* Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by this interface are absolute
* addressed paths in the environment, but are not canonicalized through symlinks unless returned by {@link resolvePath}.
* Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths
* in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}.
*
* File operations throw {@link FileError} for expected filesystem failures such as missing paths or permission errors.
* Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be
* encoded in the returned {@link Result}. Implementations must preserve this invariant.
*/
export interface ExecutionEnv {
/** Current working directory for relative paths and command execution. */
export interface FileSystem {
/** Current working directory for relative paths. */
cwd: string;
/** Execute a shell command in {@link cwd} unless `options.cwd` is provided. */
/** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */
absolutePath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Join path segments in the filesystem namespace without requiring the result to exist. */
joinPath(parts: string[], abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Read a UTF-8 text file. */
readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Read UTF-8 text lines. Implementations should stop once `maxLines` lines have been read. */
readTextLines(
path: string,
options?: { maxLines?: number; abortSignal?: AbortSignal },
): Promise<Result<string[], FileError>>;
/** Read a binary file. */
readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
/** Create or overwrite a file, creating parent directories when supported. */
writeFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
/** Create or append to a file, creating parent directories when supported. */
appendFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
/** Return metadata for the addressed path without following symlinks. */
fileInfo(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
/** List direct children of a directory without following symlinks. */
listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
/** Return the canonical path for an existing path, resolving symlinks where supported. */
canonicalPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */
exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
/** Create a directory. Defaults: `recursive: true`, no abort signal. */
createDir(
path: string,
options?: { recursive?: boolean; abortSignal?: AbortSignal },
): Promise<Result<void, FileError>>;
/** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */
remove(
path: string,
options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal },
): Promise<Result<void, FileError>>;
/** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */
createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */
createTempFile(options?: {
prefix?: string;
suffix?: string;
abortSignal?: AbortSignal;
}): Promise<Result<string, FileError>>;
/** Release filesystem resources. Must be best-effort and must not throw or reject. */
cleanup(): Promise<void>;
}
/** Shell execution capability used by the harness. */
export interface Shell {
/** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */
exec(
command: string,
options?: ExecutionEnvExecOptions,
): Promise<{ stdout: string; stderr: string; exitCode: number }>;
/** Read a UTF-8 text file. Throws {@link FileError}. */
readTextFile(path: string): Promise<string>;
/** Read a binary file. Throws {@link FileError}. */
readBinaryFile(path: string): Promise<Uint8Array>;
/** Create or overwrite a file, creating parent directories when supported. Throws {@link FileError}. */
writeFile(path: string, content: string | Uint8Array): Promise<void>;
/** Return metadata for the addressed path without following symlinks. Throws {@link FileError}. */
fileInfo(path: string): Promise<FileInfo>;
/** List direct children of a directory without following symlinks. Throws {@link FileError}. */
listDir(path: string): Promise<FileInfo[]>;
/** Return the canonical path for a path, following symlinks. Throws {@link FileError}. */
realPath(path: string): Promise<string>;
/** Return false for missing paths. Other errors, such as permission failures, may throw {@link FileError}. */
exists(path: string): Promise<boolean>;
/** Create a directory. */
createDir(path: string, options?: { recursive?: boolean }): Promise<void>;
/** Remove a file or directory. */
remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
/** Create a temporary directory and return its absolute path. */
createTempDir(prefix?: string): Promise<string>;
/** Create a temporary file and return its absolute path. */
createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string>;
/** Release resources owned by the environment. */
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>>;
/** Release shell resources. Must be best-effort and must not throw or reject. */
cleanup(): Promise<void>;
}
/** Filesystem and process execution environment used by the harness. */
export interface ExecutionEnv extends FileSystem, Shell {}
export interface SessionTreeEntryBase {
type: string;
id: string;
@@ -169,6 +354,11 @@ export interface ModelChangeEntry extends SessionTreeEntryBase {
modelId: string;
}
export interface ActiveToolsChangeEntry extends SessionTreeEntryBase {
type: "active_tools_change";
activeToolNames: string[];
}
export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
type: "compaction";
summary: string;
@@ -211,21 +401,29 @@ export interface SessionInfoEntry extends SessionTreeEntryBase {
name?: string;
}
export interface LeafEntry extends SessionTreeEntryBase {
type: "leaf";
targetId: string | null;
}
export type SessionTreeEntry =
| MessageEntry
| ThinkingLevelChangeEntry
| ModelChangeEntry
| ActiveToolsChangeEntry
| CompactionEntry
| BranchSummaryEntry
| CustomEntry
| CustomMessageEntry
| LabelEntry
| SessionInfoEntry;
| SessionInfoEntry
| LeafEntry;
export interface SessionContext {
messages: AgentMessage[];
thinkingLevel: string;
model: { provider: string; modelId: string } | null;
activeToolNames: string[] | null;
}
export interface SessionMetadata {
@@ -242,6 +440,7 @@ export interface JsonlSessionMetadata extends SessionMetadata {
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
getMetadata(): Promise<TMetadata>;
getLeafId(): Promise<string | null>;
/** Persist a leaf entry that records the active session-tree leaf. */
setLeafId(leafId: string | null): Promise<void>;
createEntryId(): Promise<string>;
appendEntry(entry: SessionTreeEntry): Promise<void>;
@@ -254,7 +453,7 @@ export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetad
getEntries(): Promise<SessionTreeEntry[]>;
}
export type { Session } from "./session/session.js";
export type { Session } from "./session/session.ts";
export interface SessionCreateOptions {
id?: string;
@@ -298,20 +497,6 @@ export type PendingSessionWrite = SessionTreeEntry extends infer TEntry
: never
: never;
export interface AgentHarnessTurnState<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
TTool extends AgentTool = AgentTool,
> {
messages: AgentMessage[];
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
systemPrompt: string;
model: Model<any>;
thinkingLevel: ThinkingLevel;
tools: TTool[];
activeTools: TTool[];
}
export interface QueueUpdateEvent {
type: "queue_update";
steer: AgentMessage[];
@@ -353,6 +538,14 @@ export interface ContextEvent {
export interface BeforeProviderRequestEvent {
type: "before_provider_request";
model: Model<any>;
sessionId: string;
streamOptions: AgentHarnessStreamOptions;
}
export interface BeforeProviderPayloadEvent {
type: "before_provider_payload";
model: Model<any>;
payload: unknown;
}
@@ -407,19 +600,28 @@ export interface SessionTreeEvent {
fromHook?: boolean;
}
export interface ModelSelectEvent {
type: "model_select";
export interface ModelUpdateEvent {
type: "model_update";
model: Model<any>;
previousModel: Model<any> | undefined;
source: "set" | "restore";
}
export interface ThinkingLevelSelectEvent {
type: "thinking_level_select";
export interface ThinkingLevelUpdateEvent {
type: "thinking_level_update";
level: ThinkingLevel;
previousLevel: ThinkingLevel;
}
export interface ToolsUpdateEvent {
type: "tools_update";
toolNames: string[];
previousToolNames: string[];
activeToolNames: string[];
previousActiveToolNames: string[];
source: "set" | "restore";
}
export interface ResourcesUpdateEvent<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
@@ -440,6 +642,7 @@ export type AgentHarnessOwnEvent<
| BeforeAgentStartEvent<TSkill, TPromptTemplate>
| ContextEvent
| BeforeProviderRequestEvent
| BeforeProviderPayloadEvent
| AfterProviderResponseEvent
| ToolCallEvent
| ToolResultEvent
@@ -447,9 +650,10 @@ export type AgentHarnessOwnEvent<
| SessionCompactEvent
| SessionBeforeTreeEvent
| SessionTreeEvent
| ModelSelectEvent
| ThinkingLevelSelectEvent
| ResourcesUpdateEvent<TSkill, TPromptTemplate>;
| ModelUpdateEvent
| ThinkingLevelUpdateEvent
| ResourcesUpdateEvent<TSkill, TPromptTemplate>
| ToolsUpdateEvent;
export type AgentHarnessEvent<TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate> =
| AgentEvent
@@ -465,6 +669,10 @@ export interface ContextResult {
}
export interface BeforeProviderRequestResult {
streamOptions?: AgentHarnessStreamOptionsPatch;
}
export interface BeforeProviderPayloadResult {
payload: unknown;
}
@@ -497,6 +705,7 @@ export type AgentHarnessEventResultMap = {
before_agent_start: BeforeAgentStartResult | undefined;
context: ContextResult | undefined;
before_provider_request: BeforeProviderRequestResult | undefined;
before_provider_payload: BeforeProviderPayloadResult | undefined;
after_provider_response: undefined;
tool_call: ToolCallResult | undefined;
tool_result: ToolResultPatch | undefined;
@@ -504,9 +713,10 @@ export type AgentHarnessEventResultMap = {
session_compact: undefined;
session_before_tree: SessionBeforeTreeResult | undefined;
session_tree: undefined;
model_select: undefined;
thinking_level_select: undefined;
model_update: undefined;
thinking_level_update: undefined;
resources_update: undefined;
tools_update: undefined;
queue_update: undefined;
save_point: undefined;
abort: undefined;
@@ -580,11 +790,9 @@ export interface GenerateBranchSummaryOptions {
}
export interface BranchSummaryResult {
summary?: string;
readFiles?: string[];
modifiedFiles?: string[];
aborted?: boolean;
error?: string;
summary: string;
readFiles: string[];
modifiedFiles: string[];
}
export interface AgentHarnessOptions<
@@ -613,6 +821,8 @@ export interface AgentHarnessOptions<
getApiKeyAndHeaders?: (
model: Model<any>,
) => Promise<{ apiKey: string; headers?: Record<string, string> } | undefined>;
/** Curated stream/provider request options. Snapshotted at turn start. */
streamOptions?: AgentHarnessStreamOptions;
model: Model<any>;
thinkingLevel?: ThinkingLevel;
activeToolNames?: string[];
@@ -620,4 +830,4 @@ export interface AgentHarnessOptions<
followUpMode?: QueueMode;
}
export type { AgentHarness } from "./agent-harness.js";
export type { AgentHarness } from "./agent-harness.ts";

View File

@@ -1,9 +1,13 @@
import { randomBytes } from "node:crypto";
import { createWriteStream, type WriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExecutionEnv, ExecutionEnvExecOptions } from "../types.js";
import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.js";
import {
type ExecutionEnv,
type ExecutionEnvExecOptions,
ExecutionError,
err,
ok,
type Result,
toError,
} from "../types.ts";
import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.ts";
export interface ShellCaptureOptions extends Omit<ExecutionEnvExecOptions, "onStdout" | "onStderr"> {
onChunk?: (chunk: string) => void;
@@ -17,6 +21,12 @@ export interface ShellCaptureResult {
fullOutputPath?: string;
}
function toExecutionError(error: unknown): ExecutionError {
if (error instanceof ExecutionError) return error;
const cause = toError(error);
return new ExecutionError("unknown", cause.message, cause);
}
export function sanitizeBinaryOutput(str: string): string {
return Array.from(str)
.filter((char) => {
@@ -34,41 +44,62 @@ export async function executeShellWithCapture(
env: ExecutionEnv,
command: string,
options?: ShellCaptureOptions,
): Promise<ShellCaptureResult> {
): Promise<Result<ShellCaptureResult, ExecutionError>> {
const outputChunks: string[] = [];
let outputBytes = 0;
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
const encoder = new TextEncoder();
let tempFilePath: string | undefined;
let tempFileStream: WriteStream | undefined;
let totalBytes = 0;
let fullOutputPath: string | undefined;
let writeChain: Promise<Result<void, ExecutionError>> = Promise.resolve(ok(undefined));
let captureError: ExecutionError | undefined;
const ensureTempFile = () => {
if (tempFilePath) return;
const id = randomBytes(8).toString("hex");
tempFilePath = join(tmpdir(), `bash-${id}.log`);
tempFileStream = createWriteStream(tempFilePath);
for (const chunk of outputChunks) {
tempFileStream.write(chunk);
}
const appendFullOutput = (text: string): void => {
if (!fullOutputPath || captureError) return;
const path = fullOutputPath;
writeChain = writeChain.then(async (previous) => {
if (!previous.ok) return previous;
const appendResult = await env.appendFile(path, text, options?.abortSignal);
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
});
};
const ensureFullOutputFile = (initialContent: string): void => {
if (fullOutputPath || captureError) return;
writeChain = writeChain.then(async (previous) => {
if (!previous.ok) return previous;
const tempFile = await env.createTempFile({
prefix: "bash-",
suffix: ".log",
abortSignal: options?.abortSignal,
});
if (!tempFile.ok) return err(toExecutionError(tempFile.error));
fullOutputPath = tempFile.value;
const appendResult = await env.appendFile(tempFile.value, initialContent, options?.abortSignal);
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
});
};
const onChunk = (chunk: string) => {
totalBytes += Buffer.byteLength(chunk, "utf-8");
const text = sanitizeBinaryOutput(chunk).replace(/\r/g, "");
if (totalBytes > DEFAULT_MAX_BYTES) {
ensureTempFile();
try {
totalBytes += encoder.encode(chunk).byteLength;
const text = sanitizeBinaryOutput(chunk).replace(/\r/g, "");
if (totalBytes > DEFAULT_MAX_BYTES && !fullOutputPath) {
ensureFullOutputFile(outputChunks.join("") + text);
} else {
appendFullOutput(text);
}
outputChunks.push(text);
outputBytes += text.length;
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
const removed = outputChunks.shift()!;
outputBytes -= removed.length;
}
options?.onChunk?.(text);
} catch (error) {
captureError = toExecutionError(error);
}
if (tempFileStream) {
tempFileStream.write(text);
}
outputChunks.push(text);
outputBytes += text.length;
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
const removed = outputChunks.shift()!;
outputBytes -= removed.length;
}
options?.onChunk?.(text);
};
try {
@@ -77,37 +108,36 @@ export async function executeShellWithCapture(
onStdout: onChunk,
onStderr: onChunk,
});
const fullOutput = outputChunks.join("");
const truncationResult = truncateTail(fullOutput);
if (truncationResult.truncated) {
ensureTempFile();
const tailOutput = outputChunks.join("");
const truncationResult = truncateTail(tailOutput);
if (truncationResult.truncated && !fullOutputPath) {
ensureFullOutputFile(tailOutput);
}
tempFileStream?.end();
const cancelled = options?.signal?.aborted ?? false;
return {
output: truncationResult.truncated ? truncationResult.content : fullOutput,
exitCode: cancelled ? undefined : result.exitCode,
const writeResult = await writeChain;
if (!writeResult.ok) return err(writeResult.error);
if (captureError) return err(captureError);
if (!result.ok) {
if (result.error.code === "aborted" || options?.abortSignal?.aborted) {
return ok({
output: truncationResult.truncated ? truncationResult.content : tailOutput,
exitCode: undefined,
cancelled: true,
truncated: truncationResult.truncated,
fullOutputPath,
});
}
return err(result.error);
}
const cancelled = options?.abortSignal?.aborted ?? false;
return ok({
output: truncationResult.truncated ? truncationResult.content : tailOutput,
exitCode: cancelled ? undefined : result.value.exitCode,
cancelled,
truncated: truncationResult.truncated,
fullOutputPath: tempFilePath,
};
} catch (err) {
if (options?.signal?.aborted) {
const fullOutput = outputChunks.join("");
const truncationResult = truncateTail(fullOutput);
if (truncationResult.truncated) {
ensureTempFile();
}
tempFileStream?.end();
return {
output: truncationResult.truncated ? truncationResult.content : fullOutput,
exitCode: undefined,
cancelled: true,
truncated: truncationResult.truncated,
fullOutputPath: tempFilePath,
};
}
tempFileStream?.end();
throw err;
fullOutputPath,
});
} catch (error) {
return err(toExecutionError(error));
}
}

View File

@@ -44,6 +44,64 @@ export interface TruncationOptions {
maxBytes?: number;
}
interface RuntimeBuffer {
byteLength(content: string, encoding: "utf8"): number;
}
const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer;
const nonAsciiPattern = /[^\x00-\x7f]/;
function utf8ByteLength(content: string): number {
if (runtimeBuffer) return runtimeBuffer.byteLength(content, "utf8");
const firstNonAscii = content.search(nonAsciiPattern);
if (firstNonAscii === -1) return content.length;
let bytes = firstNonAscii;
for (let i = firstNonAscii; i < content.length; i++) {
const code = content.charCodeAt(i);
if (code <= 0x7f) {
bytes += 1;
} else if (code <= 0x7ff) {
bytes += 2;
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) {
const next = content.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4;
i++;
} else {
bytes += 3;
}
} else {
bytes += 3;
}
}
return bytes;
}
function replaceUnpairedSurrogates(content: string): string {
let output = "";
for (let i = 0; i < content.length; i++) {
const code = content.charCodeAt(i);
if (code >= 0xd800 && code <= 0xdbff) {
if (i + 1 < content.length) {
const next = content.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
output += content[i] + content[i + 1];
i++;
continue;
}
}
output += "<22>";
} else if (code >= 0xdc00 && code <= 0xdfff) {
output += "<22>";
} else {
output += content[i];
}
}
return output;
}
/**
* Format bytes as human-readable size.
*/
@@ -68,7 +126,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const totalBytes = utf8ByteLength(content);
const lines = content.split("\n");
const totalLines = lines.length;
@@ -90,7 +148,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
}
// Check if first line alone exceeds byte limit
const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
const firstLineBytes = utf8ByteLength(lines[0]);
if (firstLineBytes > maxBytes) {
return {
content: "",
@@ -114,7 +172,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
for (let i = 0; i < lines.length && i < maxLines; i++) {
const line = lines[i];
const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > maxBytes) {
truncatedBy = "bytes";
@@ -131,7 +189,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
}
const outputContent = outputLinesArr.join("\n");
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
const finalOutputBytes = utf8ByteLength(outputContent);
return {
content: outputContent,
@@ -158,8 +216,9 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const totalBytes = utf8ByteLength(content);
const lines = content.split("\n");
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
const totalLines = lines.length;
// Check if no truncation needed
@@ -187,7 +246,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
const line = lines[i];
const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > maxBytes) {
truncatedBy = "bytes";
@@ -196,7 +255,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
if (outputLinesArr.length === 0) {
const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
outputLinesArr.unshift(truncatedLine);
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
outputBytesCount = utf8ByteLength(truncatedLine);
lastLinePartial = true;
}
break;
@@ -212,7 +271,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
}
const outputContent = outputLinesArr.join("\n");
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
const finalOutputBytes = utf8ByteLength(outputContent);
return {
content: outputContent,
@@ -234,20 +293,40 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
* Handles multi-byte UTF-8 characters correctly.
*/
function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
const buf = Buffer.from(str, "utf-8");
if (buf.length <= maxBytes) {
return str;
if (maxBytes <= 0) return "";
let outputBytes = 0;
let start = str.length;
let needsReplacement = false;
for (let i = str.length; i > 0; ) {
let characterStart = i - 1;
const code = str.charCodeAt(characterStart);
let characterBytes: number;
let unpairedSurrogate = false;
if (code >= 0xdc00 && code <= 0xdfff && characterStart > 0) {
const previous = str.charCodeAt(characterStart - 1);
if (previous >= 0xd800 && previous <= 0xdbff) {
characterStart--;
characterBytes = 4;
} else {
characterBytes = 3;
unpairedSurrogate = true;
}
} else if (code >= 0xd800 && code <= 0xdfff) {
characterBytes = 3;
unpairedSurrogate = true;
} else {
characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;
}
if (outputBytes + characterBytes > maxBytes) break;
outputBytes += characterBytes;
start = characterStart;
needsReplacement ||= unpairedSurrogate;
i = characterStart;
}
// Start from the end, skip maxBytes back
let start = buf.length - maxBytes;
// Find a valid UTF-8 boundary (start of a character)
while (start < buf.length && (buf[start] & 0xc0) === 0x80) {
start++;
}
return buf.slice(start).toString("utf-8");
const output = str.slice(start);
return needsReplacement ? replaceUnpairedSurrogates(output) : output;
}
/**

View File

@@ -1,41 +1,5 @@
// Core Agent
export * from "./agent.js";
// Loop functions
export * from "./agent-loop.js";
export * from "./harness/agent-harness.js";
export {
collectEntriesForBranchSummary,
generateBranchSummary,
prepareBranchEntries,
} from "./harness/compaction/branch-summarization.js";
export {
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
estimateTokens,
findCutPoint,
findTurnStartIndex,
generateSummary,
getLastAssistantUsage,
prepareCompaction,
serializeConversation,
shouldCompact,
} from "./harness/compaction/compaction.js";
export * from "./harness/execution-env.js";
export * from "./harness/messages.js";
export * from "./harness/prompt-templates.js";
export * from "./harness/session/repo/jsonl.js";
export * from "./harness/session/repo/memory.js";
export * from "./harness/session/repo/shared.js";
export * from "./harness/session/session.js";
export * from "./harness/skills.js";
export * from "./harness/system-prompt.js";
// Harness
export * from "./harness/types.js";
export * from "./harness/utils/shell-output.js";
export * from "./harness/utils/truncate.js";
// Proxy utilities
export * from "./proxy.js";
// Types
export * from "./types.js";
// Import the default pi-ai entrypoint so that all built-in providers register
// automatically. Unlike "@earendil-works/pi-ai/base" which does not.
import "@earendil-works/pi-ai";
export * from "./base.ts";

View File

@@ -0,0 +1,2 @@
export { NodeExecutionEnv } from "./harness/env/nodejs.ts";
export * from "./index.ts";

View File

@@ -14,7 +14,7 @@ import {
type SimpleStreamOptions,
type StopReason,
type ToolCall,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
// Create stream class matching ProxyMessageEventStream
class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {

View File

@@ -9,7 +9,7 @@ import type {
TextContent,
Tool,
ToolResultMessage,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
import type { Static, TSchema } from "typebox";
/**
@@ -35,6 +35,14 @@ export type StreamFn = (
*/
export type ToolExecutionMode = "sequential" | "parallel";
/**
* Controls how many queued user messages are injected when the agent loop reaches a queue drain point.
*
* - "all": drain and inject every queued message at that point.
* - "one-at-a-time": drain and inject only the oldest queued message, leaving the rest queued for later drain points.
*/
export type QueueMode = "all" | "one-at-a-time";
/** A single tool call content block emitted by an assistant message. */
export type AgentToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
@@ -346,7 +354,12 @@ export interface AgentToolResult<T> {
terminate?: boolean;
}
/** Callback used by tools to stream partial execution updates. */
/**
* Callback used by tools to stream partial execution updates.
*
* The callback is scoped to the current `execute()` invocation. Calls made after
* the tool promise settles are ignored.
*/
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
/** Tool definition used by the agent runtime. */

View File

@@ -8,8 +8,8 @@ import {
} from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { agentLoop, agentLoopContinue } from "../src/agent-loop.js";
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.js";
import { agentLoop, agentLoopContinue } from "../src/agent-loop.ts";
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.ts";
// Mock stream for testing - mimics MockAssistantStream
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {

View File

@@ -1,6 +1,7 @@
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { Agent } from "../src/index.js";
import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback } from "../src/index.ts";
// Mock stream that mimics AssistantMessageEventStream
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@@ -36,6 +37,28 @@ function createAssistantMessage(text: string): AssistantMessage {
};
}
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMessage {
return {
role: "assistant",
content,
api: "openai-responses",
provider: "openai",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
};
}
function createDeferred(): {
promise: Promise<void>;
resolve: () => void;
@@ -242,6 +265,147 @@ describe("Agent", () => {
expect(receivedSignal?.aborted).toBe(true);
});
it("should ignore tool updates after the tool execution settles", async () => {
const toolSchema = Type.Object({});
let delayedUpdate: AgentToolUpdateCallback<{ status: string }> | undefined;
const events: AgentEvent[] = [];
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (error: unknown) => {
unhandledRejections.push(error);
};
const tool: AgentTool<typeof toolSchema, { status: string }> = {
name: "delayed_tool",
label: "Delayed Tool",
description: "Captures progress callbacks",
parameters: toolSchema,
async execute(_toolCallId, _params, _signal, onUpdate) {
delayedUpdate = onUpdate;
onUpdate?.({
content: [{ type: "text", text: "running" }],
details: { status: "running" },
});
return {
content: [{ type: "text", text: "ok" }],
details: { status: "done" },
terminate: true,
};
},
};
const agent = new Agent({
initialState: { tools: [tool] },
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "toolUse",
message: createAssistantToolUseMessage([
{ type: "toolCall", id: "call-1", name: "delayed_tool", arguments: {} },
]),
});
});
return stream;
},
});
agent.subscribe((event) => {
events.push(event);
});
process.on("unhandledRejection", onUnhandledRejection);
try {
await agent.prompt("run tool");
const eventCountAfterPrompt = events.length;
delayedUpdate?.({
content: [{ type: "text", text: "late" }],
details: { status: "late" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(1);
expect(events).toHaveLength(eventCountAfterPrompt);
expect(unhandledRejections).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
it("should ignore a settled parallel tool update while another tool is still running", async () => {
const toolSchema = Type.Object({});
const slowStarted = createDeferred();
const settledToolEnded = createDeferred();
const releaseSlow = createDeferred();
let settledToolUpdate: AgentToolUpdateCallback<{ status: string }> | undefined;
const events: AgentEvent[] = [];
const settledTool: AgentTool<typeof toolSchema, { status: string }> = {
name: "settled_tool",
label: "Settled Tool",
description: "Captures progress callbacks",
parameters: toolSchema,
async execute(_toolCallId, _params, _signal, onUpdate) {
settledToolUpdate = onUpdate;
return {
content: [{ type: "text", text: "done" }],
details: { status: "done" },
terminate: true,
};
},
};
const slowTool: AgentTool<typeof toolSchema, { status: string }> = {
name: "slow_tool",
label: "Slow Tool",
description: "Keeps the agent run active",
parameters: toolSchema,
async execute() {
slowStarted.resolve();
await releaseSlow.promise;
return {
content: [{ type: "text", text: "done" }],
details: { status: "done" },
terminate: true,
};
},
};
const agent = new Agent({
initialState: { tools: [settledTool, slowTool] },
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "toolUse",
message: createAssistantToolUseMessage([
{ type: "toolCall", id: "call-1", name: "settled_tool", arguments: {} },
{ type: "toolCall", id: "call-2", name: "slow_tool", arguments: {} },
]),
});
});
return stream;
},
});
agent.subscribe((event) => {
events.push(event);
if (event.type === "tool_execution_end" && event.toolCallId === "call-1") {
settledToolEnded.resolve();
}
});
const promptPromise = agent.prompt("run tools");
await Promise.all([slowStarted.promise, settledToolEnded.promise]);
const eventCountBeforeLateUpdate = events.length;
settledToolUpdate?.({
content: [{ type: "text", text: "late" }],
details: { status: "late" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(events).toHaveLength(eventCountBeforeLateUpdate);
releaseSlow.resolve();
await promptPromise;
expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(0);
});
it("should update state with mutators", () => {
const agent = new Agent();

View File

@@ -11,8 +11,8 @@ import {
type UserMessage,
} from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { Agent, type AgentEvent } from "../src/index.js";
import { calculateTool } from "./utils/calculate.js";
import { Agent, type AgentEvent } from "../src/index.ts";
import { calculateTool } from "./utils/calculate.ts";
const registrations: FauxProviderRegistration[] = [];

View File

@@ -0,0 +1,206 @@
import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, type StreamOptions } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { Session } from "../../src/harness/session/session.ts";
import { calculateTool } from "../utils/calculate.ts";
const registrations: Array<{ unregister(): void }> = [];
afterEach(() => {
for (const registration of registrations.splice(0)) {
registration.unregister();
}
});
function createHarness(options: ConstructorParameters<typeof AgentHarness>[0]): AgentHarness {
return new AgentHarness(options);
}
function captureOptions(options: StreamOptions | undefined): StreamOptions {
return {
...options,
headers: options?.headers ? { ...options.headers } : undefined,
metadata: options?.metadata ? { ...options.metadata } : undefined,
};
}
describe("AgentHarness stream configuration", () => {
it("snapshots stream options and merges auth headers before provider request hooks", async () => {
let capturedOptions: StreamOptions | undefined;
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
(_context, options) => {
capturedOptions = options;
return fauxAssistantMessage("ok");
},
]);
const session = new Session(new InMemorySessionStorage({ metadata: { id: "session-1", createdAt: "now" } }));
const harness = createHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
streamOptions: {
timeoutMs: 1000,
maxRetries: 2,
maxRetryDelayMs: 3000,
headers: { "x-base": "base" },
metadata: { base: true },
cacheRetention: "none",
},
getApiKeyAndHeaders: async () => ({ apiKey: "secret", headers: { "x-auth": "auth" } }),
});
harness.on("before_provider_request", (event) => {
expect(event.sessionId).toBe("session-1");
expect(event.streamOptions.headers).toEqual({ "x-base": "base", "x-auth": "auth" });
return {
streamOptions: {
headers: { "x-hook": "hook" },
metadata: { hook: true },
},
};
});
await harness.prompt("hello");
expect(capturedOptions).toMatchObject({
apiKey: "secret",
timeoutMs: 1000,
maxRetries: 2,
maxRetryDelayMs: 3000,
sessionId: "session-1",
cacheRetention: "none",
});
expect(capturedOptions?.headers).toEqual({ "x-base": "base", "x-auth": "auth", "x-hook": "hook" });
expect(capturedOptions?.metadata).toEqual({ base: true, hook: true });
});
it("chains provider request patches and supports deletion semantics", async () => {
let capturedOptions: StreamOptions | undefined;
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
(_context, options) => {
capturedOptions = options;
return fauxAssistantMessage("ok");
},
]);
const harness = createHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
streamOptions: {
timeoutMs: 1000,
maxRetries: 2,
headers: { keep: "base", remove: "base" },
metadata: { keep: "base", remove: "base" },
},
});
harness.on("before_provider_request", (event) => {
expect(event.streamOptions.headers).toEqual({ keep: "base", remove: "base" });
return {
streamOptions: {
headers: { first: "1", remove: undefined },
metadata: { first: 1, remove: undefined },
},
};
});
harness.on("before_provider_request", (event) => {
expect(event.streamOptions.headers).toEqual({ keep: "base", first: "1" });
expect(event.streamOptions.metadata).toEqual({ keep: "base", first: 1 });
return {
streamOptions: {
timeoutMs: undefined,
headers: { second: "2" },
metadata: undefined,
},
};
});
await harness.prompt("hello");
expect(capturedOptions?.timeoutMs).toBeUndefined();
expect(capturedOptions?.maxRetries).toBe(2);
expect(capturedOptions?.headers).toEqual({ keep: "base", first: "1", second: "2" });
expect(capturedOptions?.metadata).toBeUndefined();
});
it("uses updated stream options for save-point snapshots without mutating the active request", async () => {
const capturedOptions: StreamOptions[] = [];
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
(_context, options) => {
capturedOptions.push(captureOptions(options));
return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), {
stopReason: "toolUse",
});
},
(_context, options) => {
capturedOptions.push(captureOptions(options));
return fauxAssistantMessage("done");
},
]);
const harness = createHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
tools: [calculateTool],
streamOptions: { timeoutMs: 1000, headers: { turn: "first" } },
});
harness.subscribe((event) => {
if (event.type === "tool_execution_start") {
harness.setStreamOptions({ timeoutMs: 2000, headers: { turn: "second" } });
}
});
await harness.prompt("hello");
expect(capturedOptions).toHaveLength(2);
expect(capturedOptions[0].timeoutMs).toBe(1000);
expect(capturedOptions[0].headers).toEqual({ turn: "first" });
expect(capturedOptions[1].timeoutMs).toBe(2000);
expect(capturedOptions[1].headers).toEqual({ turn: "second" });
});
it("chains provider payload hooks", async () => {
const seenPayloads: unknown[] = [];
let finalPayload: unknown;
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
async (_context, options, _state, model) => {
finalPayload = await options?.onPayload?.({ steps: ["provider"] }, model);
return fauxAssistantMessage("ok");
},
]);
const harness = createHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
harness.on("before_provider_payload", (event) => {
seenPayloads.push(event.payload);
return { payload: { steps: ["provider", "first"] } };
});
harness.on("before_provider_payload", (event) => {
seenPayloads.push(event.payload);
return { payload: { steps: ["provider", "first", "second"] } };
});
await harness.prompt("hello");
expect(seenPayloads).toEqual([{ steps: ["provider"] }, { steps: ["provider", "first"] }]);
expect(finalPayload).toEqual({ steps: ["provider", "first", "second"] });
});
});

View File

@@ -1,11 +1,13 @@
import { getModel } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.js";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
import { Session } from "../../src/harness/session/session.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import type { PromptTemplate, Skill } from "../../src/harness/types.js";
import type { AgentTool } from "../../src/types.js";
import { fauxAssistantMessage, fauxToolCall, getModel, registerFauxProvider } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.ts";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { Session } from "../../src/harness/session/session.ts";
import type { PromptTemplate, Skill } from "../../src/harness/types.ts";
import type { AgentMessage, AgentTool } from "../../src/types.ts";
import { calculateTool } from "../utils/calculate.ts";
import { getCurrentTimeTool } from "../utils/get-current-time.ts";
interface AppSkill extends Skill {
source: "project" | "user";
@@ -15,10 +17,39 @@ interface AppPromptTemplate extends PromptTemplate {
source: "project" | "user";
}
interface AppTool extends AgentTool {
source: "builtin" | "extension";
const registrations: Array<{ unregister(): void }> = [];
function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] {
return messages.flatMap((message) => {
if (message.role !== "user") return [];
if (typeof message.content === "string") return [message.content];
if (!Array.isArray(message.content)) return [];
return message.content.flatMap((part) => {
if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return [];
return "text" in part && typeof part.text === "string" ? [part.text] : [];
});
});
}
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve = () => {};
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function getReasoning(options: unknown): unknown {
if (!options || typeof options !== "object" || !("reasoning" in options)) return undefined;
return options.reasoning;
}
afterEach(() => {
for (const registration of registrations.splice(0)) {
registration.unregister();
}
});
describe("AgentHarness", () => {
it("constructs directly and exposes queue modes", () => {
const session = new Session(new InMemorySessionStorage());
@@ -28,25 +59,506 @@ describe("AgentHarness", () => {
env,
session,
model: initialModel,
thinkingLevel: "high",
systemPrompt: "You are helpful.",
steeringMode: "all",
followUpMode: "all",
});
expect(harness.env).toBe(env);
expect(harness.agent.state.model).toBe(initialModel);
expect(harness.steeringMode).toBe("all");
expect(harness.followUpMode).toBe("all");
harness.steeringMode = "one-at-a-time";
harness.followUpMode = "one-at-a-time";
expect(harness.agent.steeringMode).toBe("one-at-a-time");
expect(harness.agent.followUpMode).toBe("one-at-a-time");
expect(harness.getModel()).toBe(initialModel);
expect(harness.getThinkingLevel()).toBe("high");
expect(harness.getSteeringMode()).toBe("all");
expect(harness.getFollowUpMode()).toBe("all");
harness.setSteeringMode("one-at-a-time");
harness.setFollowUpMode("one-at-a-time");
expect(harness.getSteeringMode()).toBe("one-at-a-time");
expect(harness.getFollowUpMode()).toBe("one-at-a-time");
});
it("drains one queued steering message at a time and emits queue updates", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
const userCounts: number[] = [];
registration.setResponses([
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("first");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("second");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("third");
},
]);
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
steeringMode: "one-at-a-time",
});
const steerQueueLengths: number[] = [];
let queued = false;
harness.subscribe((event) => {
if (event.type === "queue_update") {
steerQueueLengths.push(event.steer.length);
}
if (event.type === "message_start" && event.message.role === "assistant" && !queued) {
queued = true;
harness.steer("one");
harness.steer("two");
}
});
await harness.prompt("hello");
expect(userCounts).toEqual([1, 2, 3]);
expect(steerQueueLengths).toEqual([1, 2, 1, 0]);
});
it("appends before_agent_start messages and persists them", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
let requestText: string[] = [];
registration.setResponses([
(context) => {
requestText = textFromUserMessages(context.messages);
return fauxAssistantMessage("ok");
},
]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
harness.on("before_agent_start", () => ({
messages: [{ role: "user", content: [{ type: "text", text: "hook" }], timestamp: Date.now() }],
}));
await harness.prompt("hello");
const persistedText = (await session.getEntries()).flatMap((entry) => {
if (entry.type !== "message" || entry.message.role !== "user") return [];
const content = entry.message.content;
if (typeof content === "string") return [content];
return content.flatMap((part) => (part.type === "text" ? [part.text] : []));
});
expect(requestText).toEqual(["hello", "hook"]);
expect(persistedText).toEqual(["hello", "hook"]);
});
it("abort clears steer and follow-up queues but preserves next-turn messages", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
let releaseFirstResponse: (() => void) | undefined;
let abortedSignal: AbortSignal | undefined;
const firstResponseReleased = new Promise<void>((resolve) => {
releaseFirstResponse = resolve;
});
const secondRequestText: string[] = [];
registration.setResponses([
async (_context, options) => {
abortedSignal = options?.signal;
await firstResponseReleased;
return fauxAssistantMessage("aborted-ish");
},
(context) => {
secondRequestText.push(...textFromUserMessages(context.messages));
return fauxAssistantMessage("second");
},
]);
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
const queueUpdates: Array<{ steer: number; followUp: number; nextTurn: number }> = [];
harness.subscribe((event) => {
if (event.type === "queue_update") {
queueUpdates.push({
steer: event.steer.length,
followUp: event.followUp.length,
nextTurn: event.nextTurn.length,
});
}
});
const firstPrompt = harness.prompt("first");
await new Promise((resolve) => setTimeout(resolve, 0));
harness.steer("steer");
harness.followUp("follow");
harness.nextTurn("next");
const abortResultPromise = harness.abort();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(abortedSignal?.aborted).toBe(true);
releaseFirstResponse?.();
const abortResult = await abortResultPromise;
await firstPrompt;
await harness.prompt("second");
expect(abortResult.clearedSteer).toHaveLength(1);
expect(abortResult.clearedFollowUp).toHaveLength(1);
expect(queueUpdates).toContainEqual({ steer: 0, followUp: 0, nextTurn: 1 });
expect(secondRequestText).toEqual(["first", "next", "second"]);
});
it("drains follow-up messages one at a time after the agent would otherwise stop", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
const userCounts: number[] = [];
registration.setResponses([
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("first");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("second");
},
(context) => {
userCounts.push(context.messages.filter((message) => message.role === "user").length);
return fauxAssistantMessage("third");
},
]);
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
followUpMode: "one-at-a-time",
});
const followUpQueueLengths: number[] = [];
let queued = false;
harness.subscribe((event) => {
if (event.type === "queue_update") {
followUpQueueLengths.push(event.followUp.length);
}
if (event.type === "message_start" && event.message.role === "assistant" && !queued) {
queued = true;
harness.followUp("one");
harness.followUp("two");
}
});
await harness.prompt("hello");
expect(userCounts).toEqual([1, 2, 3]);
expect(followUpQueueLengths).toEqual([1, 2, 1, 0]);
});
it("settles thrown hook failures with persisted assistant error messages", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([() => fauxAssistantMessage("should not be used")]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
const events: string[] = [];
harness.subscribe((event) => {
events.push(event.type);
});
harness.on("context", () => {
throw new Error("context exploded");
});
const response = await harness.prompt("hello");
await expect(harness.prompt("after failure")).resolves.toMatchObject({ role: "assistant" });
const entries = await session.getEntries();
const messages = entries.flatMap((entry) => (entry.type === "message" ? [entry.message] : []));
expect(response.stopReason).toBe("error");
expect(response.errorMessage).toBe("context exploded");
expect(messages[0]?.role).toBe("user");
expect(messages[1]).toMatchObject({ role: "assistant", stopReason: "error", errorMessage: "context exploded" });
expect(events).toContain("agent_end");
expect(events).toContain("settled");
});
it("refreshes model, thinking level, resources, system prompt, and active tools at save points", async () => {
const registration = registerFauxProvider({
models: [
{ id: "first", reasoning: true },
{ id: "second", reasoning: true },
],
});
registrations.push(registration);
const secondModel = registration.getModel("second");
if (!secondModel) throw new Error("missing second faux model");
const captured: Array<{ modelId: string; reasoning: unknown; systemPrompt: string; tools: string[] }> = [];
registration.setResponses([
(context, options, _state, model) => {
captured.push({
modelId: model.id,
reasoning: getReasoning(options),
systemPrompt: context.systemPrompt ?? "",
tools: context.tools?.map((tool) => tool.name) ?? [],
});
return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), {
stopReason: "toolUse",
});
},
(context, options, _state, model) => {
captured.push({
modelId: model.id,
reasoning: getReasoning(options),
systemPrompt: context.systemPrompt ?? "",
tools: context.tools?.map((tool) => tool.name) ?? [],
});
return fauxAssistantMessage("done");
},
]);
const harness = new AgentHarness<Skill, PromptTemplate, AgentTool>({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
thinkingLevel: "off",
resources: {
skills: [{ name: "prompt", description: "prompt", content: "first prompt", filePath: "/skills/prompt" }],
},
systemPrompt: ({ resources }) => resources.skills?.[0]?.content ?? "missing prompt",
tools: [calculateTool],
});
harness.subscribe((event) => {
if (event.type === "tool_execution_start") {
void harness.setModel(secondModel);
void harness.setThinkingLevel("high");
void harness.setResources({
skills: [
{ name: "prompt", description: "prompt", content: "second prompt", filePath: "/skills/prompt" },
],
});
void harness.setTools([calculateTool, getCurrentTimeTool], [getCurrentTimeTool.name]);
}
});
await harness.prompt("hello");
expect(captured).toEqual([
{ modelId: "first", reasoning: undefined, systemPrompt: "first prompt", tools: ["calculate"] },
{ modelId: "second", reasoning: "high", systemPrompt: "second prompt", tools: ["get_current_time"] },
]);
});
it("orders pending listener session writes after agent-emitted messages", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([() => fauxAssistantMessage("ok")]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
});
let wrotePendingMessage = false;
harness.subscribe(async (event) => {
if (event.type === "message_end" && event.message.role === "assistant" && !wrotePendingMessage) {
wrotePendingMessage = true;
await harness.appendMessage({
role: "custom",
customType: "listener",
content: "listener write",
display: true,
timestamp: Date.now(),
} as AgentMessage);
}
});
await harness.prompt("hello");
const entries = await session.getEntries();
const roles = entries.flatMap((entry) => (entry.type === "message" ? [entry.message.role] : []));
expect(roles).toEqual(["user", "assistant", "custom"]);
});
it("waitForIdle waits for external run settlement and awaited listeners", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([() => fauxAssistantMessage("ok")]);
const barrier = deferred();
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session: new Session(new InMemorySessionStorage()),
model: registration.getModel(),
});
let listenerFinished = false;
harness.subscribe(async (event) => {
if (event.type === "agent_end") {
await barrier.promise;
listenerFinished = true;
}
});
const promptPromise = harness.prompt("hello");
let idleResolved = false;
const idlePromise = harness.waitForIdle().then(() => {
idleResolved = true;
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(idleResolved).toBe(false);
expect(listenerFinished).toBe(false);
barrier.resolve();
await Promise.all([promptPromise, idlePromise]);
expect(idleResolved).toBe(true);
expect(listenerFinished).toBe(true);
});
it("runs tool_call and tool_result hooks through the direct loop", async () => {
const registration = registerFauxProvider();
registrations.push(registration);
registration.setResponses([
() =>
fauxAssistantMessage(fauxToolCall("calculate", { expression: "2 + 2" }, { id: "call-1" }), {
stopReason: "toolUse",
}),
]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({
env: new NodeExecutionEnv({ cwd: process.cwd() }),
session,
model: registration.getModel(),
tools: [calculateTool],
});
const seenToolCalls: Array<{ id: string; name: string; expression: unknown }> = [];
harness.on("tool_call", (event) => {
seenToolCalls.push({ id: event.toolCallId, name: event.toolName, expression: event.input.expression });
return undefined;
});
harness.on("tool_result", (event) => {
expect(event.toolCallId).toBe("call-1");
expect(event.toolName).toBe("calculate");
return {
content: [{ type: "text", text: "patched result" }],
details: { patched: true },
terminate: true,
};
});
await harness.prompt("hello");
const toolResult = (await session.getEntries()).find(
(entry) => entry.type === "message" && entry.message.role === "toolResult",
);
expect(seenToolCalls).toEqual([{ id: "call-1", name: "calculate", expression: "2 + 2" }]);
expect(toolResult).toMatchObject({
type: "message",
message: {
role: "toolResult",
content: [{ type: "text", text: "patched result" }],
details: { patched: true },
},
});
});
it("preserves app tool types for getters and update events", async () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
type AppTool = AgentTool<typeof calculateTool.parameters, undefined> & { source: "builtin" | "extension" };
const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" };
const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" };
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AppTool>({
env,
session,
model,
tools: [inspectTool, searchTool],
activeToolNames: ["inspect"],
});
const updates: Array<{
toolNames: string[];
previousToolNames: string[];
activeToolNames: string[];
previousActiveToolNames: string[];
source: "set" | "restore";
}> = [];
harness.subscribe((event) => {
if (event.type === "tools_update") {
updates.push({
toolNames: event.toolNames,
previousToolNames: event.previousToolNames,
activeToolNames: event.activeToolNames,
previousActiveToolNames: event.previousActiveToolNames,
source: event.source,
});
expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(event.activeToolNames);
}
});
const tools = harness.getTools();
const activeTools = harness.getActiveTools();
tools.pop();
activeTools.pop();
expect(harness.getTools().map((tool) => tool.name)).toEqual(["inspect", "search"]);
expect(harness.getActiveTools().map((tool) => tool.source)).toEqual(["builtin"]);
await harness.setActiveTools(["search"]);
await harness.setTools([searchTool], ["search"]);
await expect(harness.setActiveTools(["missing"])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setActiveTools(["search", "search"])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setTools([inspectTool])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setTools([inspectTool, inspectTool], ["inspect"])).rejects.toMatchObject({
code: "invalid_argument",
});
expect(updates).toEqual([
{
toolNames: ["inspect", "search"],
previousToolNames: ["inspect", "search"],
activeToolNames: ["search"],
previousActiveToolNames: ["inspect"],
source: "set",
},
{
toolNames: ["search"],
previousToolNames: ["inspect", "search"],
activeToolNames: ["search"],
previousActiveToolNames: ["search"],
source: "set",
},
]);
expect(harness.getTools().map((tool) => tool.source)).toEqual(["extension"]);
expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(["search"]);
expect((await session.buildContext()).activeToolNames).toEqual(["search"]);
});
it("validates constructor tool names", () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
expect(
() => new AgentHarness({ env, session, model, tools: [calculateTool], activeToolNames: ["missing"] }),
).toThrow(/Unknown tool/);
expect(
() =>
new AgentHarness({
env,
session,
model,
tools: [calculateTool, calculateTool],
activeToolNames: [calculateTool.name],
}),
).toThrow(/Duplicate tool/);
expect(
() =>
new AgentHarness({
env,
session,
model,
tools: [calculateTool],
activeToolNames: [calculateTool.name, calculateTool.name],
}),
).toThrow(/Duplicate active tool/);
});
it("preserves app resource types for getters and update events", async () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AppTool>({ env, session, model });
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AgentTool>({ env, session, model });
const skill: AppSkill = {
name: "inspect",
description: "Inspect things",

View File

@@ -2,32 +2,40 @@ import {
type AssistantMessage,
type FauxProviderRegistration,
fauxAssistantMessage,
type Message,
type Model,
registerFauxProvider,
type Usage,
} from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
type CompactionPreparation,
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
estimateTokens,
findCutPoint,
findTurnStartIndex,
generateSummary,
getLastAssistantUsage,
prepareCompaction,
serializeConversation,
shouldCompact,
} from "../../src/harness/compaction/compaction.js";
import { buildSessionContext } from "../../src/harness/session/session.js";
} from "../../src/harness/compaction/compaction.ts";
import { buildSessionContext } from "../../src/harness/session/session.ts";
import type {
BranchSummaryEntry,
CompactionEntry,
CompactionSettings,
CustomMessageEntry,
MessageEntry,
ModelChangeEntry,
SessionTreeEntry,
ThinkingLevelChangeEntry,
} from "../../src/harness/types.js";
import type { AgentMessage } from "../../src/types.js";
} from "../../src/harness/types.ts";
import { getOrThrow } from "../../src/harness/types.ts";
import type { AgentMessage } from "../../src/types.ts";
let nextId = 0;
function createId(): string {
@@ -113,14 +121,17 @@ function createModelChangeEntry(provider: string, modelId: string, parentId: str
};
}
function createFauxModel(reasoning: boolean): { faux: FauxProviderRegistration; model: Model<string> } {
function createFauxModel(
reasoning: boolean,
maxTokens = 8192,
): { faux: FauxProviderRegistration; model: Model<string> } {
const faux = registerFauxProvider({
models: [
{
id: reasoning ? "reasoning-model" : "non-reasoning-model",
reasoning,
contextWindow: 200000,
maxTokens: 8192,
maxTokens,
},
],
});
@@ -175,6 +186,133 @@ describe("harness compaction", () => {
expect(entries[result.firstKeptEntryIndex]?.type).toBe("message");
});
it("covers cut-point and turn-start edge cases", () => {
const thinking = createThinkingLevelEntry("high");
const modelChange = createModelChangeEntry("openai", "gpt-4", thinking.id);
expect(findCutPoint([thinking, modelChange], 0, 2, 1)).toEqual({
firstKeptEntryIndex: 0,
turnStartIndex: -1,
isSplitTurn: false,
});
const branchSummary: BranchSummaryEntry = {
type: "branch_summary",
id: createId(),
parentId: modelChange.id,
timestamp: new Date().toISOString(),
fromId: "branch",
summary: "branch summary",
};
const customMessage: CustomMessageEntry = {
type: "custom_message",
id: createId(),
parentId: branchSummary.id,
timestamp: new Date().toISOString(),
customType: "note",
content: "custom content",
display: true,
};
expect(findTurnStartIndex([thinking, branchSummary], 1, 0)).toBe(1);
expect(findTurnStartIndex([thinking, customMessage], 1, 0)).toBe(1);
expect(findTurnStartIndex([thinking, modelChange], 1, 0)).toBe(-1);
const result = findCutPoint([thinking, branchSummary, customMessage], 0, 3, 1);
expect(result.firstKeptEntryIndex).toBe(0);
const toolResult = createMessageEntry({
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "tool output" }],
isError: false,
timestamp: Date.now(),
});
expect(findCutPoint([toolResult], 0, 1, 1)).toEqual({
firstKeptEntryIndex: 0,
turnStartIndex: -1,
isSplitTurn: false,
});
const user = createMessageEntry(createUserMessage("user"));
const compaction = createCompactionEntry("summary", user.id, user.id);
const assistant = createMessageEntry(createAssistantMessage("assistant"), compaction.id);
expect(findCutPoint([user, compaction, assistant], 0, 3, 1).firstKeptEntryIndex).toBe(2);
});
it("estimates tokens and context usage across supported message roles", () => {
const usage = createMockUsage(10, 5, 3, 2);
const assistant = createAssistantMessage("assistant", usage);
const assistantWithThinkingAndTool: AssistantMessage = {
...assistant,
content: [
{ type: "thinking", thinking: "thinking" },
{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "file.ts" } },
],
};
const customString: AgentMessage = {
role: "custom",
customType: "note",
content: "custom text",
display: true,
timestamp: Date.now(),
};
const toolResultWithImage: AgentMessage = {
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [
{ type: "text", text: "tool text" },
{ type: "image", mimeType: "image/png", data: "abc" },
],
isError: false,
timestamp: Date.now(),
};
const bashExecution: AgentMessage = {
role: "bashExecution",
command: "npm run check",
output: "ok",
exitCode: 0,
cancelled: false,
truncated: false,
timestamp: Date.now(),
};
const branchSummaryMessage: AgentMessage = {
role: "branchSummary",
summary: "branch",
fromId: "x",
timestamp: Date.now(),
};
const compactionSummaryMessage: AgentMessage = {
role: "compactionSummary",
summary: "compact",
tokensBefore: 123,
timestamp: Date.now(),
};
expect(estimateTokens({ role: "user", content: "plain user", timestamp: Date.now() })).toBeGreaterThan(0);
expect(estimateTokens(assistantWithThinkingAndTool)).toBeGreaterThan(0);
expect(estimateTokens(customString)).toBeGreaterThan(0);
expect(estimateTokens(toolResultWithImage)).toBeGreaterThan(1000);
expect(estimateTokens(bashExecution)).toBeGreaterThan(0);
expect(estimateTokens(branchSummaryMessage)).toBeGreaterThan(0);
expect(estimateTokens(compactionSummaryMessage)).toBeGreaterThan(0);
expect(estimateTokens({ role: "unknown", timestamp: Date.now() } as unknown as AgentMessage)).toBe(0);
expect(
getLastAssistantUsage([createMessageEntry(createUserMessage("user")), createMessageEntry(assistant)]),
).toBe(usage);
expect(
getLastAssistantUsage([
createMessageEntry({ ...assistant, stopReason: "aborted" }),
createMessageEntry({ ...assistant, stopReason: "error" }),
]),
).toBeUndefined();
expect(estimateContextTokens([createUserMessage("no usage")]).lastUsageIndex).toBeNull();
expect(estimateContextTokens([assistant, createUserMessage("tail")])).toMatchObject({
usageTokens: 20,
lastUsageIndex: 0,
});
});
it("builds session context with a compaction entry", () => {
const u1 = createMessageEntry(createUserMessage("1"));
const a1 = createMessageEntry(createAssistantMessage("a"), u1.id);
@@ -207,13 +345,78 @@ describe("harness compaction", () => {
const u3 = createMessageEntry(createUserMessage("user msg 3"), compaction1.id);
const a3 = createMessageEntry(createAssistantMessage("assistant msg 3", createMockUsage(8000, 2000)), u3.id);
const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3];
const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS);
const preparation = getOrThrow(prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS));
expect(preparation).toBeDefined();
expect(preparation?.previousSummary).toBe("First summary");
expect(preparation?.firstKeptEntryId).toBeTruthy();
expect(preparation?.tokensBefore).toBe(estimateContextTokens(buildSessionContext(pathEntries).messages).tokens);
});
it("prepares split-turn compaction with prior file-operation details", () => {
const u1 = createMessageEntry(createUserMessage("user msg 1"));
const assistantMessage: AssistantMessage = {
...createAssistantMessage("assistant msg 1"),
content: [{ type: "toolCall", id: "tool-1", name: "write", arguments: { path: "written.ts" } }],
};
const a1 = createMessageEntry(assistantMessage, u1.id);
const compaction1: CompactionEntry = {
...createCompactionEntry("First summary", u1.id, a1.id),
details: { readFiles: ["old-read.ts"], modifiedFiles: ["old-edit.ts"] },
};
const u2 = createMessageEntry(createUserMessage("large turn"), compaction1.id);
const a2 = createMessageEntry(createAssistantMessage("large assistant message"), u2.id);
const preparation = getOrThrow(
prepareCompaction([u1, a1, compaction1, u2, a2], {
enabled: true,
reserveTokens: 100,
keepRecentTokens: 1,
}),
);
expect(preparation).toMatchObject({ previousSummary: "First summary", isSplitTurn: true });
expect(preparation?.turnPrefixMessages.map((message) => message.role)).toEqual(["user"]);
expect([...preparation!.fileOps.read]).toContain("old-read.ts");
expect([...preparation!.fileOps.edited]).toContain("old-edit.ts");
expect([...preparation!.fileOps.written]).toContain("written.ts");
});
it("prepares custom and branch summary entries for summarization", () => {
const branchSummary: BranchSummaryEntry = {
type: "branch_summary",
id: createId(),
parentId: null,
timestamp: new Date().toISOString(),
fromId: "branch",
summary: "branch summary",
};
const customMessage: CustomMessageEntry = {
type: "custom_message",
id: createId(),
parentId: branchSummary.id,
timestamp: new Date().toISOString(),
customType: "note",
content: "custom content",
display: true,
};
const user = createMessageEntry(createUserMessage("keep"), customMessage.id);
const assistant = createMessageEntry(createAssistantMessage("assistant"), user.id);
const preparation = getOrThrow(
prepareCompaction([branchSummary, customMessage, user, assistant], {
enabled: true,
reserveTokens: 100,
keepRecentTokens: 1,
}),
);
expect(preparation?.messagesToSummarize.map((message) => message.role)).toEqual(["branchSummary", "custom"]);
});
it("does not prepare compaction when there is nothing valid to compact", () => {
const compaction = createCompactionEntry("already compacted", "entry-keep");
expect(getOrThrow(prepareCompaction([compaction], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined();
expect(getOrThrow(prepareCompaction([], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined();
});
it("serializes conversation with truncated tool results", () => {
const longContent = "x".repeat(5000);
const messages = convertMessages([
@@ -241,16 +444,18 @@ describe("harness compaction", () => {
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
await generateSummary(
messages,
reasoningModel,
2000,
"test-key",
undefined,
undefined,
undefined,
undefined,
"medium",
getOrThrow(
await generateSummary(
messages,
reasoningModel,
2000,
"test-key",
undefined,
undefined,
undefined,
undefined,
"medium",
),
);
expect(seenOptions[0]).toMatchObject({ reasoning: "medium", apiKey: "test-key" });
@@ -261,7 +466,9 @@ describe("harness compaction", () => {
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
await generateSummary(messages, offModel, 2000, "test-key", undefined, undefined, undefined, undefined, "off");
getOrThrow(
await generateSummary(messages, offModel, 2000, "test-key", undefined, undefined, undefined, undefined, "off"),
);
expect(seenOptions[1]).not.toHaveProperty("reasoning");
const { faux: fauxNonReasoning, model: nonReasoningModel } = createFauxModel(false);
@@ -271,20 +478,177 @@ describe("harness compaction", () => {
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
await generateSummary(
messages,
nonReasoningModel,
2000,
"test-key",
undefined,
undefined,
undefined,
undefined,
"medium",
getOrThrow(
await generateSummary(
messages,
nonReasoningModel,
2000,
"test-key",
undefined,
undefined,
undefined,
undefined,
"medium",
),
);
expect(seenOptions[2]).not.toHaveProperty("reasoning");
});
it("includes previous summaries and custom instructions in generateSummary prompts", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
let promptText = "";
const { faux, model } = createFauxModel(false);
faux.setResponses([
(context) => {
const message = context.messages[0];
const content = message?.role === "user" ? message.content : [];
promptText = Array.isArray(content) && content[0]?.type === "text" ? content[0].text : "";
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
const summary = getOrThrow(
await generateSummary(
messages,
model,
2000,
"test-key",
{ "x-test": "yes" },
undefined,
"focus",
"old summary",
),
);
expect(summary).toContain("Test summary");
expect(promptText).toContain("<previous-summary>\nold summary\n</previous-summary>");
expect(promptText).toContain("Additional focus: focus");
});
it("returns error results for failed or aborted summary generations", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const { faux: errorFaux, model: errorModel } = createFauxModel(false);
errorFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "boom" })]);
const errorResult = await generateSummary(messages, errorModel, 2000, "test-key");
expect(errorResult).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Summarization failed: boom" },
});
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "stopped" })]);
const abortedResult = await generateSummary(messages, abortedModel, 2000, "test-key");
expect(abortedResult).toMatchObject({ ok: false, error: { code: "aborted", message: "stopped" } });
});
it("clamps compaction summary maxTokens to the model output cap", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const seenOptions: Array<Record<string, unknown> | undefined> = [];
const { faux, model } = createFauxModel(false, 128000);
faux.setResponses([
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Goal\nTest summary");
},
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Goal\nTest summary");
},
]);
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: messages,
turnPrefixMessages: messages,
isSplitTurn: true,
tokensBefore: 600000,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 },
};
getOrThrow(await compact(preparation, model, "test-key"));
expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]);
});
it("returns compaction error results without throwing", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: messages,
turnPrefixMessages: [],
isSplitTurn: false,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
const { faux: historyFaux, model: historyModel } = createFauxModel(false);
historyFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "history failed" })]);
expect(await compact(preparation, historyModel, "test-key")).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Summarization failed: history failed" },
});
const { model: invalidModel } = createFauxModel(false);
const invalidResult = await compact(
{ ...preparation, messagesToSummarize: [], firstKeptEntryId: "" },
invalidModel,
"test-key",
);
expect(invalidResult).toMatchObject({ ok: false, error: { code: "invalid_session" } });
});
it("passes reasoning through turn-prefix summaries when enabled", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const seenOptions: Array<Record<string, unknown> | undefined> = [];
const { faux, model } = createFauxModel(true);
faux.setResponses([
(_context, options) => {
seenOptions.push(options as Record<string, unknown> | undefined);
return fauxAssistantMessage("## Original Request\nTest summary");
},
]);
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: [],
turnPrefixMessages: messages,
isSplitTurn: true,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
getOrThrow(await compact(preparation, model, "test-key", undefined, undefined, undefined, "high"));
expect(seenOptions[0]).toMatchObject({ reasoning: "high" });
});
it("returns turn-prefix compaction errors without throwing", async () => {
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
const preparation: CompactionPreparation = {
firstKeptEntryId: "entry-keep",
messagesToSummarize: [],
turnPrefixMessages: messages,
isSplitTurn: true,
tokensBefore: 100,
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
};
const { faux, model } = createFauxModel(false);
faux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "prefix failed" })]);
expect(await compact(preparation, model, "test-key")).toMatchObject({
ok: false,
error: { code: "summarization_failed", message: "Turn prefix summarization failed: prefix failed" },
});
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "prefix stopped" })]);
expect(await compact(preparation, abortedModel, "test-key")).toMatchObject({
ok: false,
error: { code: "aborted", message: "prefix stopped" },
});
});
it("returns a compaction result with file details", async () => {
const u1 = createMessageEntry(createUserMessage("read a file"));
const assistantMessage: AssistantMessage = {
@@ -294,17 +658,17 @@ describe("harness compaction", () => {
const a1 = createMessageEntry(assistantMessage, u1.id);
const u2 = createMessageEntry(createUserMessage("continue"), a1.id);
const a2 = createMessageEntry(createAssistantMessage("done", createMockUsage(4000, 500)), u2.id);
const preparation = prepareCompaction([u1, a1, u2, a2], DEFAULT_COMPACTION_SETTINGS);
const preparation = getOrThrow(prepareCompaction([u1, a1, u2, a2], DEFAULT_COMPACTION_SETTINGS));
expect(preparation).toBeDefined();
const { faux, model } = createFauxModel(false);
faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
const result = await compact(preparation!, model, "test-key");
const result = getOrThrow(await compact(preparation!, model, "test-key"));
expect(result.summary.length).toBeGreaterThan(0);
expect(result.firstKeptEntryId).toBeTruthy();
expect(result.details).toBeDefined();
});
});
function convertMessages(messages: any[]): any[] {
function convertMessages(messages: Message[]): Message[] {
return messages;
}

View File

@@ -1,8 +1,10 @@
import { access, chmod, realpath, symlink } from "node:fs/promises";
import { join } from "node:path";
import { delimiter, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { FileError, NodeExecutionEnv } from "../../src/harness/execution-env.js";
import { createTempDir } from "./session-test-utils.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { FileError, getOrThrow } from "../../src/harness/types.ts";
import { executeShellWithCapture } from "../../src/harness/utils/shell-output.ts";
import { createTempDir } from "./session-test-utils.ts";
const chmodRestorePaths: string[] = [];
@@ -19,61 +21,69 @@ describe("NodeExecutionEnv", () => {
it("reads, writes, lists, and removes files and directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("nested", { recursive: true });
await env.writeFile("nested/file.txt", "hello");
expect(await env.readTextFile("nested/file.txt")).toBe("hello");
expect(Buffer.from(await env.readBinaryFile("nested/file.txt")).toString("utf8")).toBe("hello");
expect(getOrThrow(await env.absolutePath("nested/child"))).toBe(join(root, "nested/child"));
expect(getOrThrow(await env.joinPath([root, "nested", "child"]))).toBe(join(root, "nested", "child"));
getOrThrow(await env.createDir("nested/child"));
getOrThrow(await env.writeFile("nested/child/file.txt", "hel"));
getOrThrow(await env.appendFile("nested/child/file.txt", "lo"));
expect(getOrThrow(await env.readTextFile("nested/child/file.txt"))).toBe("hello");
expect(getOrThrow(await env.readTextLines("nested/child/file.txt", { maxLines: 1 }))).toEqual(["hello"]);
expect(Buffer.from(getOrThrow(await env.readBinaryFile("nested/child/file.txt"))).toString("utf8")).toBe("hello");
const entries = await env.listDir("nested");
const entries = getOrThrow(await env.listDir("nested/child"));
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
name: "file.txt",
path: join(root, "nested/file.txt"),
path: join(root, "nested/child/file.txt"),
kind: "file",
size: 5,
});
expect(typeof entries[0]!.mtimeMs).toBe("number");
expect(await env.exists("nested/file.txt")).toBe(true);
await env.remove("nested/file.txt");
expect(await env.exists("nested/file.txt")).toBe(false);
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(true);
getOrThrow(await env.remove("nested/child/file.txt"));
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false);
});
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("dir", { recursive: true });
await env.writeFile("dir/file.txt", "hello");
getOrThrow(await env.createDir("dir", { recursive: true }));
getOrThrow(await env.writeFile("dir/file.txt", "hello"));
await symlink(join(root, "dir/file.txt"), join(root, "file-link"));
await symlink(join(root, "dir"), join(root, "dir-link"));
expect(await env.fileInfo("dir")).toMatchObject({ name: "dir", path: join(root, "dir"), kind: "directory" });
expect(await env.fileInfo("dir/file.txt")).toMatchObject({
expect(getOrThrow(await env.fileInfo("dir"))).toMatchObject({
name: "dir",
path: join(root, "dir"),
kind: "directory",
});
expect(getOrThrow(await env.fileInfo("dir/file.txt"))).toMatchObject({
name: "file.txt",
path: join(root, "dir/file.txt"),
kind: "file",
size: 5,
});
expect(await env.fileInfo("file-link")).toMatchObject({
expect(getOrThrow(await env.fileInfo("file-link"))).toMatchObject({
name: "file-link",
path: join(root, "file-link"),
kind: "symlink",
});
expect(await env.fileInfo("dir-link")).toMatchObject({
expect(getOrThrow(await env.fileInfo("dir-link"))).toMatchObject({
name: "dir-link",
path: join(root, "dir-link"),
kind: "symlink",
});
expect(await env.realPath("file-link")).toBe(await realpath(join(root, "dir/file.txt")));
expect(getOrThrow(await env.canonicalPath("file-link"))).toBe(await realpath(join(root, "dir/file.txt")));
});
it("lists symlinks as symlinks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.writeFile("target.txt", "hello");
getOrThrow(await env.writeFile("target.txt", "hello"));
await symlink(join(root, "target.txt"), join(root, "link.txt"));
const entries = await env.listDir(".");
const entries = getOrThrow(await env.listDir("."));
expect(
entries.map((entry) => ({ name: entry.name, kind: entry.kind })).sort((a, b) => a.name.localeCompare(b.name)),
).toEqual([
@@ -82,68 +92,230 @@ describe("NodeExecutionEnv", () => {
]);
});
it("throws FileError for missing paths and keeps exists false for missing paths", async () => {
it("stops reading text lines at the requested limit", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await expect(env.fileInfo("missing.txt")).rejects.toMatchObject({
name: "FileError",
code: "not_found",
path: join(root, "missing.txt"),
});
expect(await env.exists("missing.txt")).toBe(false);
getOrThrow(await env.writeFile("file.txt", "one\ntwo\nthree"));
expect(getOrThrow(await env.readTextLines("file.txt", { maxLines: 1 }))).toEqual(["one"]);
});
it("throws FileError for listing non-directories", async () => {
it("returns FileError for missing paths and keeps exists false for missing paths", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.writeFile("file.txt", "hello");
await expect(env.listDir("file.txt")).rejects.toBeInstanceOf(FileError);
await expect(env.listDir("file.txt")).rejects.toMatchObject({ code: "not_directory" });
const info = await env.fileInfo("missing.txt");
expect(info.ok).toBe(false);
if (!info.ok) {
expect(info.error).toBeInstanceOf(FileError);
expect(info.error).toMatchObject({
name: "FileError",
code: "not_found",
path: join(root, "missing.txt"),
});
}
expect(getOrThrow(await env.exists("missing.txt"))).toBe(false);
});
it("returns FileError for listing non-directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile("file.txt", "hello"));
const result = await env.listDir("file.txt");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(FileError);
expect(result.error).toMatchObject({ code: "not_directory" });
}
});
it("appends to new files and creates parent directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.appendFile("new/nested/file.txt", "a"));
getOrThrow(await env.appendFile("new/nested/file.txt", "b"));
expect(getOrThrow(await env.readTextFile("new/nested/file.txt"))).toBe("ab");
});
it("creates temporary directories and files", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const tempDir = await env.createTempDir("node-env-test-");
const tempDir = getOrThrow(await env.createTempDir("node-env-test-"));
await expect(access(tempDir)).resolves.toBeUndefined();
const tempFile = await env.createTempFile({ prefix: "prefix-", suffix: ".txt" });
const tempFile = getOrThrow(await env.createTempFile({ prefix: "prefix-", suffix: ".txt" }));
await expect(access(tempFile)).resolves.toBeUndefined();
expect(tempFile.endsWith(".txt")).toBe(true);
});
it("honors createDir recursive false and remove recursive/force options", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const createResult = await env.createDir("missing/child", { recursive: false });
expect(createResult.ok).toBe(false);
if (!createResult.ok) expect(createResult.error).toMatchObject({ code: "not_found" });
getOrThrow(await env.writeFile("dir/child/file.txt", "hello"));
const removeDirectory = await env.remove("dir", { recursive: false });
expect(removeDirectory.ok).toBe(false);
getOrThrow(await env.remove("dir", { recursive: true }));
expect(getOrThrow(await env.exists("dir"))).toBe(false);
const removeMissing = await env.remove("missing", { force: false });
expect(removeMissing.ok).toBe(false);
getOrThrow(await env.remove("missing", { force: true }));
});
it("returns aborted results for pre-aborted cancellable file operations", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile("file.txt", "hello"));
const controller = new AbortController();
controller.abort();
const signal = controller.signal;
const results = await Promise.all([
env.readTextFile("file.txt", signal),
env.readTextLines("file.txt", { abortSignal: signal }),
env.readBinaryFile("file.txt", signal),
env.writeFile("other.txt", "hello", signal),
env.listDir(".", signal),
]);
for (const result of results) {
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" });
}
});
it("cleanup is best-effort", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await expect(env.cleanup()).resolves.toBeUndefined();
});
it("executes commands in cwd with env overrides", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', {
env: { NODE_ENV_TEST: "ok" },
});
const result = getOrThrow(
await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', {
env: { NODE_ENV_TEST: "ok" },
}),
);
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
});
it("uses stdin command transport for legacy WSL bash paths", async () => {
if (process.platform === "win32") return;
const root = createTempDir();
const shellPath = "C:\\Windows\\System32\\bash.exe";
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile(shellPath, '#!/bin/sh\nprintf \'args:%s\\n\' "$*" >&2\nexec /bin/bash "$@"\n'));
await chmod(join(root, shellPath), 0o755);
const originalCwd = process.cwd();
const originalPath = process.env.PATH;
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
try {
process.chdir(root);
process.env.PATH = `${root}${delimiter}${originalPath ?? ""}`;
Object.defineProperty(process, "platform", {
configurable: true,
value: "win32",
});
const wslEnv = new NodeExecutionEnv({ cwd: root, shellPath });
const nameExpansion = "$" + "{name}";
const result = getOrThrow(await wslEnv.exec(`name='World'; echo "Hello, ${nameExpansion}!"`));
expect(result).toEqual({ stdout: "Hello, World!\n", stderr: "args:-s\n", exitCode: 0 });
} finally {
process.chdir(originalCwd);
process.env.PATH = originalPath;
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
});
it("streams stdout and stderr chunks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
let stdout = "";
let stderr = "";
const result = await env.exec("printf out; printf err >&2", {
onStdout: (chunk) => {
stdout += chunk;
},
onStderr: (chunk) => {
stderr += chunk;
},
});
const result = getOrThrow(
await env.exec("printf out; printf err >&2", {
onStdout: (chunk) => {
stdout += chunk;
},
onStderr: (chunk) => {
stderr += chunk;
},
}),
);
expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 });
expect(stdout).toBe("out");
expect(stderr).toBe("err");
});
it("rejects aborted commands", async () => {
it("returns non-zero command exit codes as successful execution results", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = getOrThrow(await env.exec("exit 7"));
expect(result).toEqual({ stdout: "", stderr: "", exitCode: 7 });
});
it("returns timeout errors for commands exceeding the timeout", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec("sleep 5", { timeout: 0.01 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "timeout" });
});
it("returns callback errors from exec stream handlers", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = await env.exec("printf out", {
onStdout: () => {
throw new Error("callback failed");
},
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "callback_error", message: "callback failed" });
});
it("returns shell unavailable and spawn errors", async () => {
const root = createTempDir();
const missingShellEnv = new NodeExecutionEnv({ cwd: root, shellPath: join(root, "missing-shell") });
const missingShell = await missingShellEnv.exec("printf ok");
expect(missingShell.ok).toBe(false);
if (!missingShell.ok) expect(missingShell.error).toMatchObject({ code: "shell_unavailable" });
const shellPath = join(root, "not-executable-shell");
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile(shellPath, "not executable"));
const spawnErrorEnv = new NodeExecutionEnv({ cwd: root, shellPath });
const spawnError = await spawnErrorEnv.exec("printf ok");
expect(spawnError.ok).toBe(false);
if (!spawnError.ok) expect(spawnError.error).toMatchObject({ code: "spawn_error" });
});
it("returns an aborted result for aborted commands", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const controller = new AbortController();
const promise = env.exec("sleep 5", { signal: controller.signal });
const promise = env.exec("sleep 5", { abortSignal: controller.signal });
controller.abort();
await expect(promise).rejects.toThrow("aborted");
const result = await promise;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" });
});
it("captures large shell output to a full output file through the execution env", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const result = getOrThrow(await executeShellWithCapture(env, "yes line | head -n 15000"));
expect(result.truncated).toBe(true);
expect(result.fullOutputPath).toBeDefined();
const fullOutput = getOrThrow(await env.readTextFile(result.fullOutputPath!));
expect(fullOutput.split("\n").length).toBeGreaterThan(10000);
expect(result.output.length).toBeLessThan(fullOutput.length);
});
});

View File

@@ -1,13 +1,13 @@
import { symlink } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import {
formatPromptTemplateInvocation,
loadPromptTemplates,
loadSourcedPromptTemplates,
} from "../../src/harness/prompt-templates.js";
import { createTempDir } from "./session-test-utils.js";
} from "../../src/harness/prompt-templates.ts";
import { createTempDir } from "./session-test-utils.ts";
describe("loadPromptTemplates", () => {
it("loads markdown templates non-recursively from one or more dirs", async () => {

View File

@@ -1,8 +1,9 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { JsonlSessionRepo } from "../../src/harness/session/repo/jsonl.js";
import { InMemorySessionRepo } from "../../src/harness/session/repo/memory.js";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionRepo } from "../../src/harness/session/jsonl-repo.ts";
import { InMemorySessionRepo } from "../../src/harness/session/memory-repo.ts";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
describe("InMemorySessionRepo", () => {
it("opens, deletes, and forks by metadata", async () => {
@@ -26,9 +27,10 @@ describe("InMemorySessionRepo", () => {
describe("JsonlSessionRepo", () => {
it("stores sessions below encoded cwd directories and lists by cwd", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const cwd = "/tmp/my-project";
const otherCwd = "/tmp/other-project";
const repo = new JsonlSessionRepo({ sessionsRoot: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const session = await repo.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" });
const otherSession = await repo.create({ cwd: otherCwd, id: "other-session" });
const metadata = await session.getMetadata();
@@ -44,7 +46,8 @@ describe("JsonlSessionRepo", () => {
it("opens, deletes, and forks by metadata", async () => {
const root = createTempDir();
const repo = new JsonlSessionRepo({ sessionsRoot: root });
const env = new NodeExecutionEnv({ cwd: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const source = await repo.create({ cwd: "/tmp/source", id: "source-session" });
const sourceMetadata = await source.getMetadata();
const user1 = await source.appendMessage(createUserMessage("one"));

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { formatPromptTemplateInvocation } from "../../src/harness/prompt-templates.js";
import { formatSkillInvocation } from "../../src/harness/skills.js";
import { formatPromptTemplateInvocation } from "../../src/harness/prompt-templates.ts";
import { formatSkillInvocation } from "../../src/harness/skills.ts";
describe("resource formatting helpers", () => {
it("formats skill invocations with additional instructions", () => {

View File

@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { uuidv7 } from "../../src/harness/session/uuid.ts";
const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const TIMESTAMP = 0x0123456789ab;
function parseTimestamp(uuid: string): number {
return Number.parseInt(uuid.replaceAll("-", "").slice(0, 12), 16);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("uuidv7", () => {
it("uses the RFC 9562 layout and preserves monotonic order", () => {
const randomValues = [
new Uint8Array([0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xfe, 0x01, 0x11, 0x22, 0x33, 0x44, 0x55]),
new Uint8Array(16),
new Uint8Array(16),
];
const getRandomValues = vi.fn((bytes: Uint8Array) => {
bytes.set(randomValues.shift() ?? new Uint8Array(bytes.length));
return bytes;
});
vi.stubGlobal("crypto", { getRandomValues });
const dateNow = vi.spyOn(Date, "now").mockReturnValue(TIMESTAMP);
try {
const first = uuidv7();
const second = uuidv7();
const third = uuidv7();
expect(first).toBe("01234567-89ab-7fff-bfff-f91122334455");
expect(second).toBe("01234567-89ab-7fff-bfff-fc0000000000");
expect(third).toBe("01234567-89ac-7000-8000-000000000000");
expect(first).toMatch(UUID_V7_RE);
expect(second).toMatch(UUID_V7_RE);
expect(third).toMatch(UUID_V7_RE);
expect(parseTimestamp(first)).toBe(TIMESTAMP);
expect(parseTimestamp(second)).toBe(TIMESTAMP);
expect(parseTimestamp(third)).toBe(TIMESTAMP + 1);
expect(first < second).toBe(true);
expect(second < third).toBe(true);
expect(getRandomValues).toHaveBeenCalledTimes(3);
} finally {
dateNow.mockRestore();
}
});
});

View File

@@ -1,11 +1,12 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { Session } from "../../src/harness/session/session.js";
import { JsonlSessionStorage } from "../../src/harness/session/storage/jsonl.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import type { SessionStorage } from "../../src/harness/types.js";
import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionStorage } from "../../src/harness/session/jsonl-storage.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { Session } from "../../src/harness/session/session.ts";
import type { SessionStorage } from "../../src/harness/types.ts";
import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.ts";
async function runSessionSuite(
name: string,
@@ -128,7 +129,8 @@ runSessionSuite(
"Session with JSONL storage",
async () => {
const dir = createTempDir();
return await JsonlSessionStorage.create(join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" });
const env = new NodeExecutionEnv({ cwd: dir });
return await JsonlSessionStorage.create(env, join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" });
},
() => {
const dir = getLatestTempDir();
@@ -138,10 +140,10 @@ runSessionSuite(
const header = JSON.parse(lines[0]!);
expect(header.type).toBe("session");
expect(header.version).toBe(3);
for (const line of lines.slice(1)) {
const entry = JSON.parse(line);
const entries = lines.slice(1).map((line) => JSON.parse(line));
expect(entries.some((entry) => entry.type === "leaf")).toBe(true);
for (const entry of entries) {
expect(entry.type).not.toBe("entry");
expect(entry.type).not.toBe("leaf");
expect(typeof entry.id).toBe("string");
}
},

View File

@@ -1,9 +1,9 @@
import { symlink } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.js";
import { createTempDir } from "./session-test-utils.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.ts";
import { createTempDir } from "./session-test-utils.ts";
describe("loadSkills", () => {
it("loads SKILL.md files through the execution environment", async () => {
@@ -93,6 +93,7 @@ Use this skill.
expect(diagnostics).toEqual([
{
type: "warning",
code: "invalid_metadata",
message: "description is required",
path: join(root, "user/broken/SKILL.md"),
source: { type: "user" },
@@ -112,4 +113,20 @@ Use this skill.
expect(skills.map((skill) => skill.name)).toEqual(["skills"]);
expect(skills[0]?.content).toBe("Root content");
});
it("ignores README.md at the skills directory root", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
await env.createDir("skills/example", { recursive: true });
await env.writeFile("skills/README.md", "# Skills\n\nDirectory documentation.");
await env.writeFile(
"skills/example/SKILL.md",
"---\nname: example\ndescription: Example skill\n---\nUse this skill.",
);
const { skills, diagnostics } = await loadSkills(env, "skills");
expect(diagnostics).toEqual([]);
expect(skills.map((skill) => skill.name)).toEqual(["example"]);
});
});

View File

@@ -1,10 +1,11 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/storage/jsonl.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import type { MessageEntry, SessionMetadata } from "../../src/harness/types.js";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { type MessageEntry, ok, type SessionMetadata } from "../../src/harness/types.ts";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
describe("InMemorySessionStorage", () => {
it("returns configured session metadata", async () => {
@@ -13,7 +14,7 @@ describe("InMemorySessionStorage", () => {
expect(await storage.getMetadata()).toEqual(metadata);
});
it("copies initial entries and tracks leaf independently", async () => {
it("copies initial entries and persists leaf changes", async () => {
const entry: MessageEntry = {
type: "message",
id: "entry-1",
@@ -28,12 +29,12 @@ describe("InMemorySessionStorage", () => {
expect(await storage.getLeafId()).toBe("entry-1");
await storage.setLeafId(null);
expect(await storage.getLeafId()).toBeNull();
expect((await storage.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: null });
});
it("rejects invalid leaf ids", async () => {
const storage = new InMemorySessionStorage();
await expect(storage.setLeafId("missing")).rejects.toThrow("Entry missing not found");
expect(() => new InMemorySessionStorage({ leafId: "missing" })).toThrow("Entry missing not found");
});
it("finds entries by type", async () => {
@@ -102,14 +103,16 @@ describe("InMemorySessionStorage", () => {
describe("JsonlSessionStorage", () => {
it("throws for missing files when opening", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
await expect(JsonlSessionStorage.open(filePath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "not_found" });
});
it("writes the header on create", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8").trim().split("\n")).toHaveLength(1);
expect(await storage.getLeafId()).toBeNull();
@@ -129,13 +132,15 @@ describe("JsonlSessionStorage", () => {
it("throws for malformed session headers", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
writeFileSync(filePath, "not json\n");
await expect(JsonlSessionStorage.open(filePath)).rejects.toThrow("first line is not a valid session header");
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toThrow("first line is not a valid session header");
});
it("ignores malformed entry lines", async () => {
it("throws for malformed entry lines", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const header = {
type: "session",
@@ -152,15 +157,14 @@ describe("JsonlSessionStorage", () => {
message: createUserMessage("one"),
};
writeFileSync(filePath, `${JSON.stringify(header)}\nnot json\n${JSON.stringify(entry)}\n`);
const storage = await JsonlSessionStorage.open(filePath);
expect((await storage.getEntries()).map((loadedEntry) => loadedEntry.id)).toEqual(["entry-1"]);
expect(await storage.getLeafId()).toBe("entry-1");
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "invalid_entry" });
});
it("creates and reads session metadata from the header", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, {
const storage = await JsonlSessionStorage.create(env, filePath, {
cwd: dir,
sessionId: "session-1",
parentSessionPath: "/tmp/parent.jsonl",
@@ -179,13 +183,14 @@ describe("JsonlSessionStorage", () => {
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("one"),
});
expect(await loadJsonlSessionMetadata(filePath)).toEqual(metadata);
expect(await loadJsonlSessionMetadata(env, filePath)).toEqual(metadata);
});
it("loads existing entries and reconstructs leaf", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
const root: MessageEntry = {
type: "message",
id: "root",
@@ -201,16 +206,21 @@ describe("JsonlSessionStorage", () => {
};
await storage.appendEntry(root);
await storage.appendEntry(child);
const loaded = await JsonlSessionStorage.open(filePath);
const loaded = await JsonlSessionStorage.open(env, filePath);
expect(await loaded.getLeafId()).toBe("child");
expect((await loaded.getEntries()).map((entry) => entry.id)).toEqual(["root", "child"]);
await loaded.setLeafId("root");
const reloaded = await JsonlSessionStorage.open(env, filePath);
expect(await reloaded.getLeafId()).toBe("root");
expect((await reloaded.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: "root" });
expect((await loaded.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
});
it("finds entries by type", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
await storage.appendEntry({
type: "message",
id: "entry-1",
@@ -224,8 +234,9 @@ describe("JsonlSessionStorage", () => {
it("maintains label lookup", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
await storage.appendEntry({
type: "message",
id: "entry-1",
@@ -252,11 +263,11 @@ describe("JsonlSessionStorage", () => {
label: undefined,
});
expect(await storage.getLabel("entry-1")).toBeUndefined();
const loaded = await JsonlSessionStorage.open(filePath);
const loaded = await JsonlSessionStorage.open(env, filePath);
expect(await loaded.getLabel("entry-1")).toBeUndefined();
});
it("reads session metadata from only the first JSONL line", async () => {
it("reads session metadata through the line-reading filesystem operation", async () => {
const dir = createTempDir();
const filePath = join(dir, "session.jsonl");
const header = {
@@ -266,9 +277,18 @@ describe("JsonlSessionStorage", () => {
timestamp: "2026-01-01T00:00:00.000Z",
cwd: dir,
};
const malformedSecondLine = "{".repeat(10000);
writeFileSync(filePath, `${JSON.stringify(header)}\n${malformedSecondLine}\n`);
expect(await loadJsonlSessionMetadata(filePath)).toEqual({
const metadata = await loadJsonlSessionMetadata(
{
readTextLines: async () => ok([JSON.stringify(header)]),
readTextFile: async () => {
throw new Error("readTextFile should not be called for metadata");
},
writeFile: async () => ok(undefined),
appendFile: async () => ok(undefined),
},
filePath,
);
expect(metadata).toEqual({
id: "session-1",
createdAt: "2026-01-01T00:00:00.000Z",
cwd: dir,

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.js";
import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.ts";
const visibleSkill = {
name: "visible",

View File

@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import { truncateHead, truncateTail } from "../../src/harness/utils/truncate.ts";
const encoder = new TextEncoder();
function byteLength(content: string): number {
return encoder.encode(content).length;
}
function bufferTail(content: string, maxBytes: number): string {
const bytes = Buffer.from(content, "utf8");
if (bytes.length <= maxBytes) return content;
let start = bytes.length - maxBytes;
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
return bytes.subarray(start).toString("utf8");
}
function assertMatchesBufferTail(input: string, maxByteValues?: readonly number[]): void {
const totalBytes = Buffer.byteLength(input, "utf8");
const values = maxByteValues ?? Array.from({ length: totalBytes + 5 }, (_, maxBytes) => maxBytes);
for (const maxBytes of values) {
const result = truncateTail(input, { maxBytes, maxLines: 10 });
const expected = bufferTail(input, maxBytes);
if (result.content !== expected) {
throw new Error(
`tail mismatch input=${JSON.stringify(input)} maxBytes=${maxBytes} expected=${JSON.stringify(expected)} actual=${JSON.stringify(result.content)}`,
);
}
const outputBytes = Buffer.byteLength(result.content, "utf8");
if (outputBytes > maxBytes) {
throw new Error(
`tail output exceeded byte limit input=${JSON.stringify(input)} maxBytes=${maxBytes} outputBytes=${outputBytes}`,
);
}
}
}
function sampledByteLimits(input: string): number[] {
const totalBytes = Buffer.byteLength(input, "utf8");
const candidates = [
0,
1,
2,
3,
4,
5,
8,
Math.floor(totalBytes / 2) - 1,
Math.floor(totalBytes / 2),
Math.floor(totalBytes / 2) + 1,
totalBytes - 8,
totalBytes - 5,
totalBytes - 4,
totalBytes - 3,
totalBytes - 2,
totalBytes - 1,
totalBytes,
totalBytes + 1,
totalBytes + 4,
];
return [...new Set(candidates.filter((value) => value >= 0))].sort((a, b) => a - b);
}
describe("truncate utilities", () => {
it("counts UTF-8 bytes without Node Buffer", () => {
const content = "aé🙂\nb";
const result = truncateHead(content, { maxBytes: 100, maxLines: 10 });
expect(result.truncated).toBe(false);
expect(result.totalBytes).toBe(byteLength(content));
expect(result.outputBytes).toBe(byteLength(content));
expect(result.totalBytes).toBe(9);
});
it("truncates head on UTF-8 byte limits without partial lines", () => {
const content = "éé\nabc";
const result = truncateHead(content, { maxBytes: 4, maxLines: 10 });
expect(result.content).toBe("éé");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.outputBytes).toBe(4);
expect(result.firstLineExceedsLimit).toBe(false);
});
it("reports head truncation when the first line exceeds the byte limit", () => {
const result = truncateHead("éé\nabc", { maxBytes: 3, maxLines: 10 });
expect(result.content).toBe("");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.firstLineExceedsLimit).toBe(true);
});
it("truncates tail on UTF-8 boundaries when only a partial last line fits", () => {
const result = truncateTail("aé🙂b", { maxBytes: 5, maxLines: 10 });
expect(result.content).toBe("🙂b");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.lastLinePartial).toBe(true);
expect(result.outputBytes).toBe(5);
});
it("truncates an oversized single line with a trailing newline", () => {
const input = `${"X".repeat(300_000)}\n`;
const result = truncateTail(input, { maxBytes: 1024, maxLines: 100 });
expect(result.content).toBe("X".repeat(1024));
expect(result.outputBytes).toBe(1024);
expect(result.outputLines).toBe(1);
expect(result.lastLinePartial).toBe(true);
expect(result.truncatedBy).toBe("bytes");
});
it("drops an oversized trailing character when it cannot fit in tail byte limit", () => {
const result = truncateTail("abc🙂", { maxBytes: 3, maxLines: 10 });
expect(result.content).toBe("");
expect(result.truncated).toBe(true);
expect(result.truncatedBy).toBe("bytes");
expect(result.lastLinePartial).toBe(true);
expect(result.outputBytes).toBe(0);
});
it("matches Buffer tail truncation semantics for surrogate edge cases", () => {
const inputs = ["a\ud83d", "\ude42b", "a\ude42b", "\ud83d\ud83d\ude42", "\ud83d\ude42\ude42", "👩‍💻"];
for (const input of inputs) assertMatchesBufferTail(input);
});
it("matches Buffer tail truncation semantics across deterministic fuzz cases", () => {
const alphabet = [
"a",
"\u007f",
"\u0080",
"é",
"\u07ff",
"\u0800",
"中",
"\ud7ff",
"\ud800",
"\ud83d",
"\udc00",
"\ude42",
"🙂",
"\ue000",
"\uffff",
];
function checkExhaustive(prefix: string, depth: number): void {
assertMatchesBufferTail(prefix, sampledByteLimits(prefix));
if (depth === 0) return;
for (const character of alphabet) checkExhaustive(prefix + character, depth - 1);
}
checkExhaustive("", 3);
let seed = 0x12345678;
function random(): number {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 0x100000000;
}
for (let i = 0; i < 1_000; i++) {
let input = "";
const length = Math.floor(random() * 80);
for (let j = 0; j < length; j++) input += alphabet[Math.floor(random() * alphabet.length)];
assertMatchesBufferTail(input, sampledByteLimits(input));
}
});
});

View File

@@ -1,23 +1,24 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { getModel } from "@earendil-works/pi-ai";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import {
AgentHarness,
formatSkillsForSystemPrompt,
loadSourcedPromptTemplates,
loadSourcedSkills,
NodeExecutionEnv,
type PromptTemplate,
Session,
type Skill,
} from "../../src/index.js";
} from "../../src/index.ts";
type Source = { type: "project" | "user" | "path"; dir: string };
type SourcedSkill = Skill & { source: Source };
type SourcedPromptTemplate = PromptTemplate & { source: Source };
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const source = (type: Source["type"], dir: string) => ({ path: dir, source: { type, dir } });
const { skills: sourcedSkills } = await loadSourcedSkills<Source, SourcedSkill>(
env,

View File

@@ -1,5 +1,5 @@
import { type Static, Type } from "typebox";
import type { AgentTool, AgentToolResult } from "../../src/types.js";
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
export interface CalculateResult extends AgentToolResult<undefined> {
content: Array<{ type: "text"; text: string }>;

View File

@@ -1,5 +1,5 @@
import { type Static, Type } from "typebox";
import type { AgentTool, AgentToolResult } from "../../src/types.js";
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {}

View File

@@ -2,6 +2,10 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"paths": {
"@earendil-works/pi-ai": ["../ai/dist/index.d.ts"],
"@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"]
},
"rootDir": "./src"
},
"include": ["src/**/*.ts"],

View File

@@ -1,6 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname,
"@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname,
},
},
test: {
globals: true,
environment: "node",

View File

@@ -0,0 +1,24 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname,
"@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname,
},
},
test: {
globals: true,
environment: "node",
testTimeout: 30000,
include: ["test/harness/**/*.test.ts"],
coverage: {
provider: "v8",
all: true,
include: ["src/harness/**/*.ts", "src/agent.ts", "src/agent-loop.ts"],
exclude: ["src/**/*.d.ts"],
reporter: ["text", "html", "lcov"],
reportsDirectory: "coverage/harness",
},
},
});

View File

@@ -2,13 +2,248 @@
## [Unreleased]
## [0.79.9] - 2026-06-20
### Added
- Added configurable `chat-template` thinking support for OpenAI-compatible providers that use `chat_template_kwargs`, such as DeepSeek models behind vLLM ([#5673](https://github.com/earendil-works/pi/issues/5673)).
### Fixed
- Fixed Fireworks GLM-5.2 metadata to use the OpenAI-compatible Chat Completions endpoint with `reasoning_effort` support ([#5923](https://github.com/earendil-works/pi/issues/5923)).
- Fixed OpenRouter GLM-5.2 metadata to expose `xhigh` reasoning and send OpenRouter's native `xhigh` effort ([#5770](https://github.com/earendil-works/pi/issues/5770)).
- Fixed GitHub Copilot OAuth model availability to use the authenticated account's model picker catalog ([#5897](https://github.com/earendil-works/pi/issues/5897)).
## [0.79.8] - 2026-06-19
### Added
- Added `@earendil-works/pi-ai/base` and direct provider registration exports for bundlers that want selective provider transports without root built-in registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)).
- Added prompt caching for Mistral requests using the pi session ID as `prompt_cache_key`, including cached-token usage and cost accounting ([#5854](https://github.com/earendil-works/pi/issues/5854)).
- Added the OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)).
## [0.79.7] - 2026-06-18
### Added
- Added GLM-5.2 model to the OpenCode Go subscription model catalog ([#5860](https://github.com/earendil-works/pi/issues/5860)).
## [0.79.6] - 2026-06-16
### Fixed
- Fixed OpenCode Go DeepSeek V4 thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter.
## [0.79.5] - 2026-06-16
### Added
- Added provider-scoped `StreamOptions.env` overrides for provider configuration, including Cloudflare endpoint placeholders, Azure OpenAI, Google Vertex, Amazon Bedrock, cache retention, and proxy environment lookups ([#5728](https://github.com/earendil-works/pi/issues/5728)).
### Fixed
- Fixed OpenAI Responses streaming to tolerate null message content from OpenAI-compatible servers before tool calls ([#5819](https://github.com/earendil-works/pi/issues/5819)).
- Fixed OpenCode DeepSeek V4 thinking requests to avoid sending both `thinking` and `reasoning_effort` ([#5818](https://github.com/earendil-works/pi/issues/5818)).
- Fixed Z.AI GLM-5.2 thinking requests to send `reasoning_effort` with the provider's `high`/`max` effort mapping ([#5770](https://github.com/earendil-works/pi/issues/5770)).
- Fixed Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)).
- Fixed Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)).
## [0.79.4] - 2026-06-15
### Fixed
- Fixed Anthropic 1-hour prompt-cache write cost accounting to price 1-hour cache writes at 2x input instead of the 5-minute cache-write rate ([#5738](https://github.com/earendil-works/pi/pull/5738) by [@theBucky](https://github.com/theBucky)).
- Fixed GitHub Copilot Claude adaptive-thinking effort metadata to match manually checked Copilot model capabilities ([#4637](https://github.com/earendil-works/pi/issues/4637)).
- Fixed OpenCode/OpenCode Go completion models that reject `prompt_cache_retention` to omit long-retention cache fields when `cacheRetention` is `long` ([#5702](https://github.com/earendil-works/pi/issues/5702)).
## [0.79.3] - 2026-06-13
### Fixed
- Restored OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to the observed 272k-token Codex backend limit, avoiding a billing hazard from sending prompts above Codex's accepted limit (reported by [@trethore](https://github.com/trethore)).
## [0.79.2] - 2026-06-12
### Added
- Added AWS data retention documentation links to Amazon Bedrock unsupported data retention mode validation errors ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)).
### Fixed
- Fixed OpenAI-compatible context overflow detection for parenthesized `maximum context length (N)` errors ([#5677](https://github.com/earendil-works/pi/issues/5677)).
- Fixed OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to match current OpenAI limits ([#5644](https://github.com/earendil-works/pi/issues/5644)).
- Increased the OpenAI Codex Responses SSE response-header timeout to 20 seconds to reduce false-positive stalls while retaining the bounded wait introduced for zero-event hangs ([#4945](https://github.com/earendil-works/pi/issues/4945)).
- Fixed Anthropic refusal stops to preserve provider `stop_details` explanations in error messages ([#5666](https://github.com/earendil-works/pi/pull/5666) by [@rwachtler](https://github.com/rwachtler)).
- Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)).
## [0.79.1] - 2026-06-09
### Added
- Added Claude Fable 5 to Anthropic and Amazon Bedrock model metadata, with adaptive thinking and `xhigh` effort support.
### Fixed
- Fixed Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)).
- Fixed z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)).
- Fixed OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)).
- Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)).
- Fixed Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)).
- Fixed Azure GPT-5.4 and GPT-5.5 context window metadata to 1,050,000 tokens, matching Azure Foundry deployments instead of OpenAI's 272k limit ([#5559](https://github.com/earendil-works/pi/issues/5559)).
- Fixed OpenAI and Azure GPT-5 Pro `maxTokens` metadata to 128,000, correcting an upstream value that duplicated the 272,000 input sub-limit as the output limit ([#5559](https://github.com/earendil-works/pi/issues/5559)).
## [0.79.0] - 2026-06-08
### Fixed
- Fixed OpenAI Responses custom providers to honor `compat.supportsDeveloperRole: false` for reasoning models ([#5456](https://github.com/earendil-works/pi/issues/5456)).
- Fixed OpenRouter routing preferences on OpenAI-compatible custom providers to send `compat.openRouterRouting` even when `baseUrl` does not point directly at OpenRouter ([#5347](https://github.com/earendil-works/pi/issues/5347)).
## [0.78.1] - 2026-06-04
### Added
- Added Ant Ling as a built-in OpenAI-compatible provider with Ling 2.6 and Ring 2.6 models.
- Added MiniMax-M3 model to the `minimax` and `minimax-cn` direct providers, and removed the hardcoded context-window override that was masking models.dev values ([#5313](https://github.com/earendil-works/pi/issues/5313)).
- Added NVIDIA NIM as a built-in OpenAI-compatible provider, exposing public NIM models that support tool use.
### Fixed
- Fixed Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)).
- Fixed Anthropic Claude Opus 4.7+ requests to suppress deprecated temperature parameters ([#5251](https://github.com/earendil-works/pi/pull/5251) by [@yzhg1983](https://github.com/yzhg1983)).
- Fixed OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)).
- Fixed OpenRouter Kimi K2.6 thinking replay and preserved developer-role instructions for OpenRouter OpenAI and Anthropic models ([#5309](https://github.com/earendil-works/pi/issues/5309)).
- Fixed OpenRouter reasoning instruction requests to preserve the system role when required ([#5221](https://github.com/earendil-works/pi/pull/5221) by [@PriNova](https://github.com/PriNova)).
- Restored the NVIDIA Qwen 3.5 122B NIM model.
## [0.78.0] - 2026-05-29
### Breaking Changes
- Changed direct provider stream functions to require explicit `options.apiKey`; top-level `stream*`/`complete*` helpers still resolve built-in environment auth.
### Added
- Added custom Amazon Bedrock request header support via `StreamOptions.headers`, excluding reserved AWS signing headers ([#5178](https://github.com/earendil-works/pi-mono/pull/5178) by [@stephanmck](https://github.com/stephanmck)).
### Fixed
- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages ([#5159](https://github.com/earendil-works/pi-mono/issues/5159)).
- Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi-mono/issues/5169)).
- Fixed OpenAI Codex Responses SSE streams to abort response body reads after terminal events.
- Fixed OpenCode Kimi K2.6 generated metadata to use Anthropic-style thinking metadata instead of invalid reasoning-effort parameters.
## [0.77.0] - 2026-05-28
### Added
- Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default ([#4911](https://github.com/earendil-works/pi/pull/4911) by [@vegarsti](https://github.com/vegarsti)).
- Added Claude Opus 4.8 model metadata for Anthropic and updated Opus adaptive-thinking coverage to use it.
### Fixed
- Fixed OpenRouter DeepSeek V4 `xhigh` reasoning metadata to preserve OpenRouter's native effort instead of sending DeepSeek's `max` effort ([#4801](https://github.com/earendil-works/pi/issues/4801)).
- Fixed OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions by generating unique fallback message item IDs for converted thinking/text blocks ([#5148](https://github.com/earendil-works/pi/issues/5148)).
- Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)).
- Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts.
- Fixed OpenCode Go Kimi K2.6 thinking-off requests to send `thinking: "none"` ([#5078](https://github.com/earendil-works/pi/issues/5078)).
- Fixed Xiaomi Token Plan model metadata to omit unsupported `mimo-v2-flash` variants ([#5075](https://github.com/earendil-works/pi/issues/5075)).
## [0.76.0] - 2026-05-27
### Fixed
- Fixed OpenAI Codex Responses cache-affinity headers to send `session-id` instead of proxy-incompatible `session_id` ([#4967](https://github.com/earendil-works/pi/issues/4967)).
- Fixed `openai-codex/gpt-5.3-codex-spark` generated metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)).
- Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)).
- Fixed OpenAI Codex Responses WebSocket streams and SSE response-header waits to apply bounded timeouts instead of waiting indefinitely when no events arrive ([#4945](https://github.com/earendil-works/pi/issues/4945)).
- Fixed provider retry controls so OpenAI Codex Responses honors `maxRetries`, SDK retries default to `0`, and quota/billing 429s are not retried behind Pi's retry handling ([#4991](https://github.com/earendil-works/pi-mono/pull/4991) by [@mitsuhiko](https://github.com/mitsuhiko)).
## [0.75.5] - 2026-05-23
### Breaking Changes
- Changed `OAuthLoginCallbacks` to require `onDeviceCode` and `onSelect`, so OAuth providers can rely on pi supplying device-code and selection UI callbacks ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)).
### Fixed
- Fixed custom Anthropic-compatible model aliases for adaptive-thinking Claude models by adding `compat.forceAdaptiveThinking` model metadata and moving built-in adaptive-thinking selection out of provider id substring checks ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)).
- Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)).
- Fixed Amazon Bedrock provider loading under strict package managers by declaring its direct `@smithy/node-http-handler` dependency ([#4842](https://github.com/earendil-works/pi/issues/4842)).
- Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)).
## [0.75.4] - 2026-05-20
### Changed
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks.
- Removed the package-level development watch scripts now that the root TypeScript check validates strip-only-compatible sources.
### Added
- Added first-class OAuth device-code callback metadata, shared polling support, and GitHub Copilot OAuth integration.
### Fixed
- Fixed OpenAI-compatible `streamSimple()` requests to stop sending model-derived default output token caps, avoiding context-window reservation failures on servers such as vLLM while preserving explicit `maxTokens` and required Anthropic `max_tokens` handling ([#4675](https://github.com/earendil-works/pi/issues/4675)).
- Fixed OpenAI prompt cache keys to clamp session-derived values to the 64-character API limit across OpenAI Responses, Chat Completions, Codex Responses, and Azure OpenAI Responses ([#4720](https://github.com/earendil-works/pi/issues/4720)).
## [0.75.3] - 2026-05-18
## [0.75.2] - 2026-05-18
### Fixed
- Fixed Xiaomi MiMo generated model metadata to replay assistant tool-call messages with `reasoning_content` for thinking-mode multi-turn requests ([#4678](https://github.com/earendil-works/pi/issues/4678)).
## [0.75.1] - 2026-05-18
### Fixed
- Fixed Anthropic-compatible API-key requests to ignore unrelated `ANTHROPIC_AUTH_TOKEN` environment values, avoiding invalid bearer credentials for providers such as Xiaomi MiMo ([#4342](https://github.com/earendil-works/pi/issues/4342)).
- Fixed Amazon Bedrock message conversion to skip unknown content blocks instead of failing the stream ([#4223](https://github.com/earendil-works/pi/issues/4223)).
- Fixed Azure OpenAI Responses and OpenAI Responses error formatting to prefix HTTP status codes onto `errorMessage`, so transient 5xx and 429 errors are correctly matched by the agent-level auto-retry classifier ([#4232](https://github.com/earendil-works/pi/issues/4232)).
- Fixed Xiaomi MiMo model metadata to use the OpenAI-compatible endpoints and `openai-completions` API, restoring multi-turn thinking/tool-call sessions ([#4505](https://github.com/earendil-works/pi/issues/4505)).
- Fixed OpenCode Go Kimi reasoning replay by normalizing streamed `reasoning` fields back to `reasoning_content` for OpenCode Go only ([#4251](https://github.com/earendil-works/pi/issues/4251)).
### Removed
- Removed non-working OpenAI Codex fast model variants.
## [0.75.0] - 2026-05-17
### Breaking Changes
- Raised the minimum supported Node.js version to 22.19.0.
### Fixed
- Fixed OpenAI Codex generated model metadata to use the current upstream model list ([#4603](https://github.com/earendil-works/pi-mono/pull/4603) by [@mattiacerutti](https://github.com/mattiacerutti)).
- Fixed GitHub Copilot GPT model thinking metadata to map unsupported minimal thinking to low ([#4622](https://github.com/earendil-works/pi-mono/pull/4622) by [@mattiacerutti](https://github.com/mattiacerutti)).
- Fixed `streamSimple()` defaults for models whose advertised output limit is effectively their full context window to avoid impossible default requests ([#4614](https://github.com/earendil-works/pi/issues/4614)).
## [0.74.1] - 2026-05-16
### Added
- Added image generation APIs, image model metadata, and built-in OpenRouter image generation support ([#3887](https://github.com/earendil-works/pi-mono/pull/3887) by [@cristinaponcela](https://github.com/cristinaponcela)).
- Added Together AI as a built-in OpenAI-compatible provider with generated model metadata and `TOGETHER_API_KEY` authentication ([#3624](https://github.com/earendil-works/pi-mono/pull/3624) by [@Nutlope](https://github.com/Nutlope)).
### Fixed
- Fixed GitHub Copilot model availability to ignore generic `GH_TOKEN` and `GITHUB_TOKEN` environment variables, requiring OAuth login or `COPILOT_GITHUB_TOKEN` instead ([#4485](https://github.com/earendil-works/pi/issues/4485)).
- Fixed `openai-completions` streams to surface an error when the stream ends before any terminal `finish_reason`, so truncated responses can retry instead of being accepted as success ([#4345](https://github.com/earendil-works/pi/issues/4345)).
- Fixed Fireworks provider caching compatibility by adding session affinity headers and model metadata compat settings ([#4358](https://github.com/earendil-works/pi-mono/pull/4358) by [@yanirz](https://github.com/yanirz)).
- Fixed OpenAI Codex WebSocket transport to respect proxy environment variables under Bun ([#4354](https://github.com/earendil-works/pi-mono/pull/4354) by [@haoqixu](https://github.com/haoqixu)).
- Fixed OpenRouter cache usage normalization to preserve cached-token semantics without treating cached tokens as cache writes.
- Fixed Bedrock proxy handling to preserve `NO_PROXY` exclusions while using HTTP(S)-only proxy agents.
- Fixed compiled Bun binaries failing to start outside the repo when Bedrock proxy support tried to resolve `proxy-from-env` from external `node_modules` ([#4513](https://github.com/earendil-works/pi/issues/4513)).
- Fixed GitHub Copilot Claude test coverage to use the current Claude Sonnet 4.6 model ID.
- Fixed OpenAI Responses requests for models that support disabling reasoning to send `reasoning.effort: "none"` when thinking is off.
- Fixed Inception Mercury 2 tool calling on OpenRouter by marking `off` as unsupported in `thinkingLevelMap`, so the openai-completions provider omits the reasoning param instead of defaulting to `{reasoning:{effort:"none"}}` (which puts Mercury 2 in instant mode, disabling tool calls).
- Fixed OpenAI Codex SSE retries to honor `retry-after-ms` and `retry-after` headers before falling back to exponential backoff.
- Fixed context overflow detection for LiteLLM-wrapped OpenAI-compatible errors using `exceeds the model's maximum context length of ... tokens` wording ([#4563](https://github.com/earendil-works/pi/issues/4563)).
- Fixed `streamSimple()` defaults to respect model output limits above 32000 tokens instead of clamping provider requests to 32000 ([#4539](https://github.com/earendil-works/pi/issues/4539)).
## [0.74.0] - 2026-05-07

View File

@@ -8,6 +8,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
- [Supported Providers](#supported-providers)
- [Installation](#installation)
- [Base Entry Point](#base-entry-point)
- [Quick Start](#quick-start)
- [Tools](#tools)
- [Defining Tools](#defining-tools)
@@ -38,6 +39,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
- [Browser Usage](#browser-usage)
- [Browser Compatibility Notes](#browser-compatibility-notes)
- [Environment Variables](#environment-variables-nodejs-only)
- [Provider-Scoped Environment Overrides](#provider-scoped-environment-overrides)
- [Checking Environment Variables](#checking-environment-variables)
- [OAuth Providers](#oauth-providers)
- [Vertex AI](#vertex-ai)
@@ -51,9 +53,11 @@ Unified LLM API with automatic model discovery, provider configuration, token an
## Supported Providers
- **OpenAI**
- **Ant Ling**
- **Azure OpenAI (Responses)**
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
- **DeepSeek**
- **NVIDIA NIM**
- **Anthropic**
- **Google**
- **Vertex AI** (Gemini via Vertex AI)
@@ -65,6 +69,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
- **xAI**
- **OpenRouter**
- **Vercel AI Gateway**
- **ZAI** (with separate Coding Plan China provider)
- **MiniMax**
- **Together AI**
- **GitHub Copilot** (requires OAuth, see below)
@@ -84,6 +89,30 @@ npm install @earendil-works/pi-ai
TypeBox exports are re-exported from `@earendil-works/pi-ai`: `Type`, `Static`, and `TSchema`.
## Base Entry Point
Use `@earendil-works/pi-ai/base` when a bundler should include only explicitly selected transport implementations. The base entry point exposes model discovery, registries, generic dispatch, environment-key helpers, and provider-neutral utilities without registering built-in transports during module initialization. Generic dispatch still resolves configured environment keys when called.
Import each selected transport directly and call its `register()` function:
```typescript
import { getModel, streamSimple } from '@earendil-works/pi-ai/base';
import { register as registerAnthropic } from '@earendil-works/pi-ai/anthropic';
registerAnthropic();
const model = getModel('anthropic', 'claude-sonnet-4-6');
const response = await streamSimple(model, {
messages: [{ role: 'user', content: 'Hello!' }]
}, {
apiKey: process.env.ANTHROPIC_API_KEY
}).result();
```
Direct transport imports bundle their implementation and SDK code. Register only the transports used by the application. For example, OpenRouter, Groq, xAI, and DeepSeek chat models share the `@earendil-works/pi-ai/openai-completions` transport.
Use `@earendil-works/pi-ai` for the batteries-included behavior. The root entry point remains backward-compatible: it registers lazy wrappers for all built-in transports and resolves environment API keys automatically during dispatch.
## Quick Start
```typescript
@@ -434,7 +463,7 @@ Do not use `stream()` or `complete()` for image generation. Image generation is
### Basic Image Generation
```typescript
import { getImageModel, generateImages } from '@mariozechner/pi-ai';
import { getImageModel, generateImages } from '@earendil-works/pi-ai';
const model = getImageModel('openrouter', 'google/gemini-2.5-flash-image');
@@ -801,7 +830,7 @@ A **provider** offers models through a specific API. For example:
- **Google** models use the `google-generative-ai` API
- **OpenAI** models use the `openai-responses` API
- **Mistral** models use the `mistral-conversations` API
- **xAI, Cerebras, Groq, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible)
- **xAI, Cerebras, Groq, NVIDIA NIM, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible)
### Querying Providers and Models
@@ -923,7 +952,7 @@ const ollamaReasoningModel: Model<'openai-completions'> = {
### OpenAI Compatibility Settings
The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags.
The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, NVIDIA NIM, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field supports Responses-specific flags.
```typescript
interface OpenAICompletionsCompat {
@@ -938,14 +967,17 @@ interface OpenAICompletionsCompat {
requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false)
requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false)
requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek)
thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai)
thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses thinking: { type }, 'qwen' uses enable_thinking, 'chat-template' uses configurable chat_template_kwargs, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking and preserve_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai)
chatTemplateKwargs?: Record<string, string | number | boolean | null | { '$var': 'thinking.enabled' | 'thinking.effort'; omitWhenOff?: boolean }>; // chat_template_kwargs values; use $var for pi-controlled thinking values
cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content
openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {})
vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {})
}
interface OpenAIResponsesCompat {
// Reserved for future use
supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
sendSessionIdHeader?: boolean; // Whether to send `session_id` from `sessionId` when caching is enabled (default: true)
supportsLongCacheRetention?: boolean; // Whether provider supports `prompt_cache_retention: "24h"` (default: true)
}
```
@@ -1091,6 +1123,7 @@ const response = await complete(model, {
- OAuth login flows are not supported in browser environments. Use the `@earendil-works/pi-ai/oauth` entry point in Node.js.
- In browser builds, Bedrock can still appear in model lists. Calls to Bedrock models fail at runtime.
- Use a server-side proxy or backend service if you need Bedrock or OAuth-based auth from a web app.
- Use `@earendil-works/pi-ai/base` plus explicit direct transport registration when a browser bundle should exclude unused provider SDK implementations.
### Environment Variables (Node.js only)
@@ -1099,9 +1132,11 @@ In Node.js environments, you can set environment variables to avoid passing API
| Provider | Environment Variable(s) |
|----------|------------------------|
| OpenAI | `OPENAI_API_KEY` |
| Ant Ling | `ANT_LING_API_KEY` |
| Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` (e.g. `https://{resource}.openai.azure.com`) or `AZURE_OPENAI_RESOURCE_NAME`. Supports `*.openai.azure.com` and `*.cognitiveservices.azure.com`; root endpoints auto-normalize to `/openai/v1`. Optional: `AZURE_OPENAI_API_VERSION` (default `v1`), `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. |
| Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` |
| DeepSeek | `DEEPSEEK_API_KEY` |
| NVIDIA NIM | `NVIDIA_API_KEY` |
| Google | `GEMINI_API_KEY` |
| Vertex AI | `GOOGLE_CLOUD_API_KEY` or `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`) + `GOOGLE_CLOUD_LOCATION` + ADC |
| Mistral | `MISTRAL_API_KEY` |
@@ -1115,6 +1150,7 @@ In Node.js environments, you can set environment variables to avoid passing API
| OpenRouter | `OPENROUTER_API_KEY` |
| Vercel AI Gateway | `AI_GATEWAY_API_KEY` |
| zAI | `ZAI_API_KEY` |
| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` |
| MiniMax | `MINIMAX_API_KEY` |
| OpenCode Zen / OpenCode Go | `OPENCODE_API_KEY` |
| Kimi For Coding | `KIMI_API_KEY` |
@@ -1122,7 +1158,7 @@ In Node.js environments, you can set environment variables to avoid passing API
| Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` |
| Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` |
| Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` |
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` |
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` |
When set, the library automatically uses these keys:
@@ -1137,6 +1173,24 @@ const response = await complete(model, context, {
});
```
### Provider-Scoped Environment Overrides
Pass `env` in stream options to scope provider configuration to a request. Values in `env` are used before process environment variables for API key discovery and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`.
```typescript
const model = getModel('cloudflare-ai-gateway', 'workers-ai/@cf/moonshotai/kimi-k2.6');
const response = await complete(model, context, {
env: {
CLOUDFLARE_API_KEY: '...',
CLOUDFLARE_ACCOUNT_ID: 'account-id',
CLOUDFLARE_GATEWAY_ID: 'gateway-id'
}
});
```
Use this when one process needs different provider settings per request, or when ambient environment variables should not leak into a provider call.
### Checking Environment Variables
```typescript
@@ -1306,6 +1360,7 @@ Create a new provider file (for example `amazon-bedrock.ts`) that exports:
- `stream<Provider>()` function returning `AssistantMessageEventStream`
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping
- `register()` for explicit direct transport registration from `@earendil-works/pi-ai/base`
- Provider-specific options interface
- Message conversion functions to transform `Context` to provider format
- Tool conversion if the provider supports tools
@@ -1316,7 +1371,8 @@ Create a new provider file (for example `amazon-bedrock.ts`) that exports:
- Register the API with `registerApiProvider()`
- Add a package subpath export in `package.json` for the provider module (`./dist/providers/<provider>.js`)
- Add lazy loader wrappers in `src/providers/register-builtins.ts`, do not statically import provider implementation modules there
- Add any root-level `export type` re-exports in `src/index.ts` that should remain available from `@earendil-works/pi-ai`
- Add any root-level `export type` re-exports in `src/index.ts` and `src/base.ts` that should remain available from `@earendil-works/pi-ai` and `@earendil-works/pi-ai/base`
- Keep `src/base.ts` free of built-in registration imports
- Add credential detection in `env-api-keys.ts` for the new provider
- Ensure `streamSimple` handles auth lookup via `getEnvApiKey()` or provider-specific auth

View File

@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-ai",
"version": "0.74.0",
"version": "0.79.9",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",
@@ -10,6 +10,14 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./base": {
"types": "./dist/base.d.ts",
"import": "./dist/base.js"
},
"./amazon-bedrock": {
"types": "./dist/providers/amazon-bedrock.d.ts",
"import": "./dist/providers/amazon-bedrock.js"
},
"./anthropic": {
"types": "./dist/providers/anthropic.d.ts",
"import": "./dist/providers/anthropic.js"
@@ -42,6 +50,10 @@
"types": "./dist/providers/openai-responses.d.ts",
"import": "./dist/providers/openai-responses.js"
},
"./openrouter-images": {
"types": "./dist/providers/images/openrouter.d.ts",
"import": "./dist/providers/images/openrouter.js"
},
"./oauth": {
"types": "./dist/oauth.d.ts",
"import": "./dist/oauth.js"
@@ -60,26 +72,24 @@
],
"scripts": {
"clean": "shx rm -rf dist",
"generate-models": "npx tsx scripts/generate-models.ts",
"generate-image-models": "npx tsx scripts/generate-image-models.ts",
"generate-models": "node scripts/generate-models.ts",
"generate-image-models": "node scripts/generate-image-models.ts",
"build": "npm run generate-models && npm run generate-image-models && tsgo -p tsconfig.build.json",
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"dev:tsc": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"test": "vitest --run",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.91.1",
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
"@google/genai": "^1.40.0",
"@mistralai/mistralai": "^2.2.0",
"typebox": "^1.1.24",
"chalk": "^5.6.2",
"@anthropic-ai/sdk": "0.91.1",
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@google/genai": "1.52.0",
"@mistralai/mistralai": "2.2.6",
"@opentelemetry/api": "1.9.0",
"@smithy/node-http-handler": "4.7.3",
"http-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.6",
"openai": "6.26.0",
"partial-json": "^0.1.7",
"proxy-agent": "^6.5.0",
"undici": "^7.19.1",
"zod-to-json-schema": "^3.24.6"
"partial-json": "0.1.7",
"typebox": "1.1.38"
},
"keywords": [
"ai",
@@ -95,15 +105,15 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi-mono.git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/ai"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.19.0"
},
"devDependencies": {
"@types/node": "^24.3.0",
"canvas": "^3.2.0",
"vitest": "^3.2.4"
"@types/node": "24.12.4",
"canvas": "3.2.3",
"vitest": "4.1.9"
}
}

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env tsx
#!/usr/bin/env node
import { writeFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import type { ImagesModel } from "../src/types.js";
import type { ImagesModel } from "../src/types.ts";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -109,7 +109,7 @@ function generateImageModelsFile(models: ImagesModel<"openrouter-images">[]): st
return `// This file is auto-generated by scripts/generate-image-models.ts
// Do not edit manually - run 'npm run generate-image-models' to update
import type { ImagesApi, ImagesModel } from "./types.js";
import type { ImagesApi, ImagesModel } from "./types.ts";
export const IMAGE_MODELS = {
${providerEntries}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env tsx
#!/usr/bin/env node
import { createCanvas } from "canvas";
import { writeFileSync } from "fs";
import { mkdirSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
@@ -27,7 +27,6 @@ const buffer = canvas.toBuffer("image/png");
const outputPath = join(__dirname, "..", "test", "data", "red-circle.png");
// Ensure the directory exists
import { mkdirSync } from "fs";
mkdirSync(join(__dirname, "..", "test", "data"), { recursive: true });
writeFileSync(outputPath, buffer);

View File

@@ -6,7 +6,7 @@ import type {
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "./types.js";
} from "./types.ts";
export type ApiStreamFunction = (
model: Model<Api>,

45
packages/ai/src/base.ts Normal file
View File

@@ -0,0 +1,45 @@
export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
export * from "./api-registry.ts";
export * from "./env-api-keys.ts";
export * from "./image-models.ts";
export * from "./images.ts";
export * from "./images-api-registry.ts";
export * from "./models.ts";
export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.ts";
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.ts";
export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.ts";
export * from "./providers/faux.ts";
export type { GoogleOptions } from "./providers/google.ts";
export type { GoogleThinkingLevel } from "./providers/google-shared.ts";
export type { GoogleVertexOptions } from "./providers/google-vertex.ts";
export type { MistralOptions } from "./providers/mistral.ts";
export type {
OpenAICodexResponsesOptions,
OpenAICodexWebSocketDebugStats,
} from "./providers/openai-codex-responses.ts";
export type { OpenAICompletionsOptions } from "./providers/openai-completions.ts";
export type { OpenAIResponsesOptions } from "./providers/openai-responses.ts";
export * from "./session-resources.ts";
export * from "./stream.ts";
export * from "./types.ts";
export * from "./utils/diagnostics.ts";
export * from "./utils/event-stream.ts";
export * from "./utils/json-parse.ts";
export type {
OAuthAuthInfo,
OAuthCredentials,
OAuthDeviceCodeInfo,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProvider,
OAuthProviderId,
OAuthProviderInfo,
OAuthProviderInterface,
OAuthSelectOption,
OAuthSelectPrompt,
} from "./utils/oauth/types.ts";
export * from "./utils/overflow.ts";
export * from "./utils/typebox-helpers.ts";
export * from "./utils/validation.ts";

View File

@@ -1,4 +1,6 @@
import { streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.js";
import { register, streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.ts";
export { register };
export const bedrockProviderModule = {
streamBedrock,

View File

@@ -2,8 +2,8 @@
import { createInterface } from "node:readline";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.js";
import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.js";
import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.ts";
import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.ts";
const AUTH_FILE = "auth.json";
const PROVIDERS = getOAuthProviders();
@@ -42,9 +42,23 @@ async function login(providerId: OAuthProviderId): Promise<void> {
if (info.instructions) console.log(info.instructions);
console.log();
},
onDeviceCode: (info) => {
console.log(`\nOpen this URL in your browser:\n${info.verificationUri}`);
console.log(`Enter code: ${info.userCode}`);
console.log();
},
onPrompt: async (p) => {
return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
},
onSelect: async (p) => {
console.log(`\n${p.message}`);
for (let i = 0; i < p.options.length; i++) {
console.log(` ${i + 1}. ${p.options[i].label}`);
}
const choice = await promptFn(`Enter number (1-${p.options.length}):`);
const index = parseInt(choice, 10) - 1;
return p.options[index]?.id;
},
onProgress: (msg) => console.log(msg),
});

View File

@@ -1,4 +1,4 @@
// NEVER convert to top-level imports - breaks browser/Vite builds (web-ui)
// NEVER convert to top-level imports - breaks browser/Vite builds
let _existsSync: typeof import("node:fs").existsSync | null = null;
let _homedir: typeof import("node:os").homedir | null = null;
let _join: typeof import("node:path").join | null = null;
@@ -23,44 +23,17 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
});
}
import type { KnownProvider } from "./types.js";
let _procEnvCache: Map<string, string> | null = null;
/**
* Fallback for https://github.com/oven-sh/bun/issues/27802
* Bun compiled binaries have an empty `process.env` inside sandbox
* environments on Linux. We can recover the env from `/proc/self/environ`.
*/
function getProcEnv(key: string): string | undefined {
if (!process.versions?.bun) return undefined;
if (typeof process === "undefined") return undefined;
// If process.env already has entries, the bug is not triggered.
if (Object.keys(process.env).length > 0) return undefined;
if (_procEnvCache === null) {
_procEnvCache = new Map();
try {
const { readFileSync } = require("node:fs") as typeof import("node:fs");
const data = readFileSync("/proc/self/environ", "utf-8");
for (const entry of data.split("\0")) {
const idx = entry.indexOf("=");
if (idx > 0) {
_procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1));
}
}
} catch {
// /proc/self/environ may not be readable.
}
}
return _procEnvCache.get(key);
}
import type { KnownProvider, ProviderEnv } from "./types.ts";
import { getProviderEnvValue } from "./utils/provider-env.ts";
let cachedVertexAdcCredentialsExists: boolean | null = null;
function hasVertexAdcCredentials(): boolean {
function hasVertexAdcCredentials(env?: ProviderEnv): boolean {
const explicitCredentialsPath = env?.GOOGLE_APPLICATION_CREDENTIALS;
if (explicitCredentialsPath) {
return _existsSync ? _existsSync(explicitCredentialsPath) : false;
}
if (cachedVertexAdcCredentialsExists === null) {
// If node modules haven't loaded yet (async import race at startup),
// return false WITHOUT caching so the next call retries once they're ready.
@@ -75,7 +48,7 @@ function hasVertexAdcCredentials(): boolean {
}
// Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way)
const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS || getProcEnv("GOOGLE_APPLICATION_CREDENTIALS");
const gacPath = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env);
if (gacPath) {
cachedVertexAdcCredentialsExists = _existsSync(gacPath);
} else {
@@ -99,8 +72,10 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
}
const envMap: Record<string, string> = {
"ant-ling": "ANT_LING_API_KEY",
openai: "OPENAI_API_KEY",
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
nvidia: "NVIDIA_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
google: "GEMINI_API_KEY",
"google-vertex": "GOOGLE_CLOUD_API_KEY",
@@ -110,6 +85,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
zai: "ZAI_API_KEY",
"zai-coding-cn": "ZAI_CODING_CN_API_KEY",
mistral: "MISTRAL_API_KEY",
minimax: "MINIMAX_API_KEY",
"minimax-cn": "MINIMAX_CN_API_KEY",
@@ -140,13 +116,13 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
* credential sources such as AWS profiles, AWS IAM credentials, and Google
* Application Default Credentials.
*/
export function findEnvKeys(provider: KnownProvider): string[] | undefined;
export function findEnvKeys(provider: string): string[] | undefined;
export function findEnvKeys(provider: string): string[] | undefined {
export function findEnvKeys(provider: KnownProvider, env?: ProviderEnv): string[] | undefined;
export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined;
export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined {
const envVars = getApiKeyEnvVars(provider);
if (!envVars) return undefined;
const found = envVars.filter((envVar) => !!process.env[envVar] || !!getProcEnv(envVar));
const found = envVars.filter((envVar) => !!getProviderEnvValue(envVar, env));
return found.length > 0 ? found : undefined;
}
@@ -155,25 +131,22 @@ export function findEnvKeys(provider: string): string[] | undefined {
*
* Will not return API keys for providers that require OAuth tokens.
*/
export function getEnvApiKey(provider: KnownProvider): string | undefined;
export function getEnvApiKey(provider: string): string | undefined;
export function getEnvApiKey(provider: string): string | undefined {
const envKeys = findEnvKeys(provider);
export function getEnvApiKey(provider: KnownProvider, env?: ProviderEnv): string | undefined;
export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined;
export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined {
const envKeys = findEnvKeys(provider, env);
if (envKeys?.[0]) {
return process.env[envKeys[0]] || getProcEnv(envKeys[0]);
return getProviderEnvValue(envKeys[0], env);
}
// Vertex AI supports either an explicit API key or Application Default Credentials.
// Auth is configured via `gcloud auth application-default login`.
if (provider === "google-vertex") {
const hasCredentials = hasVertexAdcCredentials();
const hasCredentials = hasVertexAdcCredentials(env);
const hasProject = !!(
process.env.GOOGLE_CLOUD_PROJECT ||
process.env.GCLOUD_PROJECT ||
getProcEnv("GOOGLE_CLOUD_PROJECT") ||
getProcEnv("GCLOUD_PROJECT")
getProviderEnvValue("GOOGLE_CLOUD_PROJECT", env) || getProviderEnvValue("GCLOUD_PROJECT", env)
);
const hasLocation = !!(process.env.GOOGLE_CLOUD_LOCATION || getProcEnv("GOOGLE_CLOUD_LOCATION"));
const hasLocation = !!getProviderEnvValue("GOOGLE_CLOUD_LOCATION", env);
if (hasCredentials && hasProject && hasLocation) {
return "<authenticated>";
@@ -189,18 +162,12 @@ export function getEnvApiKey(provider: string): string | undefined {
// 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI)
// 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts)
if (
process.env.AWS_PROFILE ||
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
process.env.AWS_BEARER_TOKEN_BEDROCK ||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ||
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI ||
process.env.AWS_WEB_IDENTITY_TOKEN_FILE ||
getProcEnv("AWS_PROFILE") ||
(getProcEnv("AWS_ACCESS_KEY_ID") && getProcEnv("AWS_SECRET_ACCESS_KEY")) ||
getProcEnv("AWS_BEARER_TOKEN_BEDROCK") ||
getProcEnv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") ||
getProcEnv("AWS_CONTAINER_CREDENTIALS_FULL_URI") ||
getProcEnv("AWS_WEB_IDENTITY_TOKEN_FILE")
getProviderEnvValue("AWS_PROFILE", env) ||
(getProviderEnvValue("AWS_ACCESS_KEY_ID", env) && getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env)) ||
getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", env) ||
getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", env) ||
getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_FULL_URI", env) ||
getProviderEnvValue("AWS_WEB_IDENTITY_TOKEN_FILE", env)
) {
return "<authenticated>";
}

View File

@@ -1,24 +1,24 @@
// This file is auto-generated by scripts/generate-image-models.ts
// Do not edit manually - run 'npm run generate-image-models' to update
import type { ImagesApi, ImagesModel } from "./types.js";
import type { ImagesApi, ImagesModel } from "./types.ts";
export const IMAGE_MODELS = {
"openrouter": {
openrouter: {
"black-forest-labs/flux.2-flex": {
id: "black-forest-labs/flux.2-flex",
name: "Black Forest Labs: FLUX.2 Flex",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"black-forest-labs/flux.2-klein-4b": {
id: "black-forest-labs/flux.2-klein-4b",
@@ -26,14 +26,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"black-forest-labs/flux.2-max": {
id: "black-forest-labs/flux.2-max",
@@ -41,14 +41,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"black-forest-labs/flux.2-pro": {
id: "black-forest-labs/flux.2-pro",
@@ -56,14 +56,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"bytedance-seed/seedream-4.5": {
id: "bytedance-seed/seedream-4.5",
@@ -71,14 +71,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image","text"],
input: ["image", "text"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-2.5-flash-image": {
id: "google/gemini-2.5-flash-image",
@@ -86,14 +86,29 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 0.3,
"output": 2.5,
"cacheRead": 0.03,
"cacheWrite": 0.08333333333333334
}
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 0.3,
output: 2.5,
cacheRead: 0.03,
cacheWrite: 0.08333333333333334,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3-pro-image": {
id: "google/gemini-3-pro-image",
name: "Google: Nano Banana Pro (Gemini 3 Pro Image)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 2,
output: 12,
cacheRead: 0.19999999999999998,
cacheWrite: 0.375,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3-pro-image-preview": {
id: "google/gemini-3-pro-image-preview",
@@ -101,14 +116,29 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 2,
"output": 12,
"cacheRead": 0.19999999999999998,
"cacheWrite": 0.375
}
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 2,
output: 12,
cacheRead: 0.19999999999999998,
cacheWrite: 0.375,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3.1-flash-image": {
id: "google/gemini-3.1-flash-image",
name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image)",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 0.5,
output: 3,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"google/gemini-3.1-flash-image-preview": {
id: "google/gemini-3.1-flash-image-preview",
@@ -116,14 +146,29 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 0.5,
"output": 3,
"cacheRead": 0,
"cacheWrite": 0
}
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 0.5,
output: 3,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"microsoft/mai-image-2.5": {
id: "microsoft/mai-image-2.5",
name: "Microsoft: MAI-Image-2.5",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 5,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-5-image": {
id: "openai/gpt-5-image",
@@ -131,14 +176,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 10,
"output": 10,
"cacheRead": 1.25,
"cacheWrite": 0
}
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 10,
output: 10,
cacheRead: 1.25,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-5-image-mini": {
id: "openai/gpt-5-image-mini",
@@ -146,14 +191,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 2.5,
"output": 2,
"cacheRead": 0.25,
"cacheWrite": 0
}
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 2.5,
output: 2,
cacheRead: 0.25,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openai/gpt-5.4-image-2": {
id: "openai/gpt-5.4-image-2",
@@ -161,14 +206,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["image","text"],
output: ["image","text"],
cost: {
"input": 8,
"output": 15,
"cacheRead": 2,
"cacheWrite": 0
}
input: ["image", "text"],
output: ["image", "text"],
cost: {
input: 8,
output: 15,
cacheRead: 2,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"openrouter/auto": {
id: "openrouter/auto",
@@ -176,14 +221,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
output: ["text","image"],
cost: {
"input": -1000000,
"output": -1000000,
"cacheRead": 0,
"cacheWrite": 0
}
input: ["text", "image"],
output: ["text", "image"],
cost: {
input: -1000000,
output: -1000000,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v3": {
id: "recraft/recraft-v3",
@@ -191,14 +236,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4": {
id: "recraft/recraft-v4",
@@ -206,14 +251,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-pro": {
id: "recraft/recraft-v4-pro",
@@ -221,14 +266,134 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-pro-vector": {
id: "recraft/recraft-v4-pro-vector",
name: "Recraft: Recraft V4 Pro Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4-vector": {
id: "recraft/recraft-v4-vector",
name: "Recraft: Recraft V4 Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1": {
id: "recraft/recraft-v4.1",
name: "Recraft: Recraft V4.1",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-pro": {
id: "recraft/recraft-v4.1-pro",
name: "Recraft: Recraft V4.1 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-pro-vector": {
id: "recraft/recraft-v4.1-pro-vector",
name: "Recraft: Recraft V4.1 Pro Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-utility": {
id: "recraft/recraft-v4.1-utility",
name: "Recraft: Recraft V4.1 Utility",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-utility-pro": {
id: "recraft/recraft-v4.1-utility-pro",
name: "Recraft: Recraft V4.1 Utility Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"recraft/recraft-v4.1-vector": {
id: "recraft/recraft-v4.1-vector",
name: "Recraft: Recraft V4.1 Vector",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-fast": {
id: "sourceful/riverflow-v2-fast",
@@ -236,14 +401,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-fast-preview": {
id: "sourceful/riverflow-v2-fast-preview",
@@ -251,14 +416,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-max-preview": {
id: "sourceful/riverflow-v2-max-preview",
@@ -266,14 +431,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-pro": {
id: "sourceful/riverflow-v2-pro",
@@ -281,14 +446,14 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2-standard-preview": {
id: "sourceful/riverflow-v2-standard-preview",
@@ -296,14 +461,59 @@ export const IMAGE_MODELS = {
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text","image"],
input: ["text", "image"],
output: ["image"],
cost: {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2.5-fast": {
id: "sourceful/riverflow-v2.5-fast",
name: "Sourceful: Riverflow V2.5 Fast",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"sourceful/riverflow-v2.5-pro": {
id: "sourceful/riverflow-v2.5-pro",
name: "Sourceful: Riverflow V2.5 Pro",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
"x-ai/grok-imagine-image-quality": {
id: "x-ai/grok-imagine-image-quality",
name: "xAI: Grok Imagine Image Quality",
api: "openrouter-images",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
input: ["text", "image"],
output: ["image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
} satisfies ImagesModel<"openrouter-images">,
},
} as const satisfies Record<string, Record<string, ImagesModel<ImagesApi>>>;

View File

@@ -1,5 +1,5 @@
import { IMAGE_MODELS } from "./image-models.generated.js";
import type { ImagesApi, ImagesModel, KnownImagesProvider } from "./types.js";
import { IMAGE_MODELS } from "./image-models.generated.ts";
import type { ImagesApi, ImagesModel, KnownImagesProvider } from "./types.ts";
const imageModelRegistry: Map<string, Map<string, ImagesModel<ImagesApi>>> = new Map();

View File

@@ -1,4 +1,4 @@
import type { AssistantImages, ImagesApi, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "./types.js";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "./types.ts";
export type ImagesApiFunction = (
model: ImagesModel<ImagesApi>,
@@ -51,3 +51,7 @@ export function registerImagesApiProvider<TApi extends ImagesApi, TOptions exten
export function getImagesApiProvider(api: ImagesApi): ImagesApiProviderInternal | undefined {
return imagesApiProviderRegistry.get(api)?.provider;
}
export function clearImagesApiProviders(): void {
imagesApiProviderRegistry.clear();
}

View File

@@ -1,7 +1,5 @@
import "./providers/images/register-builtins.js";
import { getImagesApiProvider } from "./images-api-registry.js";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ProviderImagesOptions } from "./types.js";
import { getImagesApiProvider } from "./images-api-registry.ts";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ProviderImagesOptions } from "./types.ts";
function resolveImagesApiProvider(api: ImagesApi) {
const provider = getImagesApiProvider(api);

View File

@@ -1,46 +1,6 @@
export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
import { registerBuiltInImagesApiProviders } from "./providers/register-builtins.ts";
export * from "./api-registry.js";
export * from "./env-api-keys.js";
export * from "./image-models.js";
export * from "./images.js";
export * from "./images-api-registry.js";
export * from "./models.js";
export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.js";
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.js";
export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.js";
export * from "./providers/faux.js";
export type { GoogleOptions } from "./providers/google.js";
export type { GoogleThinkingLevel } from "./providers/google-shared.js";
export type { GoogleVertexOptions } from "./providers/google-vertex.js";
export * from "./providers/images/register-builtins.js";
export type { MistralOptions } from "./providers/mistral.js";
export type {
OpenAICodexResponsesOptions,
OpenAICodexWebSocketDebugStats,
} from "./providers/openai-codex-responses.js";
export type { OpenAICompletionsOptions } from "./providers/openai-completions.js";
export type { OpenAIResponsesOptions } from "./providers/openai-responses.js";
export * from "./providers/register-builtins.js";
export * from "./session-resources.js";
export * from "./stream.js";
export * from "./types.js";
export * from "./utils/diagnostics.js";
export * from "./utils/event-stream.js";
export * from "./utils/json-parse.js";
export type {
OAuthAuthInfo,
OAuthCredentials,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProvider,
OAuthProviderId,
OAuthProviderInfo,
OAuthProviderInterface,
OAuthSelectOption,
OAuthSelectPrompt,
} from "./utils/oauth/types.js";
export * from "./utils/overflow.js";
export * from "./utils/typebox-helpers.js";
export * from "./utils/validation.js";
export * from "./base.ts";
export * from "./providers/register-builtins.ts";
registerBuiltInImagesApiProviders();

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { MODELS } from "./models.generated.js";
import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./types.js";
import { MODELS } from "./models.generated.ts";
import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./types.ts";
const modelRegistry: Map<string, Map<string, Model<Api>>> = new Map();
@@ -37,10 +37,13 @@ export function getModels<TProvider extends KnownProvider>(
}
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
// Anthropic charges 2x base input for 1h cache writes.
const longWrite = usage.cacheWrite1h ?? 0;
const shortWrite = usage.cacheWrite - longWrite;
usage.cost.input = (model.cost.input / 1000000) * usage.input;
usage.cost.output = (model.cost.output / 1000000) * usage.output;
usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead;
usage.cost.cacheWrite = (model.cost.cacheWrite / 1000000) * usage.cacheWrite;
usage.cost.cacheWrite = (model.cost.cacheWrite * shortWrite + model.cost.input * 2 * longWrite) / 1000000;
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
return usage.cost;
}

View File

@@ -1 +1 @@
export * from "./utils/oauth/index.js";
export * from "./utils/oauth/index.ts";

View File

@@ -1,3 +1,4 @@
import type { Agent as HttpsAgent } from "node:https";
import {
BedrockRuntimeClient,
type BedrockRuntimeClientConfig,
@@ -18,16 +19,23 @@ import {
type SystemContentBlock,
type ToolChoice,
type ToolConfiguration,
type ToolResultContentBlock,
ToolResultStatus,
} from "@aws-sdk/client-bedrock-runtime";
import type { DocumentType } from "@smithy/types";
import { calculateCost } from "../models.js";
import { NodeHttpHandler } from "@smithy/node-http-handler";
import type { BuildMiddleware, DocumentType, MetadataBearer } from "@smithy/types";
import { HttpProxyAgent } from "http-proxy-agent";
import { HttpsProxyAgent } from "https-proxy-agent";
import { registerApiProvider } from "../api-registry.ts";
import { calculateCost } from "../models.ts";
import type {
Api,
AssistantMessage,
CacheRetention,
Context,
ImageContent,
Model,
ProviderEnv,
SimpleStreamOptions,
StopReason,
StreamFunction,
@@ -39,12 +47,14 @@ import type {
Tool,
ToolCall,
ToolResultMessage,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.js";
import { transformMessages } from "./transform-messages.js";
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.ts";
import { transformMessages } from "./transform-messages.ts";
export type BedrockThinkingDisplay = "summarized" | "omitted";
@@ -64,7 +74,7 @@ export interface BedrockOptions extends StreamOptions {
* - "omitted": Thinking content is redacted but the signature still travels back
* for multi-turn continuity, reducing time-to-first-text-token.
*
* Note: Anthropic's API default for Claude Opus 4.7 and Mythos Preview is
* Note: Anthropic's API default for Claude Opus 4.8 and Mythos Preview is
* "omitted". We default to "summarized" here to keep behavior consistent with
* older Claude 4 models. Only applies to Claude models on Bedrock.
*/
@@ -84,6 +94,8 @@ export interface BedrockOptions extends StreamOptions {
type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string };
const EMPTY_TEXT_PLACEHOLDER = "<empty>";
export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = (
model: Model<"bedrock-converse-stream">,
context: Context,
@@ -113,18 +125,18 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
const blocks = output.content as Block[];
const config: BedrockRuntimeClientConfig = {
profile: options.profile,
profile: options.profile || getProviderEnvValue("AWS_PROFILE", options.env),
};
const configuredRegion = getConfiguredBedrockRegion(options);
const hasConfiguredProfile = hasConfiguredBedrockProfile();
const hasAmbientConfiguredProfile = Boolean(getProviderEnvValue("AWS_PROFILE"));
const endpointRegion = getStandardBedrockEndpointRegion(model.baseUrl);
const useExplicitEndpoint = shouldUseExplicitBedrockEndpoint(
model.baseUrl,
configuredRegion,
hasConfiguredProfile,
hasAmbientConfiguredProfile,
);
// Only pin standard AWS Bedrock runtime endpoints when no region/profile is configured.
// Only pin standard AWS Bedrock runtime endpoints when no region or ambient AWS_PROFILE is configured.
// This preserves custom endpoints (VPC/proxy) from #3402 without forcing built-in
// catalog defaults such as us-east-1 to override AWS_REGION/AWS_PROFILE.
if (useExplicitEndpoint) {
@@ -132,54 +144,52 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
}
// Resolve bearer token for Bedrock API key auth.
const bearerToken = options.bearerToken || process.env.AWS_BEARER_TOKEN_BEDROCK || undefined;
const useBearerToken = bearerToken !== undefined && process.env.AWS_BEDROCK_SKIP_AUTH !== "1";
const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1";
const bearerToken =
options.bearerToken || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || undefined;
const useBearerToken = bearerToken !== undefined && !skipAuth;
// in Node.js/Bun environment only
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
// Region resolution: explicit option > env vars > SDK default chain.
// When AWS_PROFILE is set, we leave region undefined so the SDK can
// resovle it from aws profile configs. Otherwise fall back to us-east-1.
if (configuredRegion) {
// Region resolution: ARN-embedded > explicit option > env vars > SDK default chain.
// When the model ID is an inference profile ARN, extract the region from it.
// This avoids conflicts with AWS_REGION set for other services.
const arnRegionMatch = model.id.match(/^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/);
if (arnRegionMatch) {
config.region = arnRegionMatch[1];
} else if (configuredRegion) {
config.region = configuredRegion;
} else if (endpointRegion && useExplicitEndpoint) {
config.region = endpointRegion;
} else if (!hasConfiguredProfile) {
} else if (!hasAmbientConfiguredProfile) {
config.region = "us-east-1";
}
// Support proxies that don't need authentication
if (process.env.AWS_BEDROCK_SKIP_AUTH === "1") {
if (skipAuth) {
config.credentials = {
accessKeyId: "dummy-access-key",
secretAccessKey: "dummy-secret-key",
};
}
if (
process.env.HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.NO_PROXY ||
process.env.http_proxy ||
process.env.https_proxy ||
process.env.no_proxy
) {
const nodeHttpHandler = await import("@smithy/node-http-handler");
const proxyAgent = await import("proxy-agent");
const agent = new proxyAgent.ProxyAgent();
const credentials = getConfiguredBedrockCredentials(options.env);
if (!skipAuth && credentials) {
config.credentials = credentials;
}
const proxyUrl = resolveHttpProxyUrlForTarget(model.baseUrl, options.env);
if (proxyUrl) {
// Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based
// on `http2` module and has no support for http agent.
// Use NodeHttpHandler to support http agent.
config.requestHandler = new nodeHttpHandler.NodeHttpHandler({
httpAgent: agent,
httpsAgent: agent,
// Use NodeHttpHandler to support HTTP(S) proxy agents.
config.requestHandler = new NodeHttpHandler({
httpAgent: new HttpProxyAgent(proxyUrl),
httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent,
});
} else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") {
} else if (getProviderEnvValue("AWS_BEDROCK_FORCE_HTTP1", options.env) === "1") {
// Some custom endpoints require HTTP/1.1 instead of HTTP/2
const nodeHttpHandler = await import("@smithy/node-http-handler");
config.requestHandler = new nodeHttpHandler.NodeHttpHandler();
config.requestHandler = new NodeHttpHandler();
}
} else {
// Non-Node environment (browser): fall back to us-east-1 since
@@ -195,13 +205,17 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
try {
const client = new BedrockRuntimeClient(config);
const cacheRetention = resolveCacheRetention(options.cacheRetention);
if (options.headers && Object.keys(options.headers).length > 0) {
addCustomHeadersMiddleware(client, options.headers);
}
const cacheRetention = resolveCacheRetention(options.cacheRetention, options.env);
const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined);
let commandInput = {
modelId: model.id,
messages: convertMessages(context, model, cacheRetention),
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention),
messages: convertMessages(context, model, cacheRetention, options.env),
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention, options.env),
inferenceConfig: {
...(options.maxTokens !== undefined && { maxTokens: options.maxTokens }),
...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }),
...(options.temperature !== undefined && { temperature: options.temperature }),
},
toolConfig: convertToolConfig(context.tools, options.toolChoice),
@@ -292,6 +306,13 @@ const BEDROCK_ERROR_PREFIXES: Record<string, string> = {
ServiceUnavailableException: "Service unavailable",
};
/**
* Some models reject the account/profile's configured Bedrock data retention mode
* (e.g. "data retention mode 'default' is not available for this model"). Point
* users at the AWS docs explaining how to configure a supported mode.
*/
const BEDROCK_DATA_RETENTION_DOCS_URL = "https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html";
/**
* Format a Bedrock error with a human-readable prefix.
* AWS SDK exceptions (both from `client.send()` and from stream event items)
@@ -301,11 +322,50 @@ const BEDROCK_ERROR_PREFIXES: Record<string, string> = {
*/
function formatBedrockError(error: unknown): string {
const message = error instanceof Error ? error.message : JSON.stringify(error);
const dataRetentionHint = /data retention mode/i.test(message)
? ` See ${BEDROCK_DATA_RETENTION_DOCS_URL} for supported data retention modes.`
: "";
if (error instanceof BedrockRuntimeServiceException) {
const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name;
return `${prefix}: ${message}`;
return `${prefix}: ${message}${dataRetentionHint}`;
}
return message;
return `${message}${dataRetentionHint}`;
}
/**
* Header keys that must never be overwritten by caller-supplied headers.
* `host` and `x-amz-*` participate in the SigV4 canonical request; `authorization`
* is owned by SigV4 or the bearer-token path (config.token + authSchemePreference).
* Compared case-insensitively (caller key is lower-cased before lookup).
*/
const RESERVED_HEADER_EXACT = new Set(["authorization", "host"]);
function isReservedHeader(key: string): boolean {
const lower = key.toLowerCase();
return lower.startsWith("x-amz-") || RESERVED_HEADER_EXACT.has(lower);
}
/**
* Attach caller-supplied headers to the outgoing Bedrock request via a Smithy
* `build`-step middleware. The `build` step runs after request serialisation but
* before SigV4 signing, so injected headers are covered by the signature. Reserved
* SigV4 / auth headers (`x-amz-*`, `authorization`, `host`) are silently skipped;
* all other caller headers override any existing same-named header on the request.
*/
function addCustomHeadersMiddleware(client: BedrockRuntimeClient, headers: Record<string, string>): void {
const middleware: BuildMiddleware<object, MetadataBearer> = (next) => async (args) => {
const request = args.request;
if (request && typeof request === "object" && "headers" in request) {
const requestHeaders = (request as { headers: Record<string, string> }).headers;
for (const [key, value] of Object.entries(headers)) {
if (!isReservedHeader(key)) {
requestHeaders[key] = value;
}
}
}
return next(args);
};
client.middlewareStack.add(middleware, { step: "build", name: "pi-ai-custom-headers", priority: "low" });
}
export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions> = (
@@ -327,8 +387,10 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp
} satisfies BedrockOptions);
}
// Undefined means the caller did not request an output cap; let the helper use the model cap.
// Do not coerce to 0 here, or the thinking budget would become the entire maxTokens value.
const adjusted = adjustMaxTokensForThinking(
base.maxTokens || 0,
base.maxTokens,
model.maxTokens,
options.reasoning,
options.thinkingBudgets,
@@ -352,6 +414,14 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp
} satisfies BedrockOptions);
};
export function register(): void {
registerApiProvider({
api: "bedrock-converse-stream",
stream: streamBedrock,
streamSimple: streamSimpleBedrock,
});
}
function handleContentBlockStart(
event: ContentBlockStartEvent,
blocks: Block[],
@@ -491,12 +561,19 @@ function getModelMatchCandidates(modelId: string, modelName?: string): string[]
function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean {
const candidates = getModelMatchCandidates(modelId, modelName);
return candidates.some((s) => s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("sonnet-4-6"));
return candidates.some(
(s) =>
s.includes("opus-4-6") ||
s.includes("opus-4-7") ||
s.includes("opus-4-8") ||
s.includes("sonnet-4-6") ||
s.includes("fable-5"),
);
}
function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean {
const candidates = getModelMatchCandidates(model.id, model.name);
return candidates.some((s) => s.includes("opus-4-7"));
return candidates.some((s) => s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("fable-5"));
}
function mapThinkingLevelToEffort(
@@ -525,11 +602,11 @@ function mapThinkingLevelToEffort(
* Resolve cache retention preference.
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
*/
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
if (cacheRetention) {
return cacheRetention;
}
if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") {
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
return "long";
}
return "short";
@@ -564,14 +641,14 @@ function isAnthropicClaudeModel(model: Model<"bedrock-converse-stream">): boolea
* As a last resort, set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points.
* Amazon Nova models have automatic caching and don't need explicit cache points.
*/
function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean {
function supportsPromptCaching(model: Model<"bedrock-converse-stream">, env?: ProviderEnv): boolean {
const candidates = getModelMatchCandidates(model.id, model.name);
const hasClaudeRef = candidates.some((s) => s.includes("claude"));
if (!hasClaudeRef) {
// Application inference profiles don't contain the model name in the ARN.
// Allow users to force cache points via environment variable.
if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true;
if (getProviderEnvValue("AWS_BEDROCK_FORCE_CACHE", env) === "1") return true;
return false;
}
// Claude 4.x models (opus-4, sonnet-4, haiku-4)
@@ -599,13 +676,14 @@ function buildSystemPrompt(
systemPrompt: string | undefined,
model: Model<"bedrock-converse-stream">,
cacheRetention: CacheRetention,
env?: ProviderEnv,
): SystemContentBlock[] | undefined {
if (!systemPrompt) return undefined;
const blocks: SystemContentBlock[] = [{ text: sanitizeSurrogates(systemPrompt) }];
// Add cache point for supported Claude models when caching is enabled
if (cacheRetention !== "none" && supportsPromptCaching(model)) {
if (cacheRetention !== "none" && supportsPromptCaching(model, env)) {
blocks.push({
cachePoint: { type: CachePointType.DEFAULT, ...(cacheRetention === "long" ? { ttl: CacheTTL.ONE_HOUR } : {}) },
});
@@ -619,10 +697,34 @@ function normalizeToolCallId(id: string): string {
return sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
}
function createNonBlankTextBlock(text: string): ContentBlock.TextMember | undefined {
const sanitized = sanitizeSurrogates(text);
return sanitized.trim().length === 0 ? undefined : { text: sanitized };
}
function createRequiredTextBlock(text: string): ContentBlock.TextMember {
return createNonBlankTextBlock(text) ?? { text: EMPTY_TEXT_PLACEHOLDER };
}
function convertToolResultContent(content: (TextContent | ImageContent)[]): ToolResultContentBlock[] {
const result: ToolResultContentBlock[] = [];
for (const c of content) {
if (c.type === "image") {
result.push({ image: createImageBlock(c.mimeType, c.data) });
} else {
const textBlock = createNonBlankTextBlock(c.text);
if (textBlock) result.push(textBlock);
}
}
if (result.length === 0) result.push({ text: EMPTY_TEXT_PLACEHOLDER });
return result;
}
function convertMessages(
context: Context,
model: Model<"bedrock-converse-stream">,
cacheRetention: CacheRetention,
env?: ProviderEnv,
): Message[] {
const result: Message[] = [];
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
@@ -631,24 +733,33 @@ function convertMessages(
const m = transformedMessages[i];
switch (m.role) {
case "user":
case "user": {
const content: ContentBlock[] = [];
if (typeof m.content === "string") {
content.push(createRequiredTextBlock(m.content));
} else {
for (const c of m.content) {
switch (c.type) {
case "text": {
const textBlock = createNonBlankTextBlock(c.text);
if (textBlock) content.push(textBlock);
break;
}
case "image":
content.push({ image: createImageBlock(c.mimeType, c.data) });
break;
default:
continue;
}
}
if (content.length === 0) content.push({ text: EMPTY_TEXT_PLACEHOLDER });
}
result.push({
role: ConversationRole.USER,
content:
typeof m.content === "string"
? [{ text: sanitizeSurrogates(m.content) }]
: m.content.map((c) => {
switch (c.type) {
case "text":
return { text: sanitizeSurrogates(c.text) };
case "image":
return { image: createImageBlock(c.mimeType, c.data) };
default:
throw new Error("Unknown user content type");
}
}),
content,
});
break;
}
case "assistant": {
// Skip assistant messages with empty content (e.g., from aborted requests)
// Bedrock rejects messages with empty content arrays
@@ -658,19 +769,22 @@ function convertMessages(
const contentBlocks: ContentBlock[] = [];
for (const c of m.content) {
switch (c.type) {
case "text":
case "text": {
// Skip empty text blocks
if (c.text.trim().length === 0) continue;
contentBlocks.push({ text: sanitizeSurrogates(c.text) });
const textBlock = createNonBlankTextBlock(c.text);
if (!textBlock) continue;
contentBlocks.push(textBlock);
break;
}
case "toolCall":
contentBlocks.push({
toolUse: { toolUseId: c.id, name: c.name, input: c.arguments },
});
break;
case "thinking":
case "thinking": {
// Skip empty thinking blocks
if (c.thinking.trim().length === 0) continue;
const thinking = sanitizeSurrogates(c.thinking);
if (thinking.trim().length === 0) continue;
// Only Anthropic models support the signature field in reasoningText.
// For other models, we omit the signature to avoid errors like:
// "This model doesn't support the reasoningContent.reasoningText.signature field"
@@ -679,12 +793,12 @@ function convertMessages(
// persisted message lacks a signature, Bedrock rejects the replayed
// reasoning block. Fall back to plain text, matching Anthropic.
if (!c.thinkingSignature || c.thinkingSignature.trim().length === 0) {
contentBlocks.push({ text: sanitizeSurrogates(c.thinking) });
contentBlocks.push({ text: thinking });
} else {
contentBlocks.push({
reasoningContent: {
reasoningText: {
text: sanitizeSurrogates(c.thinking),
text: thinking,
signature: c.thinkingSignature,
},
},
@@ -693,13 +807,14 @@ function convertMessages(
} else {
contentBlocks.push({
reasoningContent: {
reasoningText: { text: sanitizeSurrogates(c.thinking) },
reasoningText: { text: thinking },
},
});
}
break;
}
default:
throw new Error("Unknown assistant content type");
continue;
}
}
// Skip if all content blocks were filtered out
@@ -721,11 +836,7 @@ function convertMessages(
toolResults.push({
toolResult: {
toolUseId: m.toolCallId,
content: m.content.map((c) =>
c.type === "image"
? { image: createImageBlock(c.mimeType, c.data) }
: { text: sanitizeSurrogates(c.text) },
),
content: convertToolResultContent(m.content),
status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
},
});
@@ -737,11 +848,7 @@ function convertMessages(
toolResults.push({
toolResult: {
toolUseId: nextMsg.toolCallId,
content: nextMsg.content.map((c) =>
c.type === "image"
? { image: createImageBlock(c.mimeType, c.data) }
: { text: sanitizeSurrogates(c.text) },
),
content: convertToolResultContent(nextMsg.content),
status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
},
});
@@ -758,12 +865,12 @@ function convertMessages(
break;
}
default:
throw new Error("Unknown message role");
continue;
}
}
// Add cache point to the last user message for supported Claude models when caching is enabled
if (cacheRetention !== "none" && supportsPromptCaching(model) && result.length > 0) {
if (cacheRetention !== "none" && supportsPromptCaching(model, env) && result.length > 0) {
const lastMessage = result[result.length - 1];
if (lastMessage.role === ConversationRole.USER && lastMessage.content) {
(lastMessage.content as ContentBlock[]).push({
@@ -825,19 +932,26 @@ function mapStopReason(reason: string | undefined): StopReason {
}
function getConfiguredBedrockRegion(options: BedrockOptions): string | undefined {
if (typeof process === "undefined") {
return options.region;
}
return options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || undefined;
return (
options.region ||
getProviderEnvValue("AWS_REGION", options.env) ||
getProviderEnvValue("AWS_DEFAULT_REGION", options.env) ||
undefined
);
}
function hasConfiguredBedrockProfile(): boolean {
if (typeof process === "undefined") {
return false;
function getConfiguredBedrockCredentials(env?: ProviderEnv): BedrockRuntimeClientConfig["credentials"] | undefined {
const accessKeyId = getProviderEnvValue("AWS_ACCESS_KEY_ID", env);
const secretAccessKey = getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env);
if (!accessKeyId || !secretAccessKey) {
return undefined;
}
return Boolean(process.env.AWS_PROFILE);
const sessionToken = getProviderEnvValue("AWS_SESSION_TOKEN", env);
return {
accessKeyId,
secretAccessKey,
...(sessionToken ? { sessionToken } : {}),
};
}
function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string | undefined {
@@ -857,14 +971,14 @@ function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string |
function shouldUseExplicitBedrockEndpoint(
baseUrl: string,
configuredRegion: string | undefined,
hasConfiguredProfile: boolean,
hasAmbientConfiguredProfile: boolean,
): boolean {
const endpointRegion = getStandardBedrockEndpointRegion(baseUrl);
if (!endpointRegion) {
return true;
}
return !configuredRegion && !hasConfiguredProfile;
return !configuredRegion && !hasAmbientConfiguredProfile;
}
function isGovCloudBedrockTarget(model: Model<"bedrock-converse-stream">, options: BedrockOptions): boolean {

Some files were not shown because too many files have changed in this diff Show More