@andares/pi-toolkits 0.9.6 → 0.9.8
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 +22 -16
- package/README.zh-CN.md +20 -14
- package/package.json +8 -6
- 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 +69 -24
- 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/autostart/autostart.ts +1 -1
- package/src/features/autostart/index.ts +1 -1
- package/src/features/compact/compact.test.ts +33 -10
- package/src/features/compact/index.ts +27 -34
- package/src/features/compact/prompt.ts +23 -12
- package/src/features/favorites/constants.ts +6 -5
- package/src/features/favorites/patch-cycle.ts +4 -3
- package/src/features/favorites/patch-selector.ts +2 -2
- package/src/features/permission/index.test.ts +27 -3
- package/src/features/permission/index.ts +6 -0
- package/src/features/stash/stash.ts +1 -1
- package/src/lib/gates.ts +4 -0
- package/src/lib/model-config.ts +2 -2
- package/src/lib/model-picker.ts +1 -1
- package/src/lib/tools.ts +48 -0
package/README.md
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
[](https://pi.dev/packages/@andares/pi-toolkits)
|
|
11
11
|
[](LICENSE)
|
|
12
12
|
[](https://pnpm.io)
|
|
13
|
-
[](https://github.com/earendil-works/pi)
|
|
14
14
|
[](https://github.com/andares/pi-toolkits)
|
|
15
15
|
|
|
16
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.
|
|
17
17
|
|
|
18
|
-
**Requirements**: pi `>=
|
|
18
|
+
**Requirements**: pi `>=0.99.0`. Every published pi release from 0.99.0 through 1.4.0 has been verified with the full gate (install → `tsc --noEmit` → full test suite → keybinding audit → hash compare of the host files this package patches), each in a sandbox with the whole `@earendil-works/*` family pinned to the version under test. Newer releases are verified as they ship and the floor moves only when the measurement says so; `pnpm check:compat` reproduces the result. 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.
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
@@ -37,7 +37,7 @@ pi -e ./src/index.ts
|
|
|
37
37
|
|
|
38
38
|
After installing, verify in any pi session:
|
|
39
39
|
|
|
40
|
-
- `/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
|
|
41
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)
|
|
42
42
|
- press `ctrl+alt+y` in the input box → the current prompt is stashed and the input box is cleared
|
|
43
43
|
- after finishing a development task, type `/third-review` → switches to the review model and runs check + fix
|
|
@@ -68,7 +68,7 @@ After installing, verify in any pi session:
|
|
|
68
68
|
|
|
69
69
|
| Feature | Entry point | One-line description |
|
|
70
70
|
| --- | --- | --- |
|
|
71
|
-
| 🛡️ **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 |
|
|
72
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` |
|
|
73
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 |
|
|
74
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 |
|
|
@@ -82,17 +82,21 @@ After installing, verify in any pi session:
|
|
|
82
82
|
|
|
83
83
|
<a name="ask"></a>
|
|
84
84
|
|
|
85
|
-
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.
|
|
86
86
|
|
|
87
87
|
**While active**:
|
|
88
88
|
|
|
89
|
-
-
|
|
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)
|
|
90
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
|
|
91
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
|
|
92
|
-
- **
|
|
93
|
-
- **
|
|
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
|
|
94
96
|
|
|
95
|
-
**
|
|
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).
|
|
96
100
|
|
|
97
101
|
---
|
|
98
102
|
|
|
@@ -133,7 +137,7 @@ Repeating the argument form with the modes already in force is a **no-op**: no s
|
|
|
133
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`.
|
|
134
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".
|
|
135
139
|
|
|
136
|
-
**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.
|
|
137
141
|
|
|
138
142
|
**Enable it for every session** (the state itself is never persisted — this is the opt-in):
|
|
139
143
|
|
|
@@ -251,11 +255,11 @@ pi's `/compact` and auto-compact summarize the context **with the current sessio
|
|
|
251
255
|
| auto-compact threshold reached | summarized by the compaction model |
|
|
252
256
|
| context overflow recovery | summarized by the compaction model, retry behavior unchanged |
|
|
253
257
|
|
|
254
|
-
**Parts that are identical to pi's built-in behavior after takeover** (the built-in implementation is mirrored verbatim — copied from 0.87.1, byte-identical across
|
|
258
|
+
**Parts that are identical to pi's built-in behavior after takeover** (the built-in implementation is mirrored verbatim — copied from 0.87.1, byte-identical across 1.1.0 → 1.4.0 apart from the two 1.4.0 fixes adopted here: summaries request the model's output limit instead of a fixed fraction of `reserveTokens`, and `/compact <instructions>` also reaches the split-turn prefix — and only the executing model changes; the conversation serializer itself is imported from the host, so it follows whatever the running host does, e.g. 1.2.0 stops feeding assistant thinking back into the summarization request):
|
|
255
259
|
|
|
256
260
|
- **Summary format** — the structured Goal / Progress / Key Decisions checkpoint; when a summary already exists, the iterative-update prompt is used so information carries over instead of being lost
|
|
257
261
|
- **Split behavior** — the `keepRecentTokens` retention policy, and `firstKeptEntryId` / `tokensBefore` passed back unchanged
|
|
258
|
-
- **Output budget** — the
|
|
262
|
+
- **Output budget** — summaries ask for the model's own output limit, exactly like the built-in since 1.4.0 (#9512: a fixed 0.8×/0.5× `reserveTokens` cap made long reasoning fail the summary with "generation hit the token cap"); `reserveTokens` only decides when compaction starts
|
|
259
263
|
- **File-list tail** — `<read-files>` / `<modified-files>` tags are appended to the summary as usual, with details maintained in the same shape
|
|
260
264
|
- **Usage accounting** — the compaction model's tokens/cost are counted in the session statistics
|
|
261
265
|
- **Cancellable** — `Esc` during compaction cancels cleanly (the signal is passed through to the underlying call)
|
|
@@ -332,7 +336,7 @@ An **identical command line** declared by both sources runs once: a duplicate is
|
|
|
332
336
|
|
|
333
337
|
**Key constraint: extension commands only**. Commands registered via `registerCommand` such as `/add-dir` (pi-add-dir) and `/cd` (pi-cd) work; built-in commands (`/compact`, `/reload`, `/resume`, …) are dispatched in the TUI layer and cannot be triggered from the extension layer; skill commands (`/skill:name`) are an expansion mechanism rather than an execution mechanism. The target extension you depend on (e.g. pi-add-dir) must be installed first.
|
|
334
338
|
|
|
335
|
-
> **Implementation notes**: execution uses `sendUserMessage(cmd, { expandPromptTemplates: true })` — that option routes `/cmd args` into command dispatch instead of the model prompt path (the wrapper defaults to `false`; `sendUserMessage` and `_tryExecuteExtensionCommand` are byte-identical across 1.
|
|
339
|
+
> **Implementation notes**: execution uses `sendUserMessage(cmd, { expandPromptTemplates: true })` — that option routes `/cmd args` into command dispatch instead of the model prompt path (the wrapper defaults to `false`; `sendUserMessage` and `_tryExecuteExtensionCommand` are byte-identical across 1.1.0–1.4.0, see the host-compatibility discipline in AGENTS.md). Pre-validation uses `pi.getCommands()` (accepting only `source: "extension"`). Depends on `smol-toml` (zero-dependency, ~10KB, TOML 1.0).
|
|
336
340
|
|
|
337
341
|
---
|
|
338
342
|
|
|
@@ -348,7 +352,7 @@ pnpm typecheck # tsc --noEmit
|
|
|
348
352
|
pnpm test # vitest (all feature unit tests)
|
|
349
353
|
|
|
350
354
|
pnpm check:keybindings # audit this plugin's keys against the host's own dispatch table
|
|
351
|
-
pnpm check:compat #
|
|
355
|
+
pnpm check:compat # mandatory 4-line window + backward probe (4 more lines) → public floor
|
|
352
356
|
```
|
|
353
357
|
|
|
354
358
|
### Project structure
|
|
@@ -357,10 +361,12 @@ pnpm check:compat # verify the declared pi range: last 4 minor lines + 8-
|
|
|
357
361
|
src/
|
|
358
362
|
├── index.ts # entry point: aggregates feature modules
|
|
359
363
|
├── lib/ # shared infrastructure (tool-set snapshot/restore,
|
|
360
|
-
│ # gate bus composing tool_call policies,
|
|
364
|
+
│ # gate bus composing tool_call policies, jailed-tool
|
|
365
|
+
│ # registry the boundary gate consults,
|
|
361
366
|
│ # model-key resolution shared by review & compact)
|
|
362
367
|
└── features/ # feature modules — one directory per feature
|
|
363
368
|
├── ask/ # ask mode: command + state machine, bash sandbox,
|
|
369
|
+
│ # scratch notes (temp dir + scratchpad tool),
|
|
364
370
|
│ # system prompt banner, tests
|
|
365
371
|
├── autostart/ # session startup automation: autostart.toml parsing,
|
|
366
372
|
│ # command pre-validation + replay, tests
|
|
@@ -408,7 +414,7 @@ PRs and issues welcome. A few conventions:
|
|
|
408
414
|
- **Built-in patches** (favorites) must stay **version-guarded and idempotent** — verify the target prototype members exist before patching, guard against double-apply, degrade with a `console.warn` instead of breaking the extension
|
|
409
415
|
- **Built-in prompt sync** (compact) — the summarization prompts in `src/features/compact/prompt.ts` are copied verbatim from pi's built-in compaction; when upgrading the pi dependency, diff `dist/core/compaction/compaction.js` (and `utils.js`) and update the strings + assembly helpers to match, keeping takeover output byte-compatible with stock
|
|
410
416
|
- **Keybindings** — before picking a new shortcut, check pi defaults + reserved keys, terminal control chars, and OS/terminal-emulator grabs; prefer `ctrl+alt+<letter>` for cross-platform safety. Do not stop at "it works on my machine in the main screen": in `fullscreen` mode (pi's default since 1.0) the alt-screen layer consumes `tui.altScreen.*` **before** dialogs see the key, and on Windows/WSL several of those bindings differ from the Linux defaults. Run `pnpm check:keybindings [candidate...]` (and `pnpm check:keybindings --all` after a pi upgrade) — it probes the installed host's resolved keybinding table with the host's own `matchesKey`. A key free in the host table can still be taken at the machine layer: Windows Terminal binds `ctrl+v` (paste) and `ctrl+shift+f` (find), and third-party Windows tools commonly grab `ctrl+alt+<letter>` (a screenshot tool once took `ctrl+alt+f`) — so try the key for real on the target machine before shipping it.
|
|
411
|
-
- **Host compatibility** — the peer range is a measured statement, not a wish: the four most recently released pi minor lines are
|
|
417
|
+
- **Host compatibility** — the peer range is a measured statement, not a wish, and it only points backwards: the four most recently released pi minor lines are a mandatory window (fix our code, never raise the floor), below it we probe up to four older lines to find where compatibility actually ends, and the single number users see is the resulting floor (`pi >= <floor>`, no upper bound). Re-run `pnpm check:compat` whenever the host dependency moves and read its verdict block (it fails if the declared floor drifts from the measured one). See the pi 宿主兼容 section of AGENTS.md; keybinding collisions found during an upgrade are reported to the maintainer for a decision, never re-keyed silently.
|
|
412
418
|
- **Quality gate** — `pnpm typecheck && pnpm test` must pass before submitting
|
|
413
419
|
|
|
414
420
|
---
|
package/README.zh-CN.md
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
[](https://pi.dev/packages/@andares/pi-toolkits)
|
|
11
11
|
[](LICENSE)
|
|
12
12
|
[](https://pnpm.io)
|
|
13
|
-
[](https://github.com/earendil-works/pi)
|
|
14
14
|
[](https://github.com/andares/pi-toolkits)
|
|
15
15
|
|
|
16
16
|
一句话介绍:目前包含七个开箱即用的功能——**只读咨询模式** `ask`、**会话级边界权限** `ask-perm`、**模型收藏** `favorites`、**提示词暂存** `stash`、**第三方代码评审** `third-review`、**独立压缩模型** `compact-model`、**会话启动自动化** `autostart`,后续持续追加。
|
|
17
17
|
|
|
18
|
-
**版本要求**:pi `>=
|
|
18
|
+
**版本要求**:pi `>=0.99.0`。0.99.0 到 1.4.0 的每一个已发布版本都用完整门禁实测过(install → `tsc --noEmit` → 全量测试 → 键位审计 → 对 patch 目标宿主文件做哈希比对),每个版本都在沙箱中把整个 `@earendil-works/*` 家族钉在同一版本。更新的版本随发布逐个验证,下限只在实测结果允许时移动;`pnpm check:compat` 可复现该结论。注:pi ≥ 0.99 的宿主要求「宿主提供的包」一律声明为 peerDependencies `"*"` —— `@earendil-works/pi-tui` 与 `typebox`(ask 的 `scratchpad` 用它构造参数 schema)都按此声明;若放进 `dependencies`,pi 会告警。
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
@@ -37,7 +37,7 @@ pi -e ./src/index.ts
|
|
|
37
37
|
|
|
38
38
|
安装后在任意 pi 会话中验证:
|
|
39
39
|
|
|
40
|
-
- `/ask` → footer 出现灰色 `ask` 状态,`write`/`edit`
|
|
40
|
+
- `/ask` → footer 出现灰色 `ask` 状态,`write`/`edit` 被硬禁用,同时出现 `scratchpad` 工具用于草稿笔记
|
|
41
41
|
- `/model`(或 `ctrl+l`)→ 选择器顶部出现收藏提示行;对选中模型按 `ctrl+A` → 文字变**加粗亮黄**、收藏计数 +1(这一步同时就是真机按键检查,见「键位」约定)
|
|
42
42
|
- 输入框内按 `ctrl+alt+y` → 当前提示词被暂存并清空输入框
|
|
43
43
|
- 完成一个开发任务后输入 `/third-review` → 切换到评审模型执行查+修
|
|
@@ -68,7 +68,7 @@ pi -e ./src/index.ts
|
|
|
68
68
|
|
|
69
69
|
| 功能 | 入口 | 一句话说明 |
|
|
70
70
|
| --- | --- | --- |
|
|
71
|
-
| 🛡️ **ask** 只读咨询模式 | `/ask` |
|
|
71
|
+
| 🛡️ **ask** 只读咨询模式 | `/ask` | 进入咨询模式:`write`/`edit`/`powershell` 硬禁用,`bash` 只读沙箱,临时启用 `grep`/`find`/`ls`,并开放 `scratchpad` 把草稿写进会话临时目录 |
|
|
72
72
|
| 🔐 **permission** 会话级边界权限 | `/ask-perm` | 项目内/外 × 读/写四格模式,默认关闭且从不落盘;项目外写询问、凭据路径硬拒、破坏性命令必问;与 `/ask` 并行组合 |
|
|
73
73
|
| ⭐ **favorites** 模型收藏 | `/model` 内 `ctrl+A` / `ctrl+J` | 收藏常用模型(加粗亮黄标记),`ctrl+p` 仅在收藏间循环 |
|
|
74
74
|
| 📥 **stash** 提示词暂存 | `ctrl+alt+y` | 一段提示词的暂存槽,按键交换 / 连按两次暂存并清空输入框 |
|
|
@@ -82,17 +82,21 @@ pi -e ./src/index.ts
|
|
|
82
82
|
|
|
83
83
|
<a name="ask"></a>
|
|
84
84
|
|
|
85
|
-
用 `/ask` 进入(再按一次退出)
|
|
85
|
+
用 `/ask` 进入(再按一次退出)。适合只想要「问问题、读代码」的场景:模型不能改项目,但不会被剥夺思考的落点。
|
|
86
86
|
|
|
87
87
|
**进入后**:
|
|
88
88
|
|
|
89
|
-
-
|
|
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 沙箱因此完全不需要写权限。宿主没有可写临时目录时工具保持未激活,模式降级为旧的纯只读(进入时的提示会说清楚,不做半开)
|
|
90
91
|
- **临时启用只读检索工具** —— `grep` / `find` / `ls`(宿主已注册但默认不激活)在 ask 期间打开,让模型有正规搜索工具而不是只能走 bash;退出时按进入前快照完整恢复
|
|
91
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`)。启发式,非安全边界
|
|
92
|
-
-
|
|
93
|
-
-
|
|
93
|
+
- **短横幅而不是清单** —— 系统提示词只追加三行:咨询模式、草稿通道、退出方式。**不再重述 bash 白名单** —— 那是沙箱的职责,被拦时的拒绝理由会直接指出可用的替代,所以细节只在真用到的那一轮付费(旧横幅每个请求都要重申一遍,约 300 token)。横幅不含路径与时间戳,会话内字节稳定,可以稳定命中 provider 的提示词缓存
|
|
94
|
+
- **拒绝理由指向下一步** —— 被拦的 `write` / `edit` 会说明草稿用 `scratchpad`、真正改动要 `/ask` 退出;被拦的重定向也同样指路,而不是只说「不能写」
|
|
95
|
+
- **灰色 `ask` footer 状态** —— 常驻提示当前处于咨询模式
|
|
94
96
|
|
|
95
|
-
|
|
97
|
+
**草稿生命周期**:每个会话一个目录,位于系统临时目录(`os.tmpdir()`,所以 Windows 的 `%TEMP%` 也适用);进入时创建、退出时**不删**——续会话(`pi -c`)还能找回笔记,进程内切换会话(/new、/resume、fork)会重新绑定到新会话的目录;进入时清掉超过 7 天的旧目录。开启提示会打印路径;里面的内容不属于项目,也不会出现在 `git status` 里。
|
|
98
|
+
|
|
99
|
+
**退出**:再按 `/ask`,完整恢复进入前的工具集快照(含 scratch 工具与临时启用的只读工具)。
|
|
96
100
|
|
|
97
101
|
---
|
|
98
102
|
|
|
@@ -251,11 +255,11 @@ pi 的 `/compact` 与 auto-compact 默认**用当前会话模型**做上下文
|
|
|
251
255
|
| auto-compact 阈值触发 | 压缩模型总结 |
|
|
252
256
|
| context overflow 恢复 | 压缩模型总结,重试行为不变 |
|
|
253
257
|
|
|
254
|
-
**接管后与 pi 内建行为完全一致的部分**(逐字同步 pi 内建实现,复制自 0.87.1
|
|
258
|
+
**接管后与 pi 内建行为完全一致的部分**(逐字同步 pi 内建实现,复制自 0.87.1、在 1.1.0 → 1.4.0 内字节级不变 —— 除了此处采纳的两项 1.4.0 修正:摘要请求使用模型自身的输出上限而非 `reserveTokens` 的固定比例,以及 `/compact <instructions>` 也会进入 split-turn 前缀;只换执行模型;对话序列化函数(`serializeConversation`)是直接从宿主导入的,因此跟随宿主行为 —— 例如 1.2.0 起不再把 assistant thinking 回填进摘要请求):
|
|
255
259
|
|
|
256
260
|
- **摘要格式** —— Goal / Progress / Key Decisions 结构化 checkpoint;已有摘要时用迭代更新版 prompt,信息延续不丢
|
|
257
261
|
- **切分行为** —— 保留最近 `keepRecentTokens` 的策略、`firstKeptEntryId` / `tokensBefore` 原样回传,不动
|
|
258
|
-
- **输出预算** ——
|
|
262
|
+
- **输出预算** —— 摘要请求使用模型自身的输出上限,与 1.4.0 起的内建一致(#9512:过去按 0.8×/0.5× `reserveTokens` 设上限,长思考会把摘要顶到 "generation hit the token cap" 而失败);`reserveTokens` 只决定何时开始压缩
|
|
259
263
|
- **文件清单尾部** —— `<read-files>` / `<modified-files>` 标签照常拼在摘要尾部,details 同形状维护
|
|
260
264
|
- **用量统计** —— 压缩模型的 token/cost 计入 session 统计
|
|
261
265
|
- **可取消** —— 压缩进行中 `Esc` 干净取消(信号透传到底层调用)
|
|
@@ -332,7 +336,7 @@ run = [
|
|
|
332
336
|
|
|
333
337
|
**关键约束:只支持扩展命令**。`/add-dir`(pi-add-dir)、`/cd`(pi-cd)等 `registerCommand` 注册的命令可以;内置命令(`/compact`、`/reload`、`/resume`…)在 TUI 层分发,扩展层触发不了;skill 命令(`/skill:name`)是展开机制而非执行机制。依赖的目标扩展(如 pi-add-dir)需要先安装。
|
|
334
338
|
|
|
335
|
-
> **实现说明**:执行用 `sendUserMessage(cmd, { expandPromptTemplates: true })` —— 该选项把 `/cmd args` 路由到命令分发而不是模型 prompt 路径(包装层默认 `false`,`sendUserMessage` 与 `_tryExecuteExtensionCommand` 在 1.
|
|
339
|
+
> **实现说明**:执行用 `sendUserMessage(cmd, { expandPromptTemplates: true })` —— 该选项把 `/cmd args` 路由到命令分发而不是模型 prompt 路径(包装层默认 `false`,`sendUserMessage` 与 `_tryExecuteExtensionCommand` 在 1.1.0–1.4.0 全区间字节级一致,见 AGENTS.md 宿主兼容纪律)。预校验用 `pi.getCommands()`(只认 `source: "extension"`)。依赖 `smol-toml`(零依赖、~10KB、TOML 1.0)。
|
|
336
340
|
|
|
337
341
|
---
|
|
338
342
|
|
|
@@ -348,7 +352,7 @@ pnpm typecheck # tsc --noEmit
|
|
|
348
352
|
pnpm test # vitest(全部功能单元测试)
|
|
349
353
|
|
|
350
354
|
pnpm check:keybindings # 用宿主自己的分发表审计本插件的按键
|
|
351
|
-
pnpm check:compat # 校验声明的 pi
|
|
355
|
+
pnpm check:compat # 校验声明的 pi 下限:强制 4 条线窗口 + 向后 4 条线探针 → public floor
|
|
352
356
|
```
|
|
353
357
|
|
|
354
358
|
### 项目结构
|
|
@@ -358,9 +362,11 @@ src/
|
|
|
358
362
|
├── index.ts # 入口:聚合各功能模块
|
|
359
363
|
├── lib/ # 共享基础设施(工具集快照/恢复、
|
|
360
364
|
│ # 组合 tool_call 策略的闸门总线、
|
|
365
|
+
│ # 边界闸门会查询的自封闭工具登记表、
|
|
361
366
|
│ # review 与 compact 共用的模型 key 解析)
|
|
362
367
|
└── features/ # 功能模块 —— 一个功能一个目录
|
|
363
368
|
├── ask/ # ask 模式:命令 + 状态机、bash 沙箱、
|
|
369
|
+
│ # 草稿空间(临时目录 + scratchpad 工具)、
|
|
364
370
|
│ # 系统提示词横幅、测试
|
|
365
371
|
├── autostart/ # 会话启动自动化:autostart.toml 解析、
|
|
366
372
|
│ # 命令预校验 + 重放、测试
|
|
@@ -406,7 +412,7 @@ pnpm release:github --audit # 列出 npm 版本 vs GitHub Release vs 远端 t
|
|
|
406
412
|
- **内建 patch**(favorites)必须保持**版本守卫 + 幂等** —— 打 patch 前先确认目标原型成员存在,防止重复应用,失败时用 `console.warn` 降级而不是把扩展搞挂
|
|
407
413
|
- **内建提示词同步**(compact) —— `src/features/compact/prompt.ts` 里的总结 prompt 逐字抄自 pi 内建压缩;升级 pi 依赖时 diff `dist/core/compaction/compaction.js`(及 `utils.js`),同步字符串与组装辅助函数,保持接管输出与内建字节级一致
|
|
408
414
|
- **键位** —— 定新快捷键前先查 pi 默认键位 + 保留键、终端控制字符、OS/终端模拟器占用;跨平台安全优先选 `ctrl+alt+<字母>`。**不得只验证「主屏下我这台机器能用」**:自 pi 1.0 起默认 `fullscreen`,alt-screen 层在对话框看到按键**之前**就消费 `tui.altScreen.*`,且 Windows/WSL 上多个绑定与 Linux 默认值不同。用 `pnpm check:keybindings [候选键...]` 核查(pi 升级后跑 `pnpm check:keybindings --all`),它用宿主自己的 `matchesKey` 探测已安装宿主的**已解析**键位表。**宿主表空闲 ≠ 机器层空闲**:Windows Terminal 默认占 `ctrl+v`(粘贴)与 `ctrl+shift+f`(查找),第三方 Windows 工具常绑 `ctrl+alt+<字母>`(截图工具占过 `ctrl+alt+f`)——定键后必须在真机试按,确认按键真的到得了应用。
|
|
409
|
-
- **宿主兼容** —— peer
|
|
415
|
+
- **宿主兼容** —— peer 区间是实测结论而非愿望,而且只往回指:最近 4 个已发布中版本线是**强制窗口**(不兼容就改我们的代码,绝不抬下限),窗口之下再向后探最多 4 条线找出兼容实际停在哪里,对使用者只展示一个数:由此得到的**下限**(`pi >= <floor>`,无上界)。宿主依赖一动就重跑 `pnpm check:compat`,读它的 verdict 段(声明下限与实测不一致会直接判失败)。详见 AGENTS.md「pi 宿主兼容」;升级中发现的键位冲突**上报崔总决策**,不得顺手换键。
|
|
410
416
|
- **质量门禁** —— 提交前必须通过 `pnpm typecheck && pnpm test`
|
|
411
417
|
|
|
412
418
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andares/pi-toolkits",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
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",
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
"boundary"
|
|
34
34
|
],
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@earendil-works/pi-coding-agent": ">=
|
|
37
|
-
"@earendil-works/pi-tui": "*"
|
|
36
|
+
"@earendil-works/pi-coding-agent": ">=0.99.0",
|
|
37
|
+
"@earendil-works/pi-tui": "*",
|
|
38
|
+
"typebox": "*"
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
41
|
"@dreki-gg/pi-command-sandbox": "^0.4.3",
|
|
@@ -42,10 +43,11 @@
|
|
|
42
43
|
"smol-toml": "^1.9.0"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
|
-
"@earendil-works/pi-ai": "^1.
|
|
46
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
47
|
-
"@earendil-works/pi-tui": "^1.
|
|
46
|
+
"@earendil-works/pi-ai": "^1.4.0",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
48
|
+
"@earendil-works/pi-tui": "^1.4.0",
|
|
48
49
|
"@types/node": "^26.6.2",
|
|
50
|
+
"typebox": "^1.3.27",
|
|
49
51
|
"typescript": "^5.6.0",
|
|
50
52
|
"vitest": "^4.1.10"
|
|
51
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
|
});
|