pi-warden 0.5.2 → 0.5.4

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
@@ -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
package/dist/done.d.ts CHANGED
@@ -5,7 +5,12 @@ export type ToolOutcome = "read" | "mutation" | "check-pass" | "check-fail" | "u
5
5
  * What a finished tool call contributes to the run's evidence. Only write/edit count as code changes: shell side effects
6
6
  * (deleting a temp dir, installing a package) are too varied to demand a test run for. Custom tools are unknown.
7
7
  */
8
- export declare function classifyToolResult(tool: string, input: Record<string, unknown>, failed: boolean): ToolOutcome;
8
+ export declare function classifyToolResult(tool: string, input: Record<string, unknown>, failed: boolean, output?: string): ToolOutcome;
9
+ /**
10
+ * Recognise a test/type-check runner's own summary in tool output: node:test, jest/vitest, pytest, cargo, go test,
11
+ * tsc. Returns the outcome the summary reports, or undefined when no runner summary is present.
12
+ */
13
+ export declare function checkSummary(output: string): "pass" | "fail" | undefined;
9
14
  export interface RunEvidence {
10
15
  mutations: number;
11
16
  checks: Array<{
package/dist/done.js CHANGED
@@ -9,7 +9,7 @@ const CHECK_COMMAND = /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|lin
9
9
  * What a finished tool call contributes to the run's evidence. Only write/edit count as code changes: shell side effects
10
10
  * (deleting a temp dir, installing a package) are too varied to demand a test run for. Custom tools are unknown.
11
11
  */
12
- export function classifyToolResult(tool, input, failed) {
12
+ export function classifyToolResult(tool, input, failed, output) {
13
13
  if (tool === "write" || tool === "edit")
14
14
  return "mutation";
15
15
  if (tool === "read" || tool === "grep" || tool === "find" || tool === "ls")
@@ -19,8 +19,35 @@ export function classifyToolResult(tool, input, failed) {
19
19
  return "unknown";
20
20
  if (CHECK_COMMAND.test(view.command))
21
21
  return failed ? "check-fail" : "check-pass";
22
+ // A test runner launched from inside a script (ctx_execute JavaScript, a Python wrapper) leaves no runner name in the
23
+ // command text, but its output still carries the runner's summary. Judge that summary instead.
24
+ const summary = output === undefined ? undefined : checkSummary(output);
25
+ if (summary)
26
+ return summary === "fail" || failed ? "check-fail" : "check-pass";
22
27
  return view.shell && isReadOnlyCommand(view.command) ? "read" : "unknown";
23
28
  }
29
+ /**
30
+ * Recognise a test/type-check runner's own summary in tool output: node:test, jest/vitest, pytest, cargo, go test,
31
+ * tsc. Returns the outcome the summary reports, or undefined when no runner summary is present.
32
+ */
33
+ export function checkSummary(output) {
34
+ const tail = output.slice(-6000);
35
+ const nodeTest = /\u2139 (?:tests|pass|fail) \d+/.test(tail) && /\u2139 fail (\d+)/.exec(tail);
36
+ if (nodeTest)
37
+ return Number(nodeTest[1]) > 0 ? "fail" : "pass";
38
+ const jest = /^Tests:\s+(?:(\d+) failed, )?.*?\d+ total/m.exec(tail);
39
+ if (jest)
40
+ return jest[1] && Number(jest[1]) > 0 ? "fail" : "pass";
41
+ const pytest = /^=+ .*?(?:(\d+) failed|(\d+) error).*?in [\d.]+s/m.exec(tail) ?? /^=+ (\d+) passed.*? in [\d.]+s =+$/m.exec(tail);
42
+ if (pytest)
43
+ return /\d+ (?:failed|error)/.test(pytest[0]) ? "fail" : "pass";
44
+ const cargoOrGo = /^test result: (ok|FAILED)\./m.exec(tail) ?? /^(ok|FAIL)\s+\S+\s+[\d.]+s$/m.exec(tail);
45
+ if (cargoOrGo)
46
+ return cargoOrGo[1] === "ok" ? "pass" : "fail";
47
+ if (/\berror TS\d{4}:/.test(tail))
48
+ return "fail";
49
+ return undefined;
50
+ }
24
51
  export function emptyEvidence() {
25
52
  return { mutations: 0, checks: [] };
26
53
  }
package/dist/extension.js CHANGED
@@ -11,6 +11,7 @@ import { redact } from "./redact.js";
11
11
  import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
12
12
  import { openTracePanel } from "./panel.js";
13
13
  import { completeConfig, shapeWarning } from "./shape.js";
14
+ import { ContextLedger, formatLedger } from "./saver.js";
14
15
  import { actionDetails, doneDetails, proseDetails, stuckDetails, Trace } from "./trace.js";
15
16
  import { proseTokens, renderTemplate, TOKEN_NAMES } from "./widget.js";
16
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.";
@@ -75,7 +76,6 @@ export function confirmMessage(verdict) {
75
76
  lines.push("Judgments are model output, not authorization. Yes runs the tool; No blocks it and tells the agent.");
76
77
  return lines.join("\n");
77
78
  }
78
- const callKey = (tool, input) => `${tool}\0${JSON.stringify(input)}`;
79
79
  const SLOP_FIXES = {
80
80
  stub: "replace stubs, placeholders, and hard-coded fake data with the working implementation, or state in your reply exactly what is left unimplemented and why",
81
81
  comments: "delete comments that restate the code; keep only those that explain intent, constraints, or non-obvious behaviour",
@@ -98,13 +98,19 @@ export default function wardenExtension(pi) {
98
98
  const trace = new Trace();
99
99
  let panel;
100
100
  let lastUi;
101
- /** Calls held in steer mode, with the user prompt current at that time; a retry after the user replies asks Jev about approval. */
102
- const held = new Map();
101
+ /**
102
+ * Steer-mode hold state. After a hold, the next guarded call that runs under a *new* user prompt asks Jev whether that
103
+ * prompt approves it. The retry rarely repeats the held string byte for byte (a `command -v` dropped, a different
104
+ * timeout), so approval is judged against the action itself, not matched against the earlier command text.
105
+ */
106
+ let lastHoldPrompt;
107
+ let holdPending = false;
103
108
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
104
109
  let evidence = emptyEvidence();
105
110
  let doneNudged = false;
106
111
  const prose = new ProseTrend();
107
112
  const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
113
+ const ledger = new ContextLedger();
108
114
  // A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
109
115
  let shapeReported = false;
110
116
  const configFor = (ctx) => {
@@ -177,11 +183,13 @@ export default function wardenExtension(pi) {
177
183
  widget.clear();
178
184
  trace.clear();
179
185
  panel?.close();
180
- held.clear();
186
+ lastHoldPrompt = undefined;
187
+ holdPending = false;
181
188
  attempts.reset();
182
189
  evidence = emptyEvidence();
183
190
  doneNudged = false;
184
191
  prose.reset();
192
+ ledger.reset();
185
193
  for (const symptom of Object.keys(slopCounts))
186
194
  slopCounts[symptom] = 0;
187
195
  if (ctx.hasUI)
@@ -196,15 +204,24 @@ export default function wardenExtension(pi) {
196
204
  pi.on("agent_start", async () => {
197
205
  evidence = emptyEvidence();
198
206
  });
207
+ // Every turn that runs after a compression is a turn that did not carry the removed text.
208
+ pi.on("turn_end", async () => {
209
+ ledger.turnEnd();
210
+ });
199
211
  pi.on("tool_call", async (event, ctx) => {
200
212
  const config = configFor(ctx);
201
- if (!config.enabled || !config.action.enabled || !config.action.tools.includes(event.toolName))
213
+ if (!config.enabled)
214
+ return;
215
+ // A read of a stored full output means the excerpt was not enough; that is the number that tunes context.confidence.
216
+ const recalled = ledger.noteAccess(JSON.stringify(event.input));
217
+ if (recalled)
218
+ record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: "full output recalled" }), [`the agent went back to ${recalled}`, formatLedger(ledger.snapshot())]);
219
+ if (!config.action.enabled || !config.action.tools.includes(event.toolName))
202
220
  return;
203
221
  stats.inspected++;
204
222
  const task = latestUserPrompt(ctx);
205
- const key = callKey(event.toolName, event.input);
206
- const heldAt = held.get(key);
207
- const retryAfterHold = held.has(key) && heldAt !== task;
223
+ // A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
224
+ const retryAfterHold = holdPending && lastHoldPrompt !== task;
208
225
  const judge = judgeFor(config);
209
226
  const verdict = await evaluateAction({ tool: event.toolName, input: event.input, cwd: ctx.cwd, task, context: recentTaskContext(ctx) }, { config: config.action, judge, signal: ctx.signal, slop: config.slop, security: config.security, retryAfterHold });
210
227
  if (verdict.source === "skipped")
@@ -224,7 +241,7 @@ export default function wardenExtension(pi) {
224
241
  }
225
242
  if (verdict.approvedByUser) {
226
243
  stats.approved++;
227
- held.delete(key);
244
+ holdPending = false;
228
245
  }
229
246
  const mode = activeMode(config, ctx.hasUI);
230
247
  const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
@@ -262,7 +279,10 @@ export default function wardenExtension(pi) {
262
279
  return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${reasons}). Do not retry it unchanged; ask the user how to proceed.` };
263
280
  }
264
281
  stats.held++;
265
- held.set(key, task);
282
+ // A re-hold under the user's reply keeps the original reference prompt; otherwise the reply could never approve anything.
283
+ if (!holdPending)
284
+ lastHoldPrompt = task;
285
+ holdPending = true;
266
286
  if (ctx.hasUI)
267
287
  ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
268
288
  return { block: true, reason: told ?? steerReason(verdict, { canApprove: judge !== undefined }) };
@@ -283,6 +303,8 @@ export default function wardenExtension(pi) {
283
303
  noteError(ctx, output.error, output.errorCode);
284
304
  let content = event.content;
285
305
  const notice = securityNotice(output);
306
+ if (config.context.enabled && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
307
+ ledger.candidate();
286
308
  const excerpt = compressOutput(text, output.retention);
287
309
  if (excerpt && !ctx.signal?.aborted) {
288
310
  try {
@@ -291,9 +313,11 @@ export default function wardenExtension(pi) {
291
313
  const bytesSaved = Buffer.byteLength(text) - Buffer.byteLength(replacement) - (notice ? Buffer.byteLength(notice) * 2 + 4 : 0);
292
314
  if (bytesSaved > 0) {
293
315
  content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
316
+ ledger.record(path, bytesSaved);
294
317
  record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: output.retention, bytesSaved: String(bytesSaved) }), [
295
318
  `retention: ${output.retention}; confidence ${output.confidence?.toFixed(2)}; ${output.model}; ${output.elapsedMs} ms`,
296
319
  `saved ${bytesSaved} bytes; full output: ${path}`,
320
+ formatLedger(ledger.snapshot()),
297
321
  ]);
298
322
  }
299
323
  }
@@ -322,7 +346,7 @@ export default function wardenExtension(pi) {
322
346
  // Checks and repeat detection use the original result, not the excerpts or security banner.
323
347
  const failed = resultFailed(event.isError, event.details, event.content);
324
348
  if (config.done.enabled)
325
- recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed), event.input, event.toolName);
349
+ recordOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
326
350
  if (!config.stuck.enabled)
327
351
  return patch;
328
352
  attempts.push(makeAttempt(event.toolName, event.input, event.content, failed));
@@ -427,6 +451,7 @@ export default function wardenExtension(pi) {
427
451
  `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)"}.`,
428
452
  `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)"}.`,
429
453
  `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}.`,
454
+ formatLedger(ledger.snapshot()),
430
455
  `Config: ${userConfigPath()}${ctx.isProjectTrusted() ? ` and ${projectConfigPath(ctx.cwd)}` : ""}.`,
431
456
  widget.size ? `Last: ${[...widget.values()].join(" | ")}` : "No guarded activity yet this session.",
432
457
  `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.js CHANGED
@@ -287,7 +287,12 @@ export async function evaluateAction(action, options) {
287
287
  const patterns = matchPatterns(action.tool, action.input, action.cwd);
288
288
  const reasons = [];
289
289
  let level = "allow";
290
+ // A shell command that merely mentions a secrets file (grep for key names, cat .env.example) is decided after Jev
291
+ // says whether it can write; write/edit on such a path, and offline runs, keep the immediate warning.
292
+ const deferSensitive = judge !== undefined && (action.tool !== "write" && action.tool !== "edit");
290
293
  for (const hit of patterns) {
294
+ if (hit.severity === "sensitive" && deferSensitive)
295
+ continue;
291
296
  level = higher(level, hit.severity === "destructive" ? "confirm" : "warn");
292
297
  reasons.push(`${hit.severity}: ${hit.label}`);
293
298
  }
@@ -323,6 +328,18 @@ export async function evaluateAction(action, options) {
323
328
  judgment.approved = answers.approved.noul;
324
329
  if (typeof answers.mutates?.noul === "number")
325
330
  judgment.mutates = answers.mutates.noul;
331
+ if (deferSensitive) {
332
+ for (const hit of patterns) {
333
+ if (hit.severity !== "sensitive")
334
+ continue;
335
+ if ((judgment.mutates ?? 1) >= 0.5) {
336
+ level = higher(level, "warn");
337
+ reasons.push(`${hit.severity}: ${hit.label}`);
338
+ }
339
+ else
340
+ reasons.push(`${hit.label} (read-only, not warned)`);
341
+ }
342
+ }
326
343
  // write/edit always change something; a command that Jev judges read-only is warned about, never held, for scope alone.
327
344
  const canChange = summary.tool === "write" || summary.tool === "edit" || (judgment.mutates ?? 1) >= 0.5;
328
345
  if (judgment.irreversible >= config.irreversible.confirm) {
package/dist/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, sa
11
11
  export type { Retention, OutputVerdict, OutputOptions } from "./output.js";
12
12
  export { redact } from "./redact.js";
13
13
  export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
14
+ export { ContextLedger, formatLedger } from "./saver.js";
15
+ export type { ContextLedgerSnapshot } from "./saver.js";
14
16
  export type { ShapeResult } from "./shape.js";
15
17
  export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
16
18
  export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, ContextConfig, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { classifyToolResult, recordOutcome, emptyEvidence, needsDoneCheck, final
6
6
  export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, saveOutput, securityNotice } from "./output.js";
7
7
  export { redact } from "./redact.js";
8
8
  export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
9
+ export { ContextLedger, formatLedger } from "./saver.js";
9
10
  export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
10
11
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
11
12
  export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",