pi-warden 0.6.0 → 0.8.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-warden
2
2
 
3
- A second pair of eyes for [Pi](https://pi.dev) that makes the agent smarter instead of interrupting you. Before the agent runs a `bash`, `write`, or `edit` call (or a shell command through [context-mode](https://www.npmjs.com/package/context-mode)'s `ctx_execute`), pi-warden asks [Jev](https://typesafe.ai) two questions about it in ~250 ms: *would this destroy something that cannot be recovered?* and *is this what the user actually asked for?* The answers are probabilities, so `rm -rf dist` after "rebuild from scratch" sails through while `npm run db:reset` after "add a column" is held, and the agent is told why so it re-plans or asks you in chat. More guards watch the run: a **stuck-loop** detector, a **done-check** for completion claims that no test backed up, **slop** notes for stubs, filler, and padded replies, a check for **injected instructions** in tool output, and **tail compression** of large tool results. Every verdict lands on a status line above the editor; **click it** (fullscreen mode), press `ctrl+shift+w`, or run `/warden trace` to open a live trace sidebar with the scores, the reasons, and exactly what the agent was told. Built on [pi-typesafe](https://github.com/DevMortimer/pi-typesafe).
3
+ A second pair of eyes for [Pi](https://pi.dev) that makes the agent smarter instead of interrupting you. Before the agent runs a `bash`, `write`, or `edit` call (or a shell command through [context-mode](https://www.npmjs.com/package/context-mode)'s `ctx_execute`), pi-warden asks [Jev](https://typesafe.ai) two questions about it in ~250 ms: *would this destroy something that cannot be recovered?* and *is this what the user actually asked for?* The answers are probabilities, so `rm -rf dist` after "rebuild from scratch" sails through while `npm run db:reset` after "add a column" is held, and the agent is told why so it re-plans or asks you in chat. More guards watch the run: a **stuck-loop** detector, a **runaway** guard that stops a reply repeating the same block over and over, **desktop notifications** when the agent needs you back, a **done-check** for completion claims that no test backed up, **slop** notes for stubs, filler, and padded replies, a check for **injected instructions** in tool output, and a **context saver** that replaces repeated tool results with a note, keeps exact failing tests and errors from large runner output, and points recalls at a search instead of a whole-file read. Every verdict lands on a status line above the editor; **click it** (fullscreen mode), press `ctrl+shift+w`, or run `/warden trace` to open a live trace sidebar with the scores, the reasons, and exactly what the agent was told. Built on [pi-typesafe](https://github.com/DevMortimer/pi-typesafe).
4
4
 
5
5
  ![Real verdicts from pi-warden: the same command gets a different verdict depending on what the user asked for](https://raw.githubusercontent.com/DevMortimer/pi-warden/main/docs/preview.png)
6
6
 
@@ -16,7 +16,7 @@ Three jobs, in order of maturity:
16
16
  | --- | --- | --- |
17
17
  | **Security** | Action holds and offline patterns protect risky operations. Tool-output checks flag possible injected instructions and credential shapes; insecure written code gets a targeted steer. Output notices are advisory, not a sandbox or a guarantee that an attack is contained. | built |
18
18
  | **Deslopify** | Written code is scored per symptom — stubs, restating comments, dead code, hedging — and the agent gets a nudge naming the symptom and its fix; repeats become a standing rule. Replies are scored for wordiness, assistant clichés, and jargon against an `audience` setting, and a trend across replies shapes the next one. | built |
19
- | **Context saving** | Jev selects a retention policy for large new tool results; code keeps deterministic excerpts and an exact local copy. Only tail compression is implemented. Cold-cache pruning and compaction integration remain planned. The system prompt and existing history are untouched. | tail MVP |
19
+ | **Context saving** | Only the newest tool result is ever touched, so the prompt cache prefix stays byte-identical. An identical repeat of an earlier result becomes a short note (code only). For large new results Jev selects a retention policy and names the output format; code keeps exact lines through a format parser (failing tests, errors with file:line, changed files, package notices) or a deterministic head/diagnostic/tail excerpt, stores an exact copy, and tells the agent how to search it instead of re-reading it. Task-aware segment selection and compaction support remain planned. | built |
20
20
 
21
21
  ## Why a coding agent needs this
22
22
 
@@ -64,6 +64,10 @@ A `TYPESAFE_API_KEY` environment variable takes precedence over the stored key.
64
64
 
65
65
  Keeps the last 12 tool results for the current prompt. When the latest result failed and at least 3 failures have accumulated, it first checks for exact repeats offline (same call, same output with timings and addresses normalised). Otherwise one Jev request judges the sequence: `same_strategy` (Noul), `approach_change` (Score: identical / cosmetic / meaningfully different), `progress` (Noul). Same-strategy ≥ 0.7 counts as stuck: you get a notification, and with `nudge: true` (default) the agent gets a steer message asking for a new hypothesis or a blocker report. At most one Jev check per 3 results.
66
66
 
67
+ ### Runaway guard (`message_update`)
68
+
69
+ A model that degenerates mid-reply repeats the same lines over and over — *"PR green. Merge. Executing:"* followed by the same command block, thirty times, and no tool call — until the token limit or you press Esc. Nothing else stops it, because no tool runs and no turn ends. pi-warden reads the stream as it arrives: per token it only appends to a buffer; every 256 characters it counts identical paragraphs (normalised, at least 24 characters) and checks whether the text ends with one unit repeated back to back. When a block repeats `repeats` times (4) in the reply, or `thinkingRepeats` times (10) in thinking, the run is aborted and you get an error-level notification. Code only: nothing is sent anywhere, so this works without TypeSafe consent. With `recover: true` (default) the agent then gets one follow-up turn, shown in the transcript, that names the repeat and asks for the one next step — call the tool, or answer in three sentences, or ask you; a second runaway for the same prompt is stopped and left for you. Calibrated on 43,000 local assistant messages: ordinary replies repeat a paragraph twice at most, thinking up to seven times while drafting code, and the one runaway repeated its block 28 times; the guard stopped only that one, at half its length.
70
+
67
71
  ### Done-check (`agent_end`)
68
72
 
69
73
  Tracks each run's evidence: code changes (`write`, `edit`) and check commands (`npm test`, `pytest`, `cargo test`, `tsc`, `eslint`, `go test`, `make test`, and similar, whether run through `bash` or `ctx_execute`) with pass/fail; context-mode's inline `Command exited with code N` counts as a failure. When a run ends with a normal assistant message after code changes and no passing check, one Jev request judges the message: `claims_done`, `claims_verified`, `verification_applies` (Noul), `outcome` (Choice: complete / partial / blocked / other). A completion claim ≥ 0.7 that is not a blocker or question, for a task where checks would mean something (≥ 0.5; prose and file housekeeping score ~0.05), is reported as unverified; a verification claim with no check run is reported as a false claim. With `nudge: true` (default) the agent receives one follow-up turn asking it to run the checks or say plainly that nothing was verified — once per user prompt.
@@ -88,17 +92,25 @@ Nudges from the stuck, done, slop, and prose guards are custom messages in the a
88
92
 
89
93
  If TypeSafe cannot answer (timeout after 5 s, outage, budget), an action-guard call is allowed with a warning (`failOpen: true`; set it to `false` to hold instead), and the other guards simply skip. When the per-session request budget is spent, pi-warden says so once and continues with offline checks. Consent in headless runs comes from `PI_WARDEN_ENABLED=1`; `PI_WARDEN_MODE` overrides the mode.
90
94
 
91
- ### Tool-output security and tail compression
95
+ ### Desktop notifications
96
+
97
+ You are rarely watching the terminal while the agent works, so the moments that need you reach the desktop: a held call the agent is about to ask you about, a confirm dialog waiting for an answer, and a runaway stop. macOS uses `osascript` (Notification Center, with a sound); Linux tries `notify-send`, `dunstify`, `gdbus`, `kdialog`, `zenity`, then `powershell.exe` for WSL; Windows shows a toast through PowerShell. The notifier is probed once per session; the text is the reason, never the command. Interactive sessions only — a headless run or a subagent has nobody to call — and one notification per `cooldownMs` (10 s), so sibling holds in one turn do not stack. `"notify": { "enabled": false }` turns it off; `"command": ["curl", "-d", "{body}", "https://ntfy.sh/your-topic"]` in the user file replaces the desktop tool with your own relay (no shell; `{title}`/`{body}` are replaced and also set as `PI_WARDEN_TITLE`/`PI_WARDEN_BODY`). A project file may switch notifications off but never names a command.
98
+
99
+ ### Tool-output security and context saving
92
100
 
93
101
  With consent, content-bearing tools (`read`, fetch/search tools and named MCP equivalents) are checked for instructions that redirect the assistant or request private data. Other tools, including shell/context-mode tools, are checked from 2048 characters. Jev receives a redacted 6000-character head/tail sample, so attacks in an unsampled middle can be missed. A score at or above `security.threshold` adds an untrusted-data notice around the text and a hidden steer. Local credential-shape checks work without network access and warn not to echo or commit possible secrets. Images, result details, error flags, and usage are preserved.
94
102
 
95
103
  Written code gets a `security_risk` question in the existing action request: hardcoded credentials, disabled TLS checks, unsafe shell/SQL interpolation, broad permissions, or bypassed verification. A threshold crossing warns and steers; it does not authorize an action or automatically block otherwise safe edits.
96
104
 
97
- For a single text block of at least `context.tailMinChars`, the same output request asks Jev to choose `all`, `errors_and_summary`, or `summary_only`. The full result stays when P(keep all) exceeds `1 - context.confidence` (default: keep when Jev gives `all` more than 0.2), when the probability is missing, or on any request error. Code—not Jev—keeps bounded diagnostic/head/tail excerpts. An exact copy is written to an owner-only temporary directory before replacement; the excerpt links to it. If storage fails, the original stays. Multiple text blocks keep their layout and are not compressed. Trace and widget show the actual bytes saved. No extra steer is persisted for compression alone.
105
+ **Duplicates (code only).** A single-text-block result of at least `context.duplicateMinChars` (2000) whose content, ignoring colour codes and trailing whitespace, is identical to an earlier result of this session is replaced by a note naming the earlier tool and the size, plus a recall footer. The exact text is stored once; later copies reuse the file. No Jev request is made. Re-running the same failing test command is the typical case: nothing changed, and the note says so.
106
+
107
+ **Retention and format (Jev decides, code applies).** For a single text block of at least `context.tailMinChars`, the same output request asks Jev to choose `all`, `errors_and_summary`, or `summary_only`, and which tool format produced the output: `vitest_jest`, `node_test`, `tsc`, `eslint`, `pytest`, `git_diff`, `git_log`, `npm_install`, or `other`. The full result stays when P(keep all) exceeds `1 - context.confidence` (default: keep when Jev gives `all` more than 0.2), when the probability is missing, or on any request error. When a format has at least `context.formatConfidence` (0.7) and its markers are present, a deterministic parser keeps the exact lines that matter for that format: failing tests with their assertion lines, compiler and linter errors with file and line, changed files with +/- counts, package manager notices, and the final summary line. Otherwise code keeps bounded head/diagnostic/tail excerpts. Nothing is paraphrased. An exact copy is written to an owner-only temporary directory before replacement; the excerpt links to it. If storage fails, the original stays. Multiple text blocks keep their layout and are not compressed. Trace and widget show the actual bytes saved. No extra steer is persisted for compression alone.
108
+
109
+ **Recall through search.** The footer under every excerpt or duplicate note names the file and a search command that exists on this machine, probed once per session in this order: `rg`, `ag`, `ugrep`, `git grep --no-index`, `grep`, PowerShell `Select-String`, `findstr`. It asks for a scoped search or a ranged `read` and says not to read the whole file. `context.recallTool` pins one of those names or `none`; `auto` probes. Recalls are counted by kind: a whole-file read undoes the saving, a scoped access keeps it.
98
110
 
99
- **Measuring it.** `/warden status` keeps a session ledger so the saver's value is a number, not a claim: how many outputs were large enough to consider, how many Jev compressed, the bytes removed (≈ tokens at 4 bytes each), the *token-turns spared* (removed tokens × every later turn, which is what a prompt cache would otherwise have re-read), and **recalls** — how often the agent went back and read the stored full output. A recall means the excerpt was not enough; a recall rate above about 10% says `context.confidence` is too low for your work, near 0% says it could go lower. Every compression and recall also appears in the trace with the running ledger. The quality effect (less context rot) does not show in a counter; compare a scripted task with `context.enabled` on and off if you want that number.
111
+ **Measuring it.** `/warden status` keeps a session ledger so the saver's value is a number, not a claim: how many outputs were large enough to consider, how many Jev compressed, how many duplicates were dropped, the bytes removed (≈ tokens at 4 bytes each), the *token-turns spared* (removed tokens × every later turn, which is what a prompt cache would otherwise have re-read), and **recalls** — how often the agent went back to a stored full output, split into whole-file reads and scoped accesses. A recall means the excerpt was not enough; a recall rate above about 10% says `context.confidence` is too low for your work, near 0% says it could go lower. Whole-file recalls also give back the saving; scoped ones do not. Every compression and recall also appears in the trace with the running ledger. The quality effect (less context rot) does not show in a counter; compare a scripted task with `context.enabled` on and off if you want that number.
100
112
 
101
- This MVP is always tail-only: set `context.enabled: false` to stop compression. It never prunes a warm prefix, changes the system prompt, or edits historical session entries. Full-output files can contain secrets; they remain in the OS temporary directory until you or the OS remove them. Excerpts can omit relevant evidence; read the linked file before making a decision that needs it.
113
+ Set `context.enabled: false` to stop all of it. The saver never prunes a warm prefix, changes the system prompt, changes the tool list after load, or edits historical session entries: every change is to the newest tool result before it enters the session, so the prompt cache prefix is unaffected. Full-output files can contain secrets; they remain in the OS temporary directory until you or the OS remove them. Excerpts can omit relevant evidence; read the linked file before making a decision that needs it.
102
114
 
103
115
  ### Task continuity
104
116
 
@@ -137,11 +149,12 @@ Templates in `config.widget` control the text. Segments are separated by ` · `;
137
149
  "done": "warden · done-check · {changes} changes · {checksPassed}/{checks} checks passed · claims done {claimsDone} · claims verified {claimsVerified} · checks apply {checksApply} · {outcome} · {status}",
138
150
  "prose": "warden · prose · wordy {wordy} · clichés {cliches} · jargon {jargon} · {flags} · {status}",
139
151
  "security": "warden · security · {tool} · injection {injection} · exfiltration {exfiltration} · {status}",
140
- "context": "warden · context · {tool} · {retention} · saved {bytesSaved} bytes"
152
+ "context": "warden · context · {tool} · {retention} · saved {bytesSaved} bytes",
153
+ "runaway": "warden · runaway · {kind} · {count}× repeated · {chars} chars · {signal} · {status}"
141
154
  }
142
155
  ```
143
156
 
144
- Tokens — action: `tool level source irreversible offTask scope approved slop slopStub slopComments slopDead slopHedging patterns reasons path model ms flags time`; prose: `wordy cliches jargon status reasons model ms flags time`; stuck: `failures sameStrategy approachChange progress status source reasons model ms flags time`; done: `changes checks checksPassed claimsDone claimsVerified checksApply outcome status reasons model ms flags time`. Security tokens: `tool injection exfiltration status`; context tokens: `tool retention bytesSaved`. A minimal line: `"action": "⚔ {tool} {level} · {irreversible}/{offTask}"`. Set `"enabled": false` to hide the line (the trace and panel keep working); `"shortcut": ""` disables the keybinding.
157
+ Tokens — action: `tool level source irreversible offTask scope approved slop slopStub slopComments slopDead slopHedging patterns reasons path model ms flags time`; prose: `wordy cliches jargon status reasons model ms flags time`; stuck: `failures sameStrategy approachChange progress status source reasons model ms flags time`; done: `changes checks checksPassed claimsDone claimsVerified checksApply outcome status reasons model ms flags time`. Security tokens: `tool injection exfiltration status`; context tokens: `tool retention bytesSaved`; runaway tokens: `kind count chars signal block status time`. A minimal line: `"action": "⚔ {tool} {level} · {irreversible}/{offTask}"`. Set `"enabled": false` to hide the line (the trace and panel keep working); `"shortcut": ""` disables the keybinding.
145
158
 
146
159
  ## Configuration
147
160
 
@@ -164,7 +177,9 @@ User file `~/.pi/agent/pi-warden/config.json` (owner-only). Missing keys use the
164
177
  "stuck": { "enabled": true, "window": 12, "minFailures": 3, "cooldown": 3, "sameStrategy": 0.7, "nudge": true },
165
178
  "done": { "enabled": true, "claimsDone": 0.7, "nudge": true },
166
179
  "security": { "enabled": true, "threshold": 0.7 },
167
- "context": { "enabled": true, "tailMinChars": 12000, "confidence": 0.8 },
180
+ "context": { "enabled": true, "tailMinChars": 12000, "confidence": 0.8, "duplicateMinChars": 2000, "recallTool": "auto", "formatConfidence": 0.7 },
181
+ "runaway": { "enabled": true, "repeats": 4, "thinkingRepeats": 10, "minChars": 400, "recover": true },
182
+ "notify": { "enabled": true, "cooldownMs": 10000, "command": [] },
168
183
  "slop": {
169
184
  "enabled": true,
170
185
  "threshold": 0.7,
@@ -175,11 +190,11 @@ User file `~/.pi/agent/pi-warden/config.json` (owner-only). Missing keys use the
175
190
  }
176
191
  ```
177
192
 
178
- A project may add `.pi/pi-warden.json` with `enabled` and per-guard overrides (for example stricter thresholds, extra guarded tools, or `"done": { "enabled": false }`). Project files are read only when Pi trusts the project, and they can never grant `typesafe` consent, change `mode`, or raise `timeoutMs`/`maxRequests`. Environment: `PI_WARDEN_ENABLED=1` (consent), `PI_WARDEN_MODE=steer|confirm|advise`. Files from 0.1.x that set `action.timeoutMs`/`action.maxRequests` keep working.
193
+ A project may add `.pi/pi-warden.json` with `enabled` and per-guard overrides (for example stricter thresholds, extra guarded tools, or `"done": { "enabled": false }`). Project files are read only when Pi trusts the project, and they can never grant `typesafe` consent, change `mode`, raise `timeoutMs`/`maxRequests`, or set `notify.command`. Environment: `PI_WARDEN_ENABLED=1` (consent), `PI_WARDEN_MODE=steer|confirm|advise`. Files from 0.1.x that set `action.timeoutMs`/`action.maxRequests` keep working.
179
194
 
180
195
  ## Data handling
181
196
 
182
- - With consent, each guarded call sends to `https://api.typesafe.ai`: your latest prompt (truncated to 1500 characters), the tool name, the command (truncated to 2000 characters) or the file path (relative inside the project, `~`-shortened outside), whether the file exists, a 1500-character head/middle/tail sample for `write`, and the first three edit pairs (400 characters each) for `edit`. The stuck detector sends the last 12 tool calls (300 characters each) with 400-character output tails; the done-check and the prose check send the agent's final message (2000 and 2500 characters) with the run's check commands and the audience description. Scope and retention requests also carry up to eight prior user/assistant text messages (750 redacted characters each). Output checks send a redacted head/tail sample up to 6000 characters plus size and tool name. No unrelated files or telemetry.
197
+ - With consent, each guarded call sends to `https://api.typesafe.ai`: your latest prompt (truncated to 1500 characters), the tool name, the command (truncated to 2000 characters) or the file path (relative inside the project, `~`-shortened outside), whether the file exists, a 1500-character head/middle/tail sample for `write`, and the first three edit pairs (400 characters each) for `edit`. The stuck detector sends the last 12 tool calls (300 characters each) with 400-character output tails; the done-check and the prose check send the agent's final message (2000 and 2500 characters) with the run's check commands and the audience description. Scope and retention requests also carry up to eight prior user/assistant text messages (750 redacted characters each). Output checks send a redacted head/tail sample up to 6000 characters plus size, line counts, and tool name; the format question uses that same sample. Duplicate detection and the runaway guard send nothing. No unrelated files or telemetry.
183
198
  - Obvious credentials in the action (`Authorization` headers, `TOKEN=`/`SECRET=` assignments, `sk-`, `ghp_`, `AKIA`, JWTs, URL passwords, PEM blocks) are replaced with `[redacted]` before sending. This is best-effort; do not rely on it for prompts that contain secrets.
184
199
  - Text returned or steered to the agent names the tool, the reasons, and the scores, not the command text. UI errors never include upstream response bodies or keys.
185
200
  - Judgments are model output. Thresholds are yours to tune; a hold is information for the agent and for you, not a verdict on either.
@@ -208,15 +223,16 @@ steerReason(verdict, { canApprove: true }); // the text the agent receives for
208
223
 
209
224
  `evaluateAction` judges one call. What spans calls in a session, the hold → reply → retry approval and the batched judging of sibling tool calls, is `ActionGuard`: `inspect(call, conversation, options)` returns the same `Verdict`, `hold(task)` records a hold, `turnEnd()` and `reset()` follow Pi's turn and session.
210
225
 
211
- Also exported: `matchPatterns`, `isReadOnlyCommand`, `describeAction`, `redact`, `formatVerdict`, the question sets, the stuck detector (`AttemptWindow`, `makeAttempt`, `evaluateStuck`, `stuckNudge`), the done-check (`classifyToolResult`, `recordOutcome`, `needsDoneCheck`, `evaluateDone`, `doneNudge`), and the config helpers.
226
+ Also exported: `matchPatterns`, `isReadOnlyCommand`, `describeAction`, `redact`, `formatVerdict`, the question sets, the stuck detector (`AttemptWindow`, `makeAttempt`, `evaluateStuck`, `stuckNudge`), the runaway guard (`RunawayMonitor`, `findRepeats`, `runawayNudge`), the notifier (`detectNotifier`, `notifierCommand`, `sendNotification`), the done-check (`classifyToolResult`, `recordOutcome`, `needsDoneCheck`, `evaluateDone`, `doneNudge`), and the config helpers.
212
227
 
213
228
  ## Development
214
229
 
215
230
  ```bash
216
231
  npm install
217
232
  npm run check # typecheck, offline tests (mocked transport), build
218
- npm run test:live # 43 billable synthetic judgments across all guards (key from .env or the stored login); pass action|slop|approval|stuck|done|security for one group
219
- node scripts/security-cases.mjs [context] # the 12-case output-security, task-continuity, and retention set on its own
233
+ npm run test:live # 55 billable synthetic judgments across all guards (key from .env or the stored login); pass action|slop|approval|stuck|done|security|context for one group
234
+ node scripts/security-cases.mjs # the 9-case output-security and task-continuity set on its own
235
+ node scripts/context-cases.mjs # 15 labelled outputs that tune retention and format for the context saver
220
236
  npm run dev:pi # start Pi with this working tree plus an installed pi-typesafe
221
237
  node scripts/slop-cases.mjs # 22 billable cases that tune the slop and prose questions
222
238
  npm run preview # re-render docs/preview.png from the recorded live verdicts (needs a Chrome binary)
package/dist/config.d.ts CHANGED
@@ -64,7 +64,37 @@ export interface ContextConfig {
64
64
  tailMinChars: number;
65
65
  /** Minimum P(the full output is not needed), 1 - P(all), before code removes output. */
66
66
  confidence: number;
67
+ /** A text result at least this long that repeats an earlier result of this session is replaced by a short note (code only). */
68
+ duplicateMinChars: number;
69
+ /** Search command named in the recall footer; `auto` detects one at load. */
70
+ recallTool: RecallTool;
71
+ /** Minimum P(format) before a format-specific parser builds the excerpt instead of the generic head/tail one. */
72
+ formatConfidence: number;
67
73
  }
74
+ export interface RunawayConfig {
75
+ enabled: boolean;
76
+ /** Identical text paragraphs in one streaming reply before the run is stopped. Ordinary replies repeat a paragraph twice at most. */
77
+ repeats: number;
78
+ /** The same limit for thinking, where code drafting repeats paragraphs legitimately. */
79
+ thinkingRepeats: number;
80
+ /** Characters streamed before the first check. */
81
+ minChars: number;
82
+ /** After stopping, start one follow-up turn that names the repeat and asks for the one next step; once per user prompt. */
83
+ recover: boolean;
84
+ }
85
+ export interface NotifyConfig {
86
+ /** Desktop notification when the agent needs you: a held call it will ask about, a confirm dialog, a stopped runaway. */
87
+ enabled: boolean;
88
+ /** Sibling holds in one turn produce one notification; a second within this many milliseconds is skipped. */
89
+ cooldownMs: number;
90
+ /**
91
+ * Your own notifier as an argv (no shell), for ssh sessions or a phone relay: `{title}` and `{body}` in an argument are
92
+ * replaced, and both are in PI_WARDEN_TITLE / PI_WARDEN_BODY. Empty: detect the desktop's own tool. User file only.
93
+ */
94
+ command: string[];
95
+ }
96
+ export type RecallTool = "auto" | "rg" | "ag" | "ugrep" | "git-grep" | "grep" | "select-string" | "findstr" | "none";
97
+ export declare function isRecallTool(value: unknown): value is RecallTool;
68
98
  export type WardenMode = "steer" | "confirm" | "advise";
69
99
  export interface WardenConfig {
70
100
  /** Master switch. false disables every guard, including offline pattern checks. */
@@ -87,6 +117,8 @@ export interface WardenConfig {
87
117
  slop: SlopGuardConfig;
88
118
  security: SecurityConfig;
89
119
  context: ContextConfig;
120
+ runaway: RunawayConfig;
121
+ notify: NotifyConfig;
90
122
  /** The status line above the editor and the trace panel. */
91
123
  widget: WidgetConfig;
92
124
  /** Show steer messages in the transcript. They are always visible in the trace panel. */
@@ -94,7 +126,7 @@ export interface WardenConfig {
94
126
  }
95
127
  export declare const PACKAGE_NAME = "pi-warden";
96
128
  /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
97
- export declare const CONFIG_SCHEMA = 3;
129
+ export declare const CONFIG_SCHEMA = 4;
98
130
  export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
99
131
  export declare function defaultConfig(): WardenConfig;
100
132
  /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
package/dist/config.js CHANGED
@@ -3,9 +3,13 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { COMMAND_TOOLS } from "./tools.js";
5
5
  import { defaultWidgetConfig } from "./widget.js";
6
+ const RECALL_TOOLS = ["auto", "rg", "ag", "ugrep", "git-grep", "grep", "select-string", "findstr", "none"];
7
+ export function isRecallTool(value) {
8
+ return typeof value === "string" && RECALL_TOOLS.includes(value);
9
+ }
6
10
  export const PACKAGE_NAME = "pi-warden";
7
11
  /** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
8
- export const CONFIG_SCHEMA = 3;
12
+ export const CONFIG_SCHEMA = 4;
9
13
  export const PROJECT_CONFIG_FILE = `${PACKAGE_NAME}.json`;
10
14
  export function defaultConfig() {
11
15
  return {
@@ -26,7 +30,9 @@ export function defaultConfig() {
26
30
  done: { enabled: true, claimsDone: 0.7, nudge: true },
27
31
  slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
28
32
  security: { enabled: true, threshold: 0.7 },
29
- context: { enabled: true, tailMinChars: 12000, confidence: 0.8 },
33
+ context: { enabled: true, tailMinChars: 12000, confidence: 0.8, duplicateMinChars: 2000, recallTool: "auto", formatConfidence: 0.7 },
34
+ runaway: { enabled: true, repeats: 4, thinkingRepeats: 10, minChars: 400, recover: true },
35
+ notify: { enabled: true, cooldownMs: 10000, command: [] },
30
36
  widget: defaultWidgetConfig(),
31
37
  steerVisible: false,
32
38
  };
@@ -100,6 +106,26 @@ function applyStuck(base, raw) {
100
106
  nudge: boolean(raw.nudge, base.nudge),
101
107
  };
102
108
  }
109
+ function applyRunaway(base, raw) {
110
+ if (!isObject(raw))
111
+ return base;
112
+ return {
113
+ enabled: boolean(raw.enabled, base.enabled),
114
+ // One occurrence is not a repeat, so the floor is 2.
115
+ repeats: Math.max(2, positiveInteger(raw.repeats, base.repeats)),
116
+ thinkingRepeats: Math.max(2, positiveInteger(raw.thinkingRepeats, base.thinkingRepeats)),
117
+ minChars: positiveInteger(raw.minChars, base.minChars),
118
+ recover: boolean(raw.recover, base.recover),
119
+ };
120
+ }
121
+ function applyNotify(base, raw, allowCommand) {
122
+ if (!isObject(raw))
123
+ return base;
124
+ const cooldown = typeof raw.cooldownMs === "number" && Number.isSafeInteger(raw.cooldownMs) && raw.cooldownMs >= 0 ? raw.cooldownMs : base.cooldownMs;
125
+ const command = allowCommand && Array.isArray(raw.command) && raw.command.every((arg) => typeof arg === "string") && (raw.command.length === 0 || raw.command[0].trim())
126
+ ? [...raw.command] : base.command;
127
+ return { enabled: boolean(raw.enabled, base.enabled), cooldownMs: cooldown, command };
128
+ }
103
129
  function applyDone(base, raw) {
104
130
  if (!isObject(raw))
105
131
  return base;
@@ -138,6 +164,7 @@ function applyWidget(base, raw) {
138
164
  prose: template(raw.prose, base.prose),
139
165
  security: template(raw.security, base.security),
140
166
  context: template(raw.context, base.context),
167
+ runaway: template(raw.runaway, base.runaway),
141
168
  };
142
169
  }
143
170
  /** Shared request settings; `action.timeoutMs`/`action.maxRequests` from 0.1.x files are still honoured. */
@@ -148,8 +175,11 @@ function applyShared(base, raw) {
148
175
  maxRequests: positiveInteger(raw.maxRequests ?? legacy.maxRequests, base.maxRequests),
149
176
  };
150
177
  }
151
- function applyGuards(base, raw, timeoutMs) {
178
+ function applyGuards(base, raw, timeoutMs, source) {
152
179
  return {
180
+ runaway: applyRunaway(base.runaway, raw.runaway),
181
+ // A project file may switch notifications off or on, but never names a command to run.
182
+ notify: applyNotify(base.notify, raw.notify, source === "user"),
153
183
  action: applyAction(base.action, raw.action, timeoutMs),
154
184
  stuck: applyStuck(base.stuck, raw.stuck),
155
185
  done: applyDone(base.done, raw.done),
@@ -162,6 +192,9 @@ function applyGuards(base, raw, timeoutMs) {
162
192
  enabled: boolean(raw.context.enabled, base.context.enabled),
163
193
  tailMinChars: positiveInteger(raw.context.tailMinChars, base.context.tailMinChars),
164
194
  confidence: probability(raw.context.confidence, base.context.confidence),
195
+ duplicateMinChars: positiveInteger(raw.context.duplicateMinChars, base.context.duplicateMinChars),
196
+ recallTool: isRecallTool(raw.context.recallTool) ? raw.context.recallTool : base.context.recallTool,
197
+ formatConfidence: probability(raw.context.formatConfidence, base.context.formatConfidence),
165
198
  } : base.context,
166
199
  };
167
200
  }
@@ -175,7 +208,7 @@ export function applyUserOverrides(base, raw) {
175
208
  typesafe: boolean(raw.typesafe, base.typesafe),
176
209
  mode: isMode(raw.mode) ? raw.mode : base.mode,
177
210
  ...shared,
178
- ...applyGuards(base, raw, shared.timeoutMs),
211
+ ...applyGuards(base, raw, shared.timeoutMs, "user"),
179
212
  widget: applyWidget(base.widget, raw.widget),
180
213
  steerVisible: boolean(raw.steerVisible, base.steerVisible),
181
214
  };
@@ -184,7 +217,7 @@ export function applyUserOverrides(base, raw) {
184
217
  export function applyProjectOverrides(base, raw) {
185
218
  if (!isObject(raw))
186
219
  return base;
187
- return { ...base, enabled: boolean(raw.enabled, base.enabled), ...applyGuards(base, raw, base.timeoutMs) };
220
+ return { ...base, enabled: boolean(raw.enabled, base.enabled), ...applyGuards(base, raw, base.timeoutMs, "project") };
188
221
  }
189
222
  export function loadConfig(options = {}) {
190
223
  let config = applyUserOverrides(defaultConfig(), readJson(userConfigPath()));
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Format-aware excerpts. Jev selects which tool produced the output; code keeps the exact lines that matter for that
3
+ * format: failing tests with their assertion lines, compiler and linter errors with file:line, changed files with
4
+ * counts, package manager warnings, and the final summary. Nothing is paraphrased. A parser returns undefined when
5
+ * it does not recognise its markers, and the caller falls back to the generic head/diagnostic/tail excerpt.
6
+ */
7
+ export type OutputFormat = "vitest_jest" | "node_test" | "tsc" | "eslint" | "pytest" | "git_diff" | "git_log" | "npm_install" | "other";
8
+ export declare const formatQuestion: {
9
+ format: import("pi-typesafe").ChoiceQuestion<{
10
+ readonly vitest_jest: "A Vitest or Jest run: ✓/✗/× or PASS/FAIL per test, a 'Test Files' or 'Tests:' summary.";
11
+ readonly node_test: "Node's built-in test runner: TAP lines 'ok'/'not ok' with '# tests', or the spec reporter with ✔/✖ and 'ℹ tests'.";
12
+ readonly tsc: "TypeScript compiler diagnostics: 'file(line,col): error TSnnnn:' lines, 'Found N errors'.";
13
+ readonly eslint: "ESLint report: a file path line followed by 'line:col error|warning message rule', then '✖ N problems'.";
14
+ readonly pytest: "pytest: dots/F per test, '___ test_name ___' failure headers, 'E ' assertion lines, 'FAILED'/'PASSED' lines, '=== N passed/failed ===' summary.";
15
+ readonly git_diff: "A unified diff: 'diff --git', '---'/'+++', '@@' hunks with +/- lines.";
16
+ readonly git_log: "git log or git status output: 'commit <hash>' blocks or one-line hashes with subjects; 'modified:'/'new file:' status lines.";
17
+ readonly npm_install: "npm, pnpm, or yarn install: 'added N packages', 'npm warn', 'deprecated', 'vulnerabilities', 'up to date'.";
18
+ readonly other: "Anything else: source code, data, documentation, a shell listing, a mixed or unknown log.";
19
+ }>;
20
+ };
21
+ /**
22
+ * Exact lines selected by the parser for `format`, joined and capped, with the last non-empty lines as a tail so the
23
+ * final status is always present. Undefined when the format is `other` or its markers are absent.
24
+ */
25
+ export declare function formatExcerpt(text: string, format: OutputFormat): string | undefined;
@@ -0,0 +1,171 @@
1
+ import { choice } from "pi-typesafe";
2
+ export const formatQuestion = {
3
+ format: choice("Which kind of tool produced `output`? Judge from the markers in the sample; `tool` names the calling tool, not the program. Select other when unsure or when the output is source code, data, or prose.", {
4
+ vitest_jest: "A Vitest or Jest run: ✓/✗/× or PASS/FAIL per test, a 'Test Files' or 'Tests:' summary.",
5
+ node_test: "Node's built-in test runner: TAP lines 'ok'/'not ok' with '# tests', or the spec reporter with ✔/✖ and 'ℹ tests'.",
6
+ tsc: "TypeScript compiler diagnostics: 'file(line,col): error TSnnnn:' lines, 'Found N errors'.",
7
+ eslint: "ESLint report: a file path line followed by 'line:col error|warning message rule', then '✖ N problems'.",
8
+ pytest: "pytest: dots/F per test, '___ test_name ___' failure headers, 'E ' assertion lines, 'FAILED'/'PASSED' lines, '=== N passed/failed ===' summary.",
9
+ git_diff: "A unified diff: 'diff --git', '---'/'+++', '@@' hunks with +/- lines.",
10
+ git_log: "git log or git status output: 'commit <hash>' blocks or one-line hashes with subjects; 'modified:'/'new file:' status lines.",
11
+ npm_install: "npm, pnpm, or yarn install: 'added N packages', 'npm warn', 'deprecated', 'vulnerabilities', 'up to date'.",
12
+ other: "Anything else: source code, data, documentation, a shell listing, a mixed or unknown log.",
13
+ }),
14
+ };
15
+ const LIMIT = 6000;
16
+ /** Header lines start a block; the following non-empty lines up to `depth` are kept with it. */
17
+ function capture(lines, header, keep, depth, summary) {
18
+ const out = [];
19
+ let headers = 0;
20
+ let remaining = 0;
21
+ for (const line of lines) {
22
+ if (header.test(line)) {
23
+ headers++;
24
+ remaining = depth;
25
+ out.push(line);
26
+ continue;
27
+ }
28
+ if (summary.test(line)) {
29
+ out.push(line);
30
+ remaining = 0;
31
+ continue;
32
+ }
33
+ if (remaining > 0 && line.trim()) {
34
+ if (keep.test(line))
35
+ out.push(line);
36
+ remaining--;
37
+ }
38
+ }
39
+ return headers || out.length ? out : undefined;
40
+ }
41
+ function vitestJest(lines) {
42
+ const summary = /^\s*(?:Test Files|Tests|Test Suites|Snapshots|Duration|Time|Start at|Ran all test suites)\b|^\s*(?:FAIL|PASS)\s{2,}\S/;
43
+ const out = capture(lines, /^\s*(?:FAIL\s|[×✗✕]\s|●\s|❯\s.*\s\d+\s*$|AssertionError\b)/, /expected|received|actual|Error|assert|❯|›|\bat\b.*:\d+:\d+|[+-]\s|^\s*\d+\|/i, 8, summary);
44
+ return out && lines.some(line => summary.test(line) || /[✓✗×✔✖]/.test(line)) ? out : undefined;
45
+ }
46
+ function nodeTest(lines) {
47
+ const summary = /^(?:#|ℹ)\s*(?:tests|suites|pass|fail|cancelled|skipped|todo|duration_ms)\b/;
48
+ if (!lines.some(line => summary.test(line) || /^TAP version/.test(line) || /^\s*[✔✖]\s/.test(line)))
49
+ return undefined;
50
+ return capture(lines, /^\s*(?:not ok\b|✖\s)/, /error|expected|actual|Error|failureType|code|at\b.*:\d+:\d+|^\s*[+-]\s|^\s*\|/i, 10, summary) ?? [];
51
+ }
52
+ function tsc(lines) {
53
+ const header = /^(?:\S.*?)(?:\(\d+,\d+\)|:\d+:\d+)\s*[-:]\s*error TS\d+/;
54
+ if (!lines.some(line => header.test(line) || /^Found \d+ errors?/.test(line)))
55
+ return undefined;
56
+ return capture(lines, header, /^\s{2,}\S/, 3, /^Found \d+ errors?|^error TS\d+/);
57
+ }
58
+ function eslint(lines) {
59
+ const problem = /^\s+\d+:\d+\s+(?:error|warning)\s+/;
60
+ if (!lines.some(line => problem.test(line)) || !lines.some(line => /^✖ \d+ problems?|^\d+ problems? \(/.test(line) || /\S+\.[cm]?[jt]sx?$|\.vue$|\.svelte$/.test(line)))
61
+ return undefined;
62
+ const out = [];
63
+ let file;
64
+ for (const line of lines) {
65
+ if (/^\S.*\.(?:[cm]?[jt]sx?|vue|svelte|json|md)$/.test(line)) {
66
+ file = line;
67
+ continue;
68
+ }
69
+ if (problem.test(line)) {
70
+ if (file) {
71
+ out.push(file);
72
+ file = undefined;
73
+ }
74
+ out.push(line);
75
+ continue;
76
+ }
77
+ if (/^✖ \d+ problems?|^\d+ problems? \(|potentially fixable/.test(line))
78
+ out.push(line);
79
+ }
80
+ return out;
81
+ }
82
+ function pytest(lines) {
83
+ const summary = /^=+ .*(?:passed|failed|error|skipped|no tests ran).* =+$|^=+ short test summary info =+$|^(?:FAILED|ERROR) \S+/;
84
+ if (!lines.some(line => summary.test(line) || /^_{3,} .+ _{3,}$/.test(line)))
85
+ return undefined;
86
+ return capture(lines, /^_{3,} .+ _{3,}$/, /^E\s|^>\s|:\d+: |Error|assert/i, 12, summary) ?? [];
87
+ }
88
+ function gitDiff(lines) {
89
+ const files = [];
90
+ for (const line of lines) {
91
+ const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
92
+ if (match) {
93
+ files.push({ name: match[2], added: 0, removed: 0 });
94
+ continue;
95
+ }
96
+ const current = files.at(-1);
97
+ if (!current)
98
+ continue;
99
+ if (/^(?:new file mode|deleted file mode|rename (?:from|to)|Binary files)/.test(line))
100
+ current.note = (current.note ? `${current.note}; ` : "") + line;
101
+ else if (line.startsWith("+") && !line.startsWith("+++"))
102
+ current.added++;
103
+ else if (line.startsWith("-") && !line.startsWith("---"))
104
+ current.removed++;
105
+ }
106
+ if (!files.length)
107
+ return undefined;
108
+ const added = files.reduce((sum, file) => sum + file.added, 0);
109
+ const removed = files.reduce((sum, file) => sum + file.removed, 0);
110
+ return [...files.map(file => `${file.name} +${file.added} -${file.removed}${file.note ? ` (${file.note})` : ""}`), `${files.length} files changed, +${added} -${removed}`];
111
+ }
112
+ function gitLog(lines) {
113
+ const out = [];
114
+ let pendingSubject = false;
115
+ for (const line of lines) {
116
+ const commit = /^commit ([0-9a-f]{7,40})\b/.exec(line);
117
+ if (commit) {
118
+ out.push(commit[1].slice(0, 12));
119
+ pendingSubject = true;
120
+ continue;
121
+ }
122
+ if (pendingSubject && /^\s{4}\S/.test(line)) {
123
+ out[out.length - 1] += ` ${line.trim()}`;
124
+ pendingSubject = false;
125
+ continue;
126
+ }
127
+ if (/^[0-9a-f]{7,40} \S/.test(line) || /^\s*(?:modified|new file|deleted|renamed|both modified):|^\?\?\s|^[ MADRCU?!]{2}\s\S/.test(line) || /^(?:On branch|Your branch|Changes (?:to be committed|not staged)|Untracked files|nothing to commit)/.test(line))
128
+ out.push(line);
129
+ }
130
+ return out.length ? out : undefined;
131
+ }
132
+ function npmInstall(lines) {
133
+ const keep = /^(?:added|removed|changed|audited|found|up to date|up-to-date)\b|\bvulnerabilit(?:y|ies)\b|^npm (?:warn|WARN|error|ERR!|notice)\b|\bdeprecated\b|^(?:warning|error)\s|^\s*Progress: resolved|^Done in|^Packages: /i;
134
+ const out = lines.filter(line => keep.test(line));
135
+ return out.some(line => /^(?:added|removed|changed|audited|up to date|up-to-date|Done in|Packages:)/i.test(line)) ? out : undefined;
136
+ }
137
+ const PARSERS = {
138
+ vitest_jest: vitestJest, node_test: nodeTest, tsc, eslint, pytest, git_diff: gitDiff, git_log: gitLog, npm_install: npmInstall,
139
+ };
140
+ /**
141
+ * Exact lines selected by the parser for `format`, joined and capped, with the last non-empty lines as a tail so the
142
+ * final status is always present. Undefined when the format is `other` or its markers are absent.
143
+ */
144
+ export function formatExcerpt(text, format) {
145
+ if (format === "other")
146
+ return undefined;
147
+ const lines = text.split("\n");
148
+ const selected = PARSERS[format](lines);
149
+ if (!selected)
150
+ return undefined;
151
+ const tail = lines.filter(line => line.trim()).slice(-3);
152
+ const body = [];
153
+ let size = 0;
154
+ let dropped = 0;
155
+ for (const line of selected) {
156
+ const clipped = line.length > 400 ? `${line.slice(0, 400)}…` : line;
157
+ if (size + clipped.length + 1 > LIMIT - 400) {
158
+ dropped++;
159
+ continue;
160
+ }
161
+ body.push(clipped);
162
+ size += clipped.length + 1;
163
+ }
164
+ const parts = [`[${format} excerpt: ${selected.length} selected lines${dropped ? `, ${dropped} omitted for size` : ""}]`, ...body];
165
+ // The final status line is the one the agent reads first; add the tail only when the parser did not already keep it.
166
+ // Diffs and logs have no status line; their last lines are hunk or body text.
167
+ const last = tail.at(-1);
168
+ if (format !== "git_diff" && format !== "git_log" && last !== undefined && !body.includes(last))
169
+ parts.push("[tail]", ...tail.map(line => line.length > 400 ? `${line.slice(0, 400)}…` : line));
170
+ return parts.join("\n");
171
+ }
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { ToolCallRef } from "./action-guard.js";
3
3
  import type { SlopSymptom, Verdict } from "./guard.js";
4
- export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; and redacted tool-output samples for security and tail compression. Compression stores an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
4
+ export declare const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request and up to eight redacted prior user/assistant text messages for task context, plus a redacted, truncated summary of each guarded bash, write, or edit call before it runs; the last few tool calls and output tails when the agent keeps failing; the agent's final message when it reports completion without running checks; and redacted tool-output samples for security and context saving (retention and output format). Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
5
5
  /**
6
6
  * Tool calls of the assistant message being preflighted. Pi runs `tool_call` hooks for sibling calls one after another,
7
7
  * so judging them one request at a time costs one round trip per call; judging them together costs one round trip.