@andares/pi-toolkits 0.9.5 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -11
- package/README.zh-CN.md +17 -9
- package/package.json +6 -2
- package/src/features/ask/constants.ts +12 -0
- package/src/features/ask/index.test.ts +158 -15
- package/src/features/ask/index.ts +151 -27
- package/src/features/ask/prompt.test.ts +34 -22
- package/src/features/ask/prompt.ts +26 -39
- package/src/features/ask/reasons.test.ts +42 -3
- package/src/features/ask/reasons.ts +68 -23
- package/src/features/ask/scratch-tool.test.ts +161 -0
- package/src/features/ask/scratch-tool.ts +195 -0
- package/src/features/ask/scratch.test.ts +172 -0
- package/src/features/ask/scratch.ts +324 -0
- package/src/features/permission/index.test.ts +27 -3
- package/src/features/permission/index.ts +6 -0
- package/src/lib/tools.ts +48 -0
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
> Integrated toolkit extension for [pi](https://github.com/earendil-works/pi-coding-agent) — one package, growing new capabilities over time.
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/@andares/pi-toolkits)
|
|
10
|
+
[](https://pi.dev/packages/@andares/pi-toolkits)
|
|
10
11
|
[](LICENSE)
|
|
11
12
|
[](https://pnpm.io)
|
|
12
13
|
[](https://github.com/earendil-works/pi)
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
|
|
15
16
|
In a nutshell: seven ready-to-use capabilities — **read-only consult mode** `ask`, **session-scoped boundary permission** `ask-perm`, **model favorites** `favorites`, **prompt stash** `stash`, **third-party code review** `third-review`, **dedicated compaction model** `compact-model` and **session startup automation** `autostart` — with more to come.
|
|
16
17
|
|
|
17
|
-
**Requirements**: pi `>=1.0.0 <1.12.0` — the four most recently released minor lines (1.0.x / 1.1.x / 1.2.x / 1.3.x) are supported best-effort, and the upper bound encodes the forward probe: on every host release we check up to **8 minor lines** beyond the newest one (now 1.4 … 1.11) and never claim past that cap. **Measured natural compatibility: 1.3.0** (the newest release at the time of writing — nothing newer exists to probe yet). A single command, `pnpm check:compat`, derives that version set from the registry and runs, per version, in a sandbox under the system temp dir with the whole `@earendil-works/*` family pinned to the version under test: install → `tsc --noEmit` → full test suite → keybinding audit → hash-compare of the host `dist` files this package patches or mirrors. Results over 1.0.0 → 1.3.0 (all 8 published versions): typecheck,
|
|
18
|
+
**Requirements**: pi `>=1.0.0 <1.12.0` — the four most recently released minor lines (1.0.x / 1.1.x / 1.2.x / 1.3.x) are supported best-effort, and the upper bound encodes the forward probe: on every host release we check up to **8 minor lines** beyond the newest one (now 1.4 … 1.11) and never claim past that cap. **Measured natural compatibility: 1.3.0** (the newest release at the time of writing — nothing newer exists to probe yet). A single command, `pnpm check:compat`, derives that version set from the registry and runs, per version, in a sandbox under the system temp dir with the whole `@earendil-works/*` family pinned to the version under test: install → `tsc --noEmit` → full test suite → keybinding audit → hash-compare of the host `dist` files this package patches or mirrors. Results over 1.0.0 → 1.3.0 (all 8 published versions): typecheck, 473 tests and the keybinding audit pass on every one; the tracked host files drift only in `core/agent-session.js` (1.3.0 changed the tool-restore path; `cycleModel` untouched), `core/keybindings.js` and `core/compaction/utils.js` (both changed in 1.2.0, table semantics for our keys unchanged), and `extensions/types.d.ts` (additive) — `ModelSelectorComponent`, `core/compaction/compaction.js` and `core/usage-totals.js` are byte-identical across the whole window. Smoke-loading on real pi 1.3.0 produces no warnings. Note: pi ≥ 0.99 hosts require host-provided packages to be declared in `peerDependencies` with a `"*"` range — `@earendil-works/pi-tui` and `typebox` (the `scratchpad` tool builds a TypeBox schema) are declared that way; an installed copy in `dependencies` would make pi warn.
|
|
18
19
|
|
|
19
20
|
---
|
|
20
21
|
|
|
@@ -23,7 +24,8 @@ In a nutshell: seven ready-to-use capabilities — **read-only consult mode** `a
|
|
|
23
24
|
<a name="install"></a>
|
|
24
25
|
|
|
25
26
|
```bash
|
|
26
|
-
# from npm (recommended)
|
|
27
|
+
# from npm (recommended) — also indexed in the official pi package gallery
|
|
28
|
+
# https://pi.dev/packages/@andares/pi-toolkits
|
|
27
29
|
pi install npm:@andares/pi-toolkits
|
|
28
30
|
|
|
29
31
|
# local development install
|
|
@@ -35,7 +37,7 @@ pi -e ./src/index.ts
|
|
|
35
37
|
|
|
36
38
|
After installing, verify in any pi session:
|
|
37
39
|
|
|
38
|
-
- `/ask` → a grey `ask` status appears in the footer, `write`/`edit` are hard-disabled
|
|
40
|
+
- `/ask` → a grey `ask` status appears in the footer, `write`/`edit` are hard-disabled, and a `scratchpad` tool appears for throwaway notes
|
|
39
41
|
- `/model` (or `ctrl+l`) → a favorites hint line appears at the top of the selector; `ctrl+A` on the selected model → it turns bold bright yellow and the favorites counter goes up (this step is also the real-machine keypress check — see Keybindings)
|
|
40
42
|
- press `ctrl+alt+y` in the input box → the current prompt is stashed and the input box is cleared
|
|
41
43
|
- after finishing a development task, type `/third-review` → switches to the review model and runs check + fix
|
|
@@ -66,7 +68,7 @@ After installing, verify in any pi session:
|
|
|
66
68
|
|
|
67
69
|
| Feature | Entry point | One-line description |
|
|
68
70
|
| --- | --- | --- |
|
|
69
|
-
| 🛡️ **ask** read-only consult mode | `/ask` | enter
|
|
71
|
+
| 🛡️ **ask** read-only consult mode | `/ask` | enter consult mode: `write`/`edit`/`powershell` hard-disabled, `bash` runs in a read-only sandbox, `grep`/`find`/`ls` activated, and `scratchpad` allowed to write throwaway notes into the session's temp directory |
|
|
70
72
|
| 🔐 **permission** session-scoped boundary checks | `/ask-perm` | four in/out × read/write modes, off by default and never persisted; outside writes ask, credentials are denied, destructive commands always ask; composes with `/ask` |
|
|
71
73
|
| ⭐ **favorites** model favorites | `ctrl+A` / `ctrl+J` inside `/model` | favorite the models you use most (marked in bold bright yellow), `ctrl+p` cycles only among favorites |
|
|
72
74
|
| 📥 **stash** prompt stash | `ctrl+alt+y` | a stash slot for one prompt: press to swap, press twice to stash and clear the input box |
|
|
@@ -80,17 +82,21 @@ After installing, verify in any pi session:
|
|
|
80
82
|
|
|
81
83
|
<a name="ask"></a>
|
|
82
84
|
|
|
83
|
-
Enter with `/ask` (press it again to exit). Meant for the case where you only want to "ask questions and read code"
|
|
85
|
+
Enter with `/ask` (press it again to exit). Meant for the case where you only want to "ask questions and read code": the model may not change the project, but it is not starved of a place to think.
|
|
84
86
|
|
|
85
87
|
**While active**:
|
|
86
88
|
|
|
87
|
-
-
|
|
89
|
+
- **Project writes stay mechanically impossible** — `write` / `edit` / `powershell` are removed from the active tool set, so pi rejects calls to inactive tools outright ("Tool not found"); the gate blocks them again as defence in depth
|
|
90
|
+
- **One write channel: `scratchpad`** — a tool that takes a *relative* note name (`plan.md`, `notes/api.md`) and can only reach `<os.tmpdir()>/pi-ask/<session-id>`. It is registered inactive (`defaultActive: false`) and activated only in ask mode, so its declaration and its "notes are not deliverables" guidelines are part of the request exactly when they apply. Notes are capped at 256 KiB each, a pre-existing symlinked path component is refused, and nothing written there is ever executed — the bash sandbox needs no write allowance at all. On a host with no writable temp dir the tool stays inactive and the mode degrades to the old fully read-only behaviour (the entry notice says so instead of half-opening the mode)
|
|
88
91
|
- **Read-only helper tools activated** — `grep` / `find` / `ls` (registered by pi but inactive by default) are switched on for the duration, so the model gets proper search tools instead of bash-only search; the entry snapshot is restored exactly on exit
|
|
89
92
|
- **`bash` kept, but sandboxed to read-only** — shell tokenization, per-segment validation plus write-intent detection. Allowed beyond the obvious readers: git archaeology (`blame`, `grep`, `rev-parse`, `describe`, `shortlog`, `reflog show`, `stash list`, `tag -l`, `cat-file`, ...), package metadata queries (`pnpm view`, `pip show/list`, `python -m json.tool`) and network reads through `curl -s <url>` / `wget -O - <url>`. Still blocked: redirections, `tee`, `dd`, `sed -i`, installs/builds, git mutations, command substitution, command runners (`find -exec`, `git grep -O`, `rg --pre`), and every write target (`curl -o`, `wget -O file`, `git diff --output=`, `tree -o`). Heuristic, not a security boundary
|
|
90
|
-
- **
|
|
91
|
-
- **
|
|
93
|
+
- **A short system-prompt banner** — three lines: consult only, the scratch channel, the exit path. The bash allowlist is deliberately *not* restated there: the sandbox enforces it and a rejection names the allowed alternative, so that detail costs tokens only on the turn it is needed (the old banner restated it on every request, ~300 tokens). The banner carries no path and no timestamp, so it stays byte-identical for the session and remains a cacheable prefix
|
|
94
|
+
- **Rejections point at the next step** — a blocked `write` / `edit` names `scratchpad` for throwaway notes and `/ask` for a real change; a blocked redirect says the same instead of only "cannot write"
|
|
95
|
+
- **Grey `ask` footer status** — a permanent reminder that consult mode is on
|
|
92
96
|
|
|
93
|
-
**
|
|
97
|
+
**Scratch lifecycle**: one directory per session under the OS temp dir (`os.tmpdir()`, so Windows `%TEMP%` works too), created on entry and kept on exit — a resumed session finds its notes again, and an in-process session switch (/new, /resume, fork) rebinds the directory to the new session id. Directories older than seven days are removed when a session enters ask mode. The ON notice prints the path; nothing in it is part of the project and it never shows up in `git status`.
|
|
98
|
+
|
|
99
|
+
**Exit**: press `/ask` again; the tool-set snapshot taken on entry is fully restored (scratch tool and read-only helpers included).
|
|
94
100
|
|
|
95
101
|
---
|
|
96
102
|
|
|
@@ -131,7 +137,7 @@ Repeating the argument form with the modes already in force is a **no-op**: no s
|
|
|
131
137
|
- **credentials are denied in every mode** — `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.pi/agent/auth*.json`, `.env` files (templates excepted). Without this, `out read = allow` would hand out `~/.ssh/id_rsa`.
|
|
132
138
|
- **destructive commands always ask** — `rm` of `/`, the home directory, or the working directory; `git reset --hard`; `git clean -f`; `git push --force`; package `publish`; `dd` to a raw device; `mkfs*`; shutdown/reboot; `chmod -R 777`; anything through `sudo`. Scope alone cannot express "destructive".
|
|
133
139
|
|
|
134
|
-
**Composition with `/ask`**: the two features are independent gates on one bus. Each answers only its own question — ask: "does this mutate?", permission: "does this cross the boundary?" — and either can block. Ask mode still denies credential reads, and permission still applies while the read-only sandbox is active. Neither feature needs to know the other exists.
|
|
140
|
+
**Composition with `/ask`**: the two features are independent gates on one bus. Each answers only its own question — ask: "does this mutate?", permission: "does this cross the boundary?" — and either can block. Ask mode still denies credential reads, and permission still applies while the read-only sandbox is active. Neither feature needs to know the other exists. The one exception is ask's `scratchpad`: that tool is self-jailed (relative names only, one temp directory), so ask marks it while active and permission skips the boundary dialog that would otherwise fire for an "outside write" on every note.
|
|
135
141
|
|
|
136
142
|
**Enable it for every session** (the state itself is never persisted — this is the opt-in):
|
|
137
143
|
|
|
@@ -355,10 +361,12 @@ pnpm check:compat # verify the declared pi range: last 4 minor lines + 8-
|
|
|
355
361
|
src/
|
|
356
362
|
├── index.ts # entry point: aggregates feature modules
|
|
357
363
|
├── lib/ # shared infrastructure (tool-set snapshot/restore,
|
|
358
|
-
│ # gate bus composing tool_call policies,
|
|
364
|
+
│ # gate bus composing tool_call policies, jailed-tool
|
|
365
|
+
│ # registry the boundary gate consults,
|
|
359
366
|
│ # model-key resolution shared by review & compact)
|
|
360
367
|
└── features/ # feature modules — one directory per feature
|
|
361
368
|
├── ask/ # ask mode: command + state machine, bash sandbox,
|
|
369
|
+
│ # scratch notes (temp dir + scratchpad tool),
|
|
362
370
|
│ # system prompt banner, tests
|
|
363
371
|
├── autostart/ # session startup automation: autostart.toml parsing,
|
|
364
372
|
│ # command pre-validation + replay, tests
|
package/README.zh-CN.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
> pi 编码代理的集成扩展工具包 — 一个包,持续生长新能力。
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/@andares/pi-toolkits)
|
|
10
|
+
[](https://pi.dev/packages/@andares/pi-toolkits)
|
|
10
11
|
[](LICENSE)
|
|
11
12
|
[](https://pnpm.io)
|
|
12
13
|
[](https://github.com/earendil-works/pi)
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
|
|
15
16
|
一句话介绍:目前包含七个开箱即用的功能——**只读咨询模式** `ask`、**会话级边界权限** `ask-perm`、**模型收藏** `favorites`、**提示词暂存** `stash`、**第三方代码评审** `third-review`、**独立压缩模型** `compact-model`、**会话启动自动化** `autostart`,后续持续追加。
|
|
16
17
|
|
|
17
|
-
**版本要求**:pi `>=1.0.0 <1.12.0` —— 最近 4 个中版本线(1.0.x / 1.1.x / 1.2.x / 1.3.x)尽力支持,上界编码「向后探针」的硬上限:宿主每发一版,我们最多向后查 **8 条中版本线**(当前 1.4 … 1.11),再晚不宣称。**实测自然兼容最高 1.3.0**(写此文时的最新版本,更新的还没发布、无物可探)。一条命令 `pnpm check:compat` 从 registry 推导目标集合,逐版本在系统临时目录沙箱中(把整个 `@earendil-works/*` 家族钉在待测版本)跑:安装 → `tsc --noEmit` → 全量测试 → 键位审计 → 对 patch/mirror 的宿主 `dist` 文件做哈希比对。1.0.0 → 1.3.0 全部 8 个已发布版本:typecheck、
|
|
18
|
+
**版本要求**:pi `>=1.0.0 <1.12.0` —— 最近 4 个中版本线(1.0.x / 1.1.x / 1.2.x / 1.3.x)尽力支持,上界编码「向后探针」的硬上限:宿主每发一版,我们最多向后查 **8 条中版本线**(当前 1.4 … 1.11),再晚不宣称。**实测自然兼容最高 1.3.0**(写此文时的最新版本,更新的还没发布、无物可探)。一条命令 `pnpm check:compat` 从 registry 推导目标集合,逐版本在系统临时目录沙箱中(把整个 `@earendil-works/*` 家族钉在待测版本)跑:安装 → `tsc --noEmit` → 全量测试 → 键位审计 → 对 patch/mirror 的宿主 `dist` 文件做哈希比对。1.0.0 → 1.3.0 全部 8 个已发布版本:typecheck、473 个测试、键位审计全绿;被跟踪的宿主文件仅在 `core/agent-session.js`(1.3.0 改了 tool-restore 路径,`cycleModel` 未动)、`core/keybindings.js` 与 `core/compaction/utils.js`(二者都是 1.2.0 变的,我们按键的解析结果不变)、`extensions/types.d.ts`(纯增量)上有差异 —— `ModelSelectorComponent`、`core/compaction/compaction.js` 与 `core/usage-totals.js` 在窗口内字节级不变。真实 pi 1.3.0 冒烟加载无警告。注:pi ≥ 0.99 的宿主要求「宿主提供的包」一律声明为 peerDependencies `"*"` —— `@earendil-works/pi-tui` 与 `typebox`(ask 的 `scratchpad` 用它构造参数 schema)都按此声明;若放进 `dependencies`,pi 会告警。
|
|
18
19
|
|
|
19
20
|
---
|
|
20
21
|
|
|
@@ -23,7 +24,8 @@
|
|
|
23
24
|
<a name="install"></a>
|
|
24
25
|
|
|
25
26
|
```bash
|
|
26
|
-
# 从 npm 安装(推荐)
|
|
27
|
+
# 从 npm 安装(推荐) —— 也已收录进 pi 官方包编目
|
|
28
|
+
# https://pi.dev/packages/@andares/pi-toolkits
|
|
27
29
|
pi install npm:@andares/pi-toolkits
|
|
28
30
|
|
|
29
31
|
# 本地开发安装
|
|
@@ -35,7 +37,7 @@ pi -e ./src/index.ts
|
|
|
35
37
|
|
|
36
38
|
安装后在任意 pi 会话中验证:
|
|
37
39
|
|
|
38
|
-
- `/ask` → footer 出现灰色 `ask` 状态,`write`/`edit`
|
|
40
|
+
- `/ask` → footer 出现灰色 `ask` 状态,`write`/`edit` 被硬禁用,同时出现 `scratchpad` 工具用于草稿笔记
|
|
39
41
|
- `/model`(或 `ctrl+l`)→ 选择器顶部出现收藏提示行;对选中模型按 `ctrl+A` → 文字变**加粗亮黄**、收藏计数 +1(这一步同时就是真机按键检查,见「键位」约定)
|
|
40
42
|
- 输入框内按 `ctrl+alt+y` → 当前提示词被暂存并清空输入框
|
|
41
43
|
- 完成一个开发任务后输入 `/third-review` → 切换到评审模型执行查+修
|
|
@@ -66,7 +68,7 @@ pi -e ./src/index.ts
|
|
|
66
68
|
|
|
67
69
|
| 功能 | 入口 | 一句话说明 |
|
|
68
70
|
| --- | --- | --- |
|
|
69
|
-
| 🛡️ **ask** 只读咨询模式 | `/ask` |
|
|
71
|
+
| 🛡️ **ask** 只读咨询模式 | `/ask` | 进入咨询模式:`write`/`edit`/`powershell` 硬禁用,`bash` 只读沙箱,临时启用 `grep`/`find`/`ls`,并开放 `scratchpad` 把草稿写进会话临时目录 |
|
|
70
72
|
| 🔐 **permission** 会话级边界权限 | `/ask-perm` | 项目内/外 × 读/写四格模式,默认关闭且从不落盘;项目外写询问、凭据路径硬拒、破坏性命令必问;与 `/ask` 并行组合 |
|
|
71
73
|
| ⭐ **favorites** 模型收藏 | `/model` 内 `ctrl+A` / `ctrl+J` | 收藏常用模型(加粗亮黄标记),`ctrl+p` 仅在收藏间循环 |
|
|
72
74
|
| 📥 **stash** 提示词暂存 | `ctrl+alt+y` | 一段提示词的暂存槽,按键交换 / 连按两次暂存并清空输入框 |
|
|
@@ -80,17 +82,21 @@ pi -e ./src/index.ts
|
|
|
80
82
|
|
|
81
83
|
<a name="ask"></a>
|
|
82
84
|
|
|
83
|
-
用 `/ask` 进入(再按一次退出)
|
|
85
|
+
用 `/ask` 进入(再按一次退出)。适合只想要「问问题、读代码」的场景:模型不能改项目,但不会被剥夺思考的落点。
|
|
84
86
|
|
|
85
87
|
**进入后**:
|
|
86
88
|
|
|
87
|
-
-
|
|
89
|
+
- **项目文件写入仍然机制上不可能** —— `write` / `edit` / `powershell` 从活跃工具集中移除,pi 对未激活工具的调用直接拒绝("Tool not found");闸门还会二次拦截,防止有东西把它们重新打开
|
|
90
|
+
- **只留一个写通道:`scratchpad`** —— 参数是**相对**笔记名(`plan.md`、`notes/api.md`),只能落到 `<os.tmpdir()>/pi-ask/<session-id>`。它注册时处于未激活状态(`defaultActive: false`),只在 ask 模式里启用,所以它的声明与「笔记不是交付物」的 guidelines 恰好在适用时才进入请求。单文件上限 256 KiB,路径上已存在的符号链接会被拒绝,里面的东西永不被执行—— bash 沙箱因此完全不需要写权限。宿主没有可写临时目录时工具保持未激活,模式降级为旧的纯只读(进入时的提示会说清楚,不做半开)
|
|
88
91
|
- **临时启用只读检索工具** —— `grep` / `find` / `ls`(宿主已注册但默认不激活)在 ask 期间打开,让模型有正规搜索工具而不是只能走 bash;退出时按进入前快照完整恢复
|
|
89
92
|
- **`bash` 保留但只读沙箱** —— shell 分词 + 逐段校验 + 写盘意图检测。除常见读取命令外,放行 git 考古族(`blame`、`grep`、`rev-parse`、`describe`、`shortlog`、`reflog show`、`stash list`、`tag -l`、`cat-file` 等)、包元数据查询(`pnpm view`、`pip show/list`、`python -m json.tool`),以及网络读取 `curl -s <url>` / `wget -O - <url>`。仍拦截:重定向、`tee`、`dd`、`sed -i`、安装/构建、git 变更类命令、命令替换、会启动其它程序的选项(`find -exec`、`git grep -O`、`rg --pre`),以及一切写盘目标(`curl -o`、`wget -O file`、`git diff --output=`、`tree -o`)。启发式,非安全边界
|
|
90
|
-
-
|
|
91
|
-
-
|
|
93
|
+
- **短横幅而不是清单** —— 系统提示词只追加三行:咨询模式、草稿通道、退出方式。**不再重述 bash 白名单** —— 那是沙箱的职责,被拦时的拒绝理由会直接指出可用的替代,所以细节只在真用到的那一轮付费(旧横幅每个请求都要重申一遍,约 300 token)。横幅不含路径与时间戳,会话内字节稳定,可以稳定命中 provider 的提示词缓存
|
|
94
|
+
- **拒绝理由指向下一步** —— 被拦的 `write` / `edit` 会说明草稿用 `scratchpad`、真正改动要 `/ask` 退出;被拦的重定向也同样指路,而不是只说「不能写」
|
|
95
|
+
- **灰色 `ask` footer 状态** —— 常驻提示当前处于咨询模式
|
|
92
96
|
|
|
93
|
-
|
|
97
|
+
**草稿生命周期**:每个会话一个目录,位于系统临时目录(`os.tmpdir()`,所以 Windows 的 `%TEMP%` 也适用);进入时创建、退出时**不删**——续会话(`pi -c`)还能找回笔记,进程内切换会话(/new、/resume、fork)会重新绑定到新会话的目录;进入时清掉超过 7 天的旧目录。开启提示会打印路径;里面的内容不属于项目,也不会出现在 `git status` 里。
|
|
98
|
+
|
|
99
|
+
**退出**:再按 `/ask`,完整恢复进入前的工具集快照(含 scratch 工具与临时启用的只读工具)。
|
|
94
100
|
|
|
95
101
|
---
|
|
96
102
|
|
|
@@ -356,9 +362,11 @@ src/
|
|
|
356
362
|
├── index.ts # 入口:聚合各功能模块
|
|
357
363
|
├── lib/ # 共享基础设施(工具集快照/恢复、
|
|
358
364
|
│ # 组合 tool_call 策略的闸门总线、
|
|
365
|
+
│ # 边界闸门会查询的自封闭工具登记表、
|
|
359
366
|
│ # review 与 compact 共用的模型 key 解析)
|
|
360
367
|
└── features/ # 功能模块 —— 一个功能一个目录
|
|
361
368
|
├── ask/ # ask 模式:命令 + 状态机、bash 沙箱、
|
|
369
|
+
│ # 草稿空间(临时目录 + scratchpad 工具)、
|
|
362
370
|
│ # 系统提示词横幅、测试
|
|
363
371
|
├── autostart/ # 会话启动自动化:autostart.toml 解析、
|
|
364
372
|
│ # 命令预校验 + 重放、测试
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andares/pi-toolkits",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"description": "Integrated toolkit extension for pi — ask mode (read-only Q&A), session-scoped permission checks, model favorites, prompt stash, third-party code review, session autostart, and a dedicated compaction model",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"src"
|
|
16
16
|
],
|
|
17
17
|
"keywords": [
|
|
18
|
+
"pi-package",
|
|
19
|
+
"pi-coding-agent",
|
|
18
20
|
"pi",
|
|
19
21
|
"pi-extension",
|
|
20
22
|
"ask-mode",
|
|
@@ -32,7 +34,8 @@
|
|
|
32
34
|
],
|
|
33
35
|
"peerDependencies": {
|
|
34
36
|
"@earendil-works/pi-coding-agent": ">=1.0.0 <1.12.0",
|
|
35
|
-
"@earendil-works/pi-tui": "*"
|
|
37
|
+
"@earendil-works/pi-tui": "*",
|
|
38
|
+
"typebox": "*"
|
|
36
39
|
},
|
|
37
40
|
"dependencies": {
|
|
38
41
|
"@dreki-gg/pi-command-sandbox": "^0.4.3",
|
|
@@ -44,6 +47,7 @@
|
|
|
44
47
|
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
45
48
|
"@earendil-works/pi-tui": "^1.3.0",
|
|
46
49
|
"@types/node": "^26.6.2",
|
|
50
|
+
"typebox": "^1.3.27",
|
|
47
51
|
"typescript": "^5.6.0",
|
|
48
52
|
"vitest": "^4.1.10"
|
|
49
53
|
},
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* `write`/`edit` are the obvious ones. `powershell` is a shell too: pi
|
|
9
9
|
* registers it on every platform (inactive by default, enabled by settings),
|
|
10
10
|
* and it would otherwise hand ask mode a full write channel.
|
|
11
|
+
*
|
|
12
|
+
* The one write channel ask mode keeps is its own tool, {@link ASK_SCRATCH_TOOL}:
|
|
13
|
+
* it takes a relative note name and can only reach the session's scratch
|
|
14
|
+
* directory (see scratch.ts), so it needs no path check from the model and no
|
|
15
|
+
* write allowance in the bash sandbox.
|
|
11
16
|
*/
|
|
12
17
|
export const ASK_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
|
|
13
18
|
"write",
|
|
@@ -23,6 +28,13 @@ export const ASK_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
|
|
|
23
28
|
*/
|
|
24
29
|
export const ASK_READ_ONLY_HELPERS: readonly string[] = ["grep", "find", "ls"];
|
|
25
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The scratch-note tool. Registered inactive (`defaultActive: false`) and
|
|
33
|
+
* activated only while ask mode is on, so its declaration and
|
|
34
|
+
* `promptGuidelines` are part of the request exactly when they apply.
|
|
35
|
+
*/
|
|
36
|
+
export const ASK_SCRATCH_TOOL = "scratchpad";
|
|
37
|
+
|
|
26
38
|
/** Footer status key (extension-scoped) and label. */
|
|
27
39
|
export const ASK_STATUS_KEY = "ask-mode";
|
|
28
40
|
export const ASK_STATUS_LABEL = "ask";
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ask mode over the gate bus.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
7
|
-
* off.
|
|
4
|
+
* Covers the state machine (tool snapshot, scratch tool activation, jailed-tool
|
|
5
|
+
* registration), the gate's verdicts, and the degradation path when the session
|
|
6
|
+
* has no writable temp directory.
|
|
8
7
|
*/
|
|
9
|
-
import {
|
|
8
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
10
12
|
import type {
|
|
11
13
|
ExtensionAPI,
|
|
12
14
|
ExtensionContext,
|
|
13
15
|
ToolCallEvent,
|
|
14
16
|
} from "@earendil-works/pi-coding-agent";
|
|
15
17
|
import type { GateHub, ToolGate } from "../../lib/gates.js";
|
|
18
|
+
import { isJailedTool, resetJailedTools } from "../../lib/tools.js";
|
|
19
|
+
import { ASK_SCRATCH_TOOL } from "./constants.js";
|
|
16
20
|
import { registerAsk } from "./index.js";
|
|
17
21
|
|
|
18
22
|
function event(toolName: string, input: Record<string, unknown> = {}): ToolCallEvent {
|
|
@@ -24,7 +28,11 @@ function event(toolName: string, input: Record<string, unknown> = {}): ToolCallE
|
|
|
24
28
|
} as unknown as ToolCallEvent;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
function
|
|
31
|
+
function tempBase(): string {
|
|
32
|
+
return mkdtempSync(join(tmpdir(), "pi-ask-mode-"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeHarness(options: { scratchBaseDir?: string } = {}) {
|
|
28
36
|
const captured: ToolGate[] = [];
|
|
29
37
|
const hub = {
|
|
30
38
|
register(gate: ToolGate) {
|
|
@@ -36,19 +44,27 @@ function makeHarness() {
|
|
|
36
44
|
|
|
37
45
|
let activeTools = ["read", "write", "edit", "bash"];
|
|
38
46
|
let commandHandler: ((args: string, ctx: ExtensionContext) => unknown) | undefined;
|
|
47
|
+
let registeredTool: { defaultActive?: boolean; name: string } | undefined;
|
|
48
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => unknown>();
|
|
39
49
|
const pi = {
|
|
40
|
-
on: () => {
|
|
50
|
+
on(name: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) {
|
|
51
|
+
handlers.set(name, handler);
|
|
52
|
+
},
|
|
41
53
|
registerCommand(
|
|
42
54
|
name: string,
|
|
43
55
|
options: { handler: (args: string, ctx: ExtensionContext) => unknown },
|
|
44
56
|
) {
|
|
45
57
|
if (name === "ask") commandHandler = options.handler;
|
|
46
58
|
},
|
|
59
|
+
registerTool(tool: { defaultActive?: boolean; name: string }) {
|
|
60
|
+
registeredTool = tool;
|
|
61
|
+
},
|
|
47
62
|
getActiveTools: () => [...activeTools],
|
|
48
63
|
getAllTools: () => [
|
|
49
64
|
{ name: "grep" },
|
|
50
65
|
{ name: "find" },
|
|
51
66
|
{ name: "ls" },
|
|
67
|
+
{ name: ASK_SCRATCH_TOOL },
|
|
52
68
|
],
|
|
53
69
|
setActiveTools: (names: string[]) => {
|
|
54
70
|
activeTools = [...names];
|
|
@@ -56,41 +72,61 @@ function makeHarness() {
|
|
|
56
72
|
} as unknown as ExtensionAPI;
|
|
57
73
|
|
|
58
74
|
const statuses: Array<[string, string | undefined]> = [];
|
|
59
|
-
const notes: string
|
|
75
|
+
const notes: Array<{ message: string; kind?: string }> = [];
|
|
76
|
+
let sessionId = "session-1";
|
|
60
77
|
const ctx = {
|
|
61
78
|
hasUI: true,
|
|
62
79
|
mode: "tui",
|
|
63
80
|
ui: {
|
|
64
|
-
notify: (message: string) => notes.push(message),
|
|
81
|
+
notify: (message: string, kind?: string) => notes.push({ message, kind }),
|
|
65
82
|
setStatus: (key: string, value: string | undefined) => statuses.push([key, value]),
|
|
66
83
|
theme: { fg: (_color: string, text: string) => text },
|
|
67
84
|
},
|
|
85
|
+
sessionManager: { getSessionId: () => sessionId },
|
|
68
86
|
} as unknown as ExtensionContext;
|
|
69
87
|
|
|
70
|
-
registerAsk(pi, hub);
|
|
88
|
+
registerAsk(pi, hub, options);
|
|
71
89
|
const gate = captured[0];
|
|
72
90
|
if (!gate || !commandHandler) throw new Error("ask did not register");
|
|
73
91
|
|
|
74
92
|
return {
|
|
75
93
|
gate,
|
|
76
94
|
ctx,
|
|
95
|
+
notes,
|
|
77
96
|
activeTools: () => activeTools,
|
|
97
|
+
registeredTool: () => registeredTool,
|
|
98
|
+
setSessionId: (id: string) => {
|
|
99
|
+
sessionId = id;
|
|
100
|
+
},
|
|
101
|
+
sessionStart: async () => {
|
|
102
|
+
await handlers.get("session_start")?.({ reason: "new" }, ctx);
|
|
103
|
+
},
|
|
78
104
|
toggle: async () => {
|
|
79
105
|
await commandHandler?.("", ctx);
|
|
80
106
|
},
|
|
81
107
|
evaluate: async (toolName: string, input: Record<string, unknown> = {}) =>
|
|
82
108
|
gate.evaluate(event(toolName, input), ctx),
|
|
109
|
+
banner: () =>
|
|
110
|
+
handlers.get("before_agent_start")?.(
|
|
111
|
+
{ type: "before_agent_start", systemPrompt: "base" },
|
|
112
|
+
ctx,
|
|
113
|
+
) as { systemPrompt: string } | undefined,
|
|
83
114
|
};
|
|
84
115
|
}
|
|
85
116
|
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
// The jail registry is module state; one test's mark must not leak into the next.
|
|
119
|
+
resetJailedTools();
|
|
120
|
+
});
|
|
121
|
+
|
|
86
122
|
describe("ask gate", () => {
|
|
87
123
|
it("has no opinion while ask mode is off", async () => {
|
|
88
|
-
const h = makeHarness();
|
|
124
|
+
const h = makeHarness({ scratchBaseDir: tempBase() });
|
|
89
125
|
expect(await h.evaluate("write", { path: "x" })).toBeUndefined();
|
|
90
126
|
});
|
|
91
127
|
|
|
92
128
|
it("blocks confiscated tools and non-read-only bash while on", async () => {
|
|
93
|
-
const h = makeHarness();
|
|
129
|
+
const h = makeHarness({ scratchBaseDir: tempBase() });
|
|
94
130
|
await h.toggle();
|
|
95
131
|
expect(await h.evaluate("write", { path: "x" })).toMatchObject({
|
|
96
132
|
kind: "block",
|
|
@@ -102,17 +138,124 @@ describe("ask gate", () => {
|
|
|
102
138
|
});
|
|
103
139
|
|
|
104
140
|
it("stops blocking after exiting", async () => {
|
|
105
|
-
const h = makeHarness();
|
|
141
|
+
const h = makeHarness({ scratchBaseDir: tempBase() });
|
|
106
142
|
await h.toggle();
|
|
107
143
|
await h.toggle();
|
|
108
144
|
expect(await h.evaluate("write", { path: "x" })).toBeUndefined();
|
|
109
145
|
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("ask tool set", () => {
|
|
149
|
+
it("registers the scratch tool inactive", async () => {
|
|
150
|
+
const h = makeHarness({ scratchBaseDir: tempBase() });
|
|
151
|
+
expect(h.registeredTool()?.name).toBe(ASK_SCRATCH_TOOL);
|
|
152
|
+
expect(h.registeredTool()?.defaultActive).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("activates scratchpad and marks it jailed only while on", async () => {
|
|
156
|
+
const h = makeHarness({ scratchBaseDir: tempBase() });
|
|
157
|
+
await h.toggle();
|
|
158
|
+
expect(h.activeTools()).toContain(ASK_SCRATCH_TOOL);
|
|
159
|
+
expect(isJailedTool(ASK_SCRATCH_TOOL)).toBe(true);
|
|
160
|
+
|
|
161
|
+
await h.toggle();
|
|
162
|
+
expect(h.activeTools()).not.toContain(ASK_SCRATCH_TOOL);
|
|
163
|
+
expect(isJailedTool(ASK_SCRATCH_TOOL)).toBe(false);
|
|
164
|
+
});
|
|
110
165
|
|
|
111
|
-
it("restores the
|
|
112
|
-
const h = makeHarness();
|
|
166
|
+
it("restores the entry snapshot on exit", async () => {
|
|
167
|
+
const h = makeHarness({ scratchBaseDir: tempBase() });
|
|
113
168
|
await h.toggle();
|
|
114
169
|
expect(h.activeTools()).not.toContain("write");
|
|
115
170
|
await h.toggle();
|
|
116
171
|
expect(h.activeTools()).toEqual(["read", "write", "edit", "bash"]);
|
|
117
172
|
});
|
|
173
|
+
|
|
174
|
+
it("adds the read-only helpers that were inactive", async () => {
|
|
175
|
+
const h = makeHarness({ scratchBaseDir: tempBase() });
|
|
176
|
+
await h.toggle();
|
|
177
|
+
expect(h.activeTools()).toEqual(
|
|
178
|
+
expect.arrayContaining(["grep", "find", "ls"]),
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("ask scratch availability", () => {
|
|
184
|
+
it("creates the scratch directory under the configured base", async () => {
|
|
185
|
+
const base = tempBase();
|
|
186
|
+
const h = makeHarness({ scratchBaseDir: base });
|
|
187
|
+
await h.toggle();
|
|
188
|
+
expect(h.notes.at(-1)?.message).toContain(resolve(base));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("degrades to fully read-only when the temp dir is unusable", async () => {
|
|
192
|
+
const base = tempBase();
|
|
193
|
+
const file = join(base, "not-a-dir");
|
|
194
|
+
writeFileSync(file, "x");
|
|
195
|
+
const h = makeHarness({ scratchBaseDir: file });
|
|
196
|
+
await h.toggle();
|
|
197
|
+
|
|
198
|
+
expect(h.activeTools()).not.toContain(ASK_SCRATCH_TOOL);
|
|
199
|
+
expect(isJailedTool(ASK_SCRATCH_TOOL)).toBe(false);
|
|
200
|
+
expect(h.notes.at(-1)?.message).toContain("read-only");
|
|
201
|
+
const warning = h.notes.find((note) => note.kind === "warning");
|
|
202
|
+
expect(warning?.message).toContain("no scratch space");
|
|
203
|
+
// Still a consult mode: the hard block is unchanged.
|
|
204
|
+
expect(await h.evaluate("write", { path: "x" })).toMatchObject({
|
|
205
|
+
kind: "block",
|
|
206
|
+
});
|
|
207
|
+
expect(h.banner()?.systemPrompt).not.toContain(ASK_SCRATCH_TOOL);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("ask banner", () => {
|
|
212
|
+
it("names the scratch channel while it exists", async () => {
|
|
213
|
+
const h = makeHarness({ scratchBaseDir: tempBase() });
|
|
214
|
+
await h.toggle();
|
|
215
|
+
const banner = h.banner()?.systemPrompt ?? "";
|
|
216
|
+
expect(banner).toContain("[ASK MODE]");
|
|
217
|
+
expect(banner).toContain(ASK_SCRATCH_TOOL);
|
|
218
|
+
expect(banner).toContain("/ask");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("stays silent when the mode is off", () => {
|
|
222
|
+
const h = makeHarness({ scratchBaseDir: tempBase() });
|
|
223
|
+
expect(h.banner()).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("ask session rebinding", () => {
|
|
228
|
+
it("rebinds the scratch directory when the session changes while on", async () => {
|
|
229
|
+
const base = tempBase();
|
|
230
|
+
const h = makeHarness({ scratchBaseDir: base });
|
|
231
|
+
await h.toggle();
|
|
232
|
+
const firstNotice = h.notes.at(-1)?.message ?? "";
|
|
233
|
+
expect(firstNotice).toContain("notes go to");
|
|
234
|
+
|
|
235
|
+
h.setSessionId("session-2");
|
|
236
|
+
await h.sessionStart();
|
|
237
|
+
|
|
238
|
+
const rebind = h.notes.at(-1)?.message ?? "";
|
|
239
|
+
expect(rebind).toContain("notes now go to");
|
|
240
|
+
// The two notices name different per-session directories.
|
|
241
|
+
const firstRoot = firstNotice.match(/notes go to (.+)\. \//)?.[1];
|
|
242
|
+
const reboundRoot = rebind.match(/notes now go to (.+)\./)?.[1];
|
|
243
|
+
expect(firstRoot).toBeDefined();
|
|
244
|
+
expect(reboundRoot).toBeDefined();
|
|
245
|
+
expect(reboundRoot).not.toBe(firstRoot);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("keeps the scratch directory on a repeated emission for the same session", async () => {
|
|
249
|
+
const h = makeHarness({ scratchBaseDir: tempBase() });
|
|
250
|
+
await h.toggle();
|
|
251
|
+
const noteCount = h.notes.length;
|
|
252
|
+
await h.sessionStart();
|
|
253
|
+
expect(h.notes.length).toBe(noteCount);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("does nothing while the mode is off", async () => {
|
|
257
|
+
const h = makeHarness({ scratchBaseDir: tempBase() });
|
|
258
|
+
await h.sessionStart();
|
|
259
|
+
expect(h.notes).toHaveLength(0);
|
|
260
|
+
});
|
|
118
261
|
});
|