pi-warden 0.5.1 → 0.5.3

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. Three more guards watch the run: a **stuck-loop** detector, a **done-check** for completion claims that no test backed up, and a **slop** note for stubs and filler. 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 **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).
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
 
@@ -52,7 +52,7 @@ A `TYPESAFE_API_KEY` environment variable takes precedence over the stored key.
52
52
 
53
53
  1. **Skip** read-only tools, and read-only shell lines such as `git status && ls` (no network, no widget). Guarded by default: `bash`, `powershell`, `write`, `edit`, and context-mode's `ctx_execute`, `ctx_execute_file`, `ctx_batch_execute` (their `code`/`commands` fields are read as the command; non-shell code such as JavaScript is judged but never skipped as read-only). Add other tools with `action.tools`; unknown tools are judged from their JSON input.
54
54
  2. **Pattern pass** (offline): force pushes, `git reset --hard`, `git clean -f`, recursive `rm` on absolute/home/variable/parent paths, SQL `DROP`/`TRUNCATE`/`DELETE FROM`, block-device writes, `chmod -R 777`, fork bombs, `curl | sh`, `kill -1`, shutdown, package publishing, infrastructure destroys → **hold**. `rm -rf` on a project path, `git checkout -- .`, `git branch -D`, `git stash drop`, `find -delete`, `sudo` → **warn**. Reads or writes of `.env`, SSH, AWS, npm, kube, and other credential files → **warn**. A `write` that overwrites an existing file outside the project → **hold**; creating or editing outside the project → **warn**.
55
- 3. **Jev judgment** (with consent): one request with the state `{ task, action }` and three questions — `irreversible` (Noul), `off_task` (Noul), `scope` (Choice: expected step, plausible side step, unrelated, unclear). Defaults: irreversible ≥ 0.5 warns, ≥ 0.7 holds; off-task ≥ 0.6 warns, ≥ 0.85 with `unrelated` holds. Pattern results set the floor; Jev can only raise it. For `write`/`edit`, two slop questions ride on the same request.
55
+ 3. **Jev judgment** (with consent): one request with the state `{ task, context, action }` and four questions — `irreversible` (Noul), `off_task` (Noul), `mutates` (Noul: does it change anything), `scope` (Choice: expected step, plausible side step, unrelated, unclear). Defaults: irreversible ≥ 0.5 warns, ≥ 0.7 holds; off-task ≥ 0.6 warns, ≥ 0.85 with `unrelated` holds **only when the action can change something** (`write`/`edit`, or `mutates` ≥ 0.5). An unrelated `cat`/`grep`/`node -e` inspection or a read-only `ctx_execute` script is warned about, never held: scope alone is not worth blocking a command that cannot do damage. Pattern results set the floor; Jev can only raise it. For `write`/`edit`, the slop and `security_risk` questions ride on the same request.
56
56
  4. **Act**, by mode:
57
57
  - `steer` (default): a hold blocks the call and returns the judgment to the agent as the tool result, with the two acceptable next moves. You see a notification and the widget line; the agent keeps working. When the agent asks you and your reply approves the action (Jev: `approved` ≥ 0.7, or an offline yes/go-ahead heuristic without consent), the identical retry is allowed once.
58
58
  - `confirm`: a hold opens a `ctx.ui.confirm` dialog; No blocks with a short reason. Without a UI this falls back to `steer`.
@@ -96,6 +96,8 @@ Written code gets a `security_risk` question in the existing action request: har
96
96
 
97
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.
98
98
 
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.
100
+
99
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.
100
102
 
101
103
  ### Task continuity
@@ -184,7 +186,7 @@ A project may add `.pi/pi-warden.json` with `enabled` and per-guard overrides (f
184
186
 
185
187
  ### After updating the package
186
188
 
187
- Run `/reload` in each existing Pi session after an update. If an existing session still reports `Cannot read properties of undefined (reading 'enabled')` while a fresh session works, restart that session to replace loaded modules. Version labels alone do not prove that all in-memory modules match the files on disk. Current config loading merges missing legacy fields with defaults; regression tests cover malformed and pre-prose config files. A persistent crash after restart needs its new stack trace and a redacted config example.
189
+ Run `/reload` in each existing Pi session after an update. If an existing session still reports `Cannot read properties of undefined (reading 'enabled')` while a fresh session works, restart that session to replace loaded modules. Version labels alone do not prove that all in-memory modules match the files on disk. Current config loading merges missing legacy fields with defaults; regression tests cover malformed and pre-prose config files. Since 0.5.2 the extension also checks the shape of the config it receives: if a section is missing (the symptom of an extension module and a config module from different package versions in one process), that guard is switched off and one warning names the missing sections and the schema numbers instead of throwing inside Pi. If you see that warning, restart Pi. A crash that survives a restart needs its stack trace and a redacted config example.
188
190
 
189
191
  ## For extension authors
190
192
 
package/dist/config.d.ts CHANGED
@@ -93,6 +93,8 @@ export interface WardenConfig {
93
93
  steerVisible: boolean;
94
94
  }
95
95
  export declare const PACKAGE_NAME = "pi-warden";
96
+ /** 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;
96
98
  export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
97
99
  export declare function defaultConfig(): WardenConfig;
98
100
  /** Mirrors Pi's agent directory rule so the file sits next to pi-typesafe's auth.json. */
package/dist/config.js CHANGED
@@ -4,6 +4,8 @@ import { dirname, join } from "node:path";
4
4
  import { COMMAND_TOOLS } from "./tools.js";
5
5
  import { defaultWidgetConfig } from "./widget.js";
6
6
  export const PACKAGE_NAME = "pi-warden";
7
+ /** 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;
7
9
  export const PROJECT_CONFIG_FILE = `${PACKAGE_NAME}.json`;
8
10
  export function defaultConfig() {
9
11
  return {
package/dist/extension.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { MouseRegion, Text } from "@earendil-works/pi-tui";
2
2
  import { createTypeSafe, resolveApiKey } from "pi-typesafe";
3
3
  import { ensureApiKey } from "pi-typesafe/ui";
4
+ import * as configModule from "./config.js";
4
5
  import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
5
6
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome } from "./done.js";
6
7
  import { evaluateAction, formatVerdict, SLOP_LABELS, steerReason, textApproves } from "./guard.js";
@@ -9,6 +10,8 @@ import { compressOutput, evaluateOutput, saveOutput, securityNotice } from "./ou
9
10
  import { redact } from "./redact.js";
10
11
  import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
11
12
  import { openTracePanel } from "./panel.js";
13
+ import { completeConfig, shapeWarning } from "./shape.js";
14
+ import { ContextLedger, formatLedger } from "./saver.js";
12
15
  import { actionDetails, doneDetails, proseDetails, stuckDetails, Trace } from "./trace.js";
13
16
  import { proseTokens, renderTemplate, TOKEN_NAMES } from "./widget.js";
14
17
  export 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.";
@@ -103,7 +106,22 @@ export default function wardenExtension(pi) {
103
106
  let doneNudged = false;
104
107
  const prose = new ProseTrend();
105
108
  const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
106
- const configFor = (ctx) => loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
109
+ const ledger = new ContextLedger();
110
+ // A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
111
+ let shapeReported = false;
112
+ const configFor = (ctx) => {
113
+ const { config, missing } = completeConfig(loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() }));
114
+ if (missing.length && !shapeReported) {
115
+ shapeReported = true;
116
+ // A namespace read stays undefined (not a link error) when an older config module lacks the export.
117
+ const text = shapeWarning(missing, configModule.CONFIG_SCHEMA);
118
+ if (ctx.hasUI)
119
+ ctx.ui.notify(text, "warning");
120
+ else
121
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true });
122
+ }
123
+ return config;
124
+ };
107
125
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
108
126
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
109
127
  const judgeFor = (config) => {
@@ -166,6 +184,7 @@ export default function wardenExtension(pi) {
166
184
  evidence = emptyEvidence();
167
185
  doneNudged = false;
168
186
  prose.reset();
187
+ ledger.reset();
169
188
  for (const symptom of Object.keys(slopCounts))
170
189
  slopCounts[symptom] = 0;
171
190
  if (ctx.hasUI)
@@ -180,9 +199,19 @@ export default function wardenExtension(pi) {
180
199
  pi.on("agent_start", async () => {
181
200
  evidence = emptyEvidence();
182
201
  });
202
+ // Every turn that runs after a compression is a turn that did not carry the removed text.
203
+ pi.on("turn_end", async () => {
204
+ ledger.turnEnd();
205
+ });
183
206
  pi.on("tool_call", async (event, ctx) => {
184
207
  const config = configFor(ctx);
185
- if (!config.enabled || !config.action.enabled || !config.action.tools.includes(event.toolName))
208
+ if (!config.enabled)
209
+ return;
210
+ // A read of a stored full output means the excerpt was not enough; that is the number that tunes context.confidence.
211
+ const recalled = ledger.noteAccess(JSON.stringify(event.input));
212
+ if (recalled)
213
+ record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: "full output recalled" }), [`the agent went back to ${recalled}`, formatLedger(ledger.snapshot())]);
214
+ if (!config.action.enabled || !config.action.tools.includes(event.toolName))
186
215
  return;
187
216
  stats.inspected++;
188
217
  const task = latestUserPrompt(ctx);
@@ -267,6 +296,8 @@ export default function wardenExtension(pi) {
267
296
  noteError(ctx, output.error, output.errorCode);
268
297
  let content = event.content;
269
298
  const notice = securityNotice(output);
299
+ if (config.context.enabled && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
300
+ ledger.candidate();
270
301
  const excerpt = compressOutput(text, output.retention);
271
302
  if (excerpt && !ctx.signal?.aborted) {
272
303
  try {
@@ -275,9 +306,11 @@ export default function wardenExtension(pi) {
275
306
  const bytesSaved = Buffer.byteLength(text) - Buffer.byteLength(replacement) - (notice ? Buffer.byteLength(notice) * 2 + 4 : 0);
276
307
  if (bytesSaved > 0) {
277
308
  content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
309
+ ledger.record(path, bytesSaved);
278
310
  record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: output.retention, bytesSaved: String(bytesSaved) }), [
279
311
  `retention: ${output.retention}; confidence ${output.confidence?.toFixed(2)}; ${output.model}; ${output.elapsedMs} ms`,
280
312
  `saved ${bytesSaved} bytes; full output: ${path}`,
313
+ formatLedger(ledger.snapshot()),
281
314
  ]);
282
315
  }
283
316
  }
@@ -411,6 +444,7 @@ export default function wardenExtension(pi) {
411
444
  `pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `enabled via ${source}` : "disabled (run /warden enable)"}; key ${key ? key.source === "stored" ? "stored (shared with pi-typesafe)" : "from TYPESAFE_API_KEY" : "missing (run /warden enable)"}.`,
412
445
  `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.slop} slop notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.errors} TypeSafe errors; ${usage?.requestsStarted ?? 0}/${config.maxRequests} requests. Steers are ${config.steerVisible ? "shown in the transcript" : "hidden from the transcript (trace panel shows them)"}.`,
413
446
  `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / hold ${config.action.offTask.confirm}; stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; failOpen ${config.action.failOpen}.`,
447
+ formatLedger(ledger.snapshot()),
414
448
  `Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
415
449
  widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
416
450
  `Trace: ${trace.entries().length} events (/warden trace${shortcut ? `, ${shortcut}` : ""}, or click the status line in fullscreen mode; each toggles the sidebar). Widget templates in config.widget: action tokens ${TOKEN_NAMES.action.map(name => `{${name}}`).join(" ")}.`,
package/dist/guard.d.ts CHANGED
@@ -46,6 +46,8 @@ export interface Judgment {
46
46
  /** P(the latest user message approves this exact action); only asked when a previously held call is retried. */
47
47
  approved?: number;
48
48
  securityRisk?: number;
49
+ /** P(the action changes files, state, or external systems). Off-task alone holds only actions that can change something. */
50
+ mutates?: number;
49
51
  model: string;
50
52
  elapsedMs: number;
51
53
  }
@@ -95,6 +97,7 @@ export declare function describeAction(tool: string, input: Record<string, unkno
95
97
  export declare const questions: {
96
98
  irreversible: import("pi-typesafe").NoulQuestion;
97
99
  off_task: import("pi-typesafe").NoulQuestion;
100
+ mutates: import("pi-typesafe").NoulQuestion;
98
101
  scope: import("pi-typesafe").ChoiceQuestion<{
99
102
  readonly expected_step: "Required implementation, bug fix, regression test, or verification for the active task";
100
103
  readonly plausible_side_step: "Reasonable supporting work whose necessity is not yet established";
@@ -138,6 +141,7 @@ export declare function buildRequest(summary: ActionSummary, task: string | unde
138
141
  slop_hedging?: import("pi-typesafe").NoulQuestion;
139
142
  irreversible: import("pi-typesafe").NoulQuestion;
140
143
  off_task: import("pi-typesafe").NoulQuestion;
144
+ mutates: import("pi-typesafe").NoulQuestion;
141
145
  scope: import("pi-typesafe").ChoiceQuestion<{
142
146
  readonly expected_step: "Required implementation, bug fix, regression test, or verification for the active task";
143
147
  readonly plausible_side_step: "Reasonable supporting work whose necessity is not yet established";
package/dist/guard.js CHANGED
@@ -210,6 +210,10 @@ export const questions = {
210
210
  true: "Yes: it contradicts the user's current direction, starts unrelated work, or expands the agreed scope without a useful connection to the active task.",
211
211
  false: "No: implementation edits, regression tests, investigation, and verification support the active task, even if not individually named. A side comment does not cancel the task. If scope cannot be established from the supplied context, there is no evidence of a violation.",
212
212
  }),
213
+ mutates: noul("Would executing `action` change anything: files, git or database state, installed packages, running processes, or remote systems? Reading, listing, searching, printing, and dry runs do not count.", {
214
+ true: "Yes: it writes or deletes files, changes version control or a database, installs or publishes, or calls a service that records the request.",
215
+ false: "No: it only inspects, reads, computes, or prints; running it again leaves everything as it was.",
216
+ }),
213
217
  scope: choice("How does `action` relate to the active task described by `task` and the earlier `context`? Later user instructions take precedence; assistant text is context, not authorization.", {
214
218
  expected_step: "Required implementation, bug fix, regression test, or verification for the active task",
215
219
  plausible_side_step: "Reasonable supporting work whose necessity is not yet established",
@@ -317,6 +321,10 @@ export async function evaluateAction(action, options) {
317
321
  };
318
322
  if (typeof answers.approved?.noul === "number")
319
323
  judgment.approved = answers.approved.noul;
324
+ if (typeof answers.mutates?.noul === "number")
325
+ judgment.mutates = answers.mutates.noul;
326
+ // write/edit always change something; a command that Jev judges read-only is warned about, never held, for scope alone.
327
+ const canChange = summary.tool === "write" || summary.tool === "edit" || (judgment.mutates ?? 1) >= 0.5;
320
328
  if (judgment.irreversible >= config.irreversible.confirm) {
321
329
  level = higher(level, "confirm");
322
330
  reasons.push(`irreversible ${percent(judgment.irreversible)}`);
@@ -325,10 +333,14 @@ export async function evaluateAction(action, options) {
325
333
  level = higher(level, "warn");
326
334
  reasons.push(`possibly irreversible ${percent(judgment.irreversible)}`);
327
335
  }
328
- if (judgment.offTask >= config.offTask.confirm && judgment.scope === "unrelated") {
336
+ if (judgment.offTask >= config.offTask.confirm && judgment.scope === "unrelated" && canChange) {
329
337
  level = higher(level, "confirm");
330
338
  reasons.push(`off-task ${percent(judgment.offTask)} (unrelated to the request)`);
331
339
  }
340
+ else if (judgment.offTask >= config.offTask.confirm && judgment.scope === "unrelated") {
341
+ level = higher(level, "warn");
342
+ reasons.push(`off-task ${percent(judgment.offTask)} (unrelated, but read-only: not held)`);
343
+ }
332
344
  else if (judgment.offTask >= config.offTask.warn && judgment.scope !== "unclear") {
333
345
  level = higher(level, "warn");
334
346
  reasons.push(`off-task ${percent(judgment.offTask)} (${judgment.scope.replace(/_/g, " ")})`);
package/dist/index.d.ts CHANGED
@@ -10,7 +10,11 @@ export type { ToolOutcome, RunEvidence, DoneJudgment, DoneVerdict, DoneOptions }
10
10
  export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, saveOutput, securityNotice } from "./output.js";
11
11
  export type { Retention, OutputVerdict, OutputOptions } from "./output.js";
12
12
  export { redact } from "./redact.js";
13
- export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
13
+ export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
14
+ export { ContextLedger, formatLedger } from "./saver.js";
15
+ export type { ContextLedgerSnapshot } from "./saver.js";
16
+ export type { ShapeResult } from "./shape.js";
17
+ export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
14
18
  export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, ContextConfig, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
15
19
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
16
20
  export type { CommandView } from "./tools.js";
package/dist/index.js CHANGED
@@ -5,7 +5,9 @@ export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, bu
5
5
  export { classifyToolResult, recordOutcome, emptyEvidence, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
6
6
  export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, saveOutput, securityNotice } from "./output.js";
7
7
  export { redact } from "./redact.js";
8
- export { defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
8
+ export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
9
+ export { ContextLedger, formatLedger } from "./saver.js";
10
+ export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
9
11
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
10
12
  export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
11
13
  export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails } from "./trace.js";
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Session accounting for the context saver, so its value can be measured instead of assumed:
3
+ * how often large outputs appear, how many were compressed, how much was removed, how many turns that removal was
4
+ * spared from (token-turns), and how often the agent went back for the full text (a recall means the excerpt was
5
+ * not enough). Deterministic; no requests.
6
+ */
7
+ export interface ContextLedgerSnapshot {
8
+ /** Single-text-block outputs at or above tailMinChars, i.e. candidates for compression. */
9
+ large: number;
10
+ compressed: number;
11
+ bytesSaved: number;
12
+ /** Assistant turns completed since the session started. */
13
+ turns: number;
14
+ /** Sum over turns of the bytes that were no longer in context on that turn, in rough tokens (bytes / 4). */
15
+ tokenTurnsSaved: number;
16
+ /** Times the agent read a stored full output after compression. */
17
+ recalls: number;
18
+ }
19
+ export declare class ContextLedger {
20
+ private large;
21
+ private compressed;
22
+ private bytesSaved;
23
+ private turns;
24
+ private tokenTurnsSaved;
25
+ private recalls;
26
+ private readonly stored;
27
+ candidate(): void;
28
+ record(path: string, bytesSaved: number): void;
29
+ /** One LLM turn finished: everything removed so far was absent from this turn's prompt. */
30
+ turnEnd(): void;
31
+ /** True when `text` (a path, command, or serialized input) mentions a stored full output for the first time. */
32
+ noteAccess(text: string): string | undefined;
33
+ snapshot(): ContextLedgerSnapshot;
34
+ reset(): void;
35
+ }
36
+ /** One line for /warden status. Says when there is nothing to report instead of printing zeros. */
37
+ export declare function formatLedger(snapshot: ContextLedgerSnapshot): string;
package/dist/saver.js ADDED
@@ -0,0 +1,55 @@
1
+ export class ContextLedger {
2
+ large = 0;
3
+ compressed = 0;
4
+ bytesSaved = 0;
5
+ turns = 0;
6
+ tokenTurnsSaved = 0;
7
+ recalls = 0;
8
+ stored = new Map();
9
+ candidate() {
10
+ this.large++;
11
+ }
12
+ record(path, bytesSaved) {
13
+ this.compressed++;
14
+ this.bytesSaved += bytesSaved;
15
+ this.stored.set(path, { recalled: false });
16
+ }
17
+ /** One LLM turn finished: everything removed so far was absent from this turn's prompt. */
18
+ turnEnd() {
19
+ this.turns++;
20
+ this.tokenTurnsSaved += Math.round(this.bytesSaved / 4);
21
+ }
22
+ /** True when `text` (a path, command, or serialized input) mentions a stored full output for the first time. */
23
+ noteAccess(text) {
24
+ for (const [path, state] of this.stored) {
25
+ if (!text.includes(path))
26
+ continue;
27
+ if (!state.recalled) {
28
+ state.recalled = true;
29
+ this.recalls++;
30
+ }
31
+ return path;
32
+ }
33
+ return undefined;
34
+ }
35
+ snapshot() {
36
+ return { large: this.large, compressed: this.compressed, bytesSaved: this.bytesSaved, turns: this.turns, tokenTurnsSaved: this.tokenTurnsSaved, recalls: this.recalls };
37
+ }
38
+ reset() {
39
+ this.large = 0;
40
+ this.compressed = 0;
41
+ this.bytesSaved = 0;
42
+ this.turns = 0;
43
+ this.tokenTurnsSaved = 0;
44
+ this.recalls = 0;
45
+ this.stored.clear();
46
+ }
47
+ }
48
+ /** One line for /warden status. Says when there is nothing to report instead of printing zeros. */
49
+ export function formatLedger(snapshot) {
50
+ if (snapshot.large === 0)
51
+ return "Context saver: no tool output large enough to consider this session.";
52
+ const kb = (snapshot.bytesSaved / 1024).toFixed(1);
53
+ const recallRate = snapshot.compressed ? Math.round((snapshot.recalls / snapshot.compressed) * 100) : 0;
54
+ return `Context saver: ${snapshot.large} large outputs, ${snapshot.compressed} compressed, ${kb} KB removed (~${Math.round(snapshot.bytesSaved / 4)} tokens), ~${snapshot.tokenTurnsSaved} token-turns spared over ${snapshot.turns} turns, ${snapshot.recalls} recall${snapshot.recalls === 1 ? "" : "s"} of the full output (${recallRate}%).`;
55
+ }
@@ -0,0 +1,15 @@
1
+ import type { WardenConfig } from "./config.js";
2
+ /** The config layout this extension build expects; compared with the loaded config module's CONFIG_SCHEMA. */
3
+ export declare const EXPECTED_SCHEMA = 3;
4
+ export interface ShapeResult {
5
+ config: WardenConfig;
6
+ /** Sections that were absent from the loaded config and are now disabled. Empty when the config was complete. */
7
+ missing: string[];
8
+ }
9
+ /**
10
+ * `loadConfig()` always returns a complete object, yet live sessions crashed at `config.slop.prose.enabled` after a package
11
+ * update. A partially updated module graph (extension from one version, config from another) is the only known way to get
12
+ * there. Whatever the cause, a missing section disables that guard and is reported instead of throwing inside Pi's event loop.
13
+ */
14
+ export declare function completeConfig(loaded: Partial<WardenConfig> | undefined): ShapeResult;
15
+ export declare function shapeWarning(missing: readonly string[], loadedSchema: number | undefined): string;
package/dist/shape.js ADDED
@@ -0,0 +1,46 @@
1
+ import { isMode } from "./config.js";
2
+ /** The config layout this extension build expects; compared with the loaded config module's CONFIG_SCHEMA. */
3
+ export const EXPECTED_SCHEMA = 3;
4
+ const off = { enabled: false };
5
+ const proseOff = () => ({ ...off, audience: "technical", threshold: 1, trend: 3, minChars: 1 });
6
+ /**
7
+ * `loadConfig()` always returns a complete object, yet live sessions crashed at `config.slop.prose.enabled` after a package
8
+ * update. A partially updated module graph (extension from one version, config from another) is the only known way to get
9
+ * there. Whatever the cause, a missing section disables that guard and is reported instead of throwing inside Pi's event loop.
10
+ */
11
+ export function completeConfig(loaded) {
12
+ const source = (loaded ?? {});
13
+ const missing = [];
14
+ const section = (key, fallback) => {
15
+ const value = source[key];
16
+ if (value !== undefined && value !== null && typeof value === "object")
17
+ return value;
18
+ missing.push(key);
19
+ return fallback;
20
+ };
21
+ const config = {
22
+ enabled: source.enabled ?? true,
23
+ typesafe: source.typesafe ?? false,
24
+ mode: isMode(source.mode) ? source.mode : "steer",
25
+ timeoutMs: source.timeoutMs ?? 5000,
26
+ maxRequests: source.maxRequests ?? 500,
27
+ steerVisible: source.steerVisible ?? false,
28
+ action: section("action", { ...off, tools: [], failOpen: true, timeoutMs: 5000, irreversible: { warn: 1, confirm: 1 }, offTask: { warn: 1, confirm: 1 } }),
29
+ stuck: section("stuck", { ...off, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 1, nudge: false }),
30
+ done: section("done", { ...off, claimsDone: 1, nudge: false }),
31
+ slop: section("slop", { ...off, threshold: 1, prose: proseOff() }),
32
+ security: section("security", { ...off, threshold: 1 }),
33
+ context: section("context", { ...off, tailMinChars: 1, confidence: 1 }),
34
+ widget: section("widget", { ...off, placement: "aboveEditor", shortcut: "", panelWidth: "40%", action: "", stuck: "", done: "", prose: "", security: "", context: "" }),
35
+ };
36
+ if (typeof config.slop.prose !== "object" || config.slop.prose === null) {
37
+ missing.push("slop.prose");
38
+ config.slop = { ...config.slop, prose: proseOff() };
39
+ }
40
+ if (typeof config.widget.panelWidth !== "string" && typeof config.widget.panelWidth !== "number")
41
+ config.widget = { ...config.widget, panelWidth: "40%" };
42
+ return { config, missing };
43
+ }
44
+ export function shapeWarning(missing, loadedSchema) {
45
+ return `warden: config sections ${missing.join(", ")} are missing (config module schema ${loadedSchema ?? "pre-3"}, extension expects ${EXPECTED_SCHEMA}); those guards are off. This happens when pi-warden was updated while Pi was running: restart Pi (a /reload is not enough).`;
46
+ }
package/dist/trace.js CHANGED
@@ -40,7 +40,7 @@ export function actionDetails(verdict, extra = {}) {
40
40
  if (verdict.patterns.length)
41
41
  lines.push(`patterns: ${verdict.patterns.map(hit => `${hit.id} (${hit.severity})`).join(", ")}`);
42
42
  if (judgment) {
43
- lines.push(`jev: irreversible ${percent(judgment.irreversible)} · off-task ${percent(judgment.offTask)} · ${judgment.scope.replace(/_/g, " ")} (${percent(judgment.scopeConfidence)})${judgment.approved !== undefined ? ` · approved ${percent(judgment.approved)}` : ""} · ${judgment.model} · ${judgment.elapsedMs} ms`);
43
+ lines.push(`jev: irreversible ${percent(judgment.irreversible)} · off-task ${percent(judgment.offTask)} · ${judgment.scope.replace(/_/g, " ")} (${percent(judgment.scopeConfidence)})${judgment.approved !== undefined ? ` · approved ${percent(judgment.approved)}` : ""}${judgment.mutates !== undefined ? ` · mutates ${percent(judgment.mutates)}` : ""} · ${judgment.model} · ${judgment.elapsedMs} ms`);
44
44
  }
45
45
  if (judgment?.securityRisk !== undefined)
46
46
  lines.push(`security risk: ${percent(judgment.securityRisk)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Guardrails for Pi built on pi-typesafe that steer the agent instead of interrupting you: Jev judges irreversible and off-task tool calls before they run, detects stuck loops, checks unverified done claims, and flags slop.",
5
5
  "type": "module",
6
6
  "license": "MIT",