pi-warden 0.5.5 → 0.7.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/dist/extension.js CHANGED
@@ -1,12 +1,14 @@
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 { ActionGuard } from "./action-guard.js";
4
5
  import * as configModule from "./config.js";
5
6
  import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
6
7
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome } from "./done.js";
7
- import { evaluateAction, formatVerdict, SLOP_LABELS, steerReason, textApproves } from "./guard.js";
8
+ import { evaluateAction, formatVerdict, SLOP_LABELS, steerReason } from "./guard.js";
8
9
  import { evaluateProse, proseNudge, ProseTrend } from "./prose.js";
9
- import { compressOutput, evaluateOutput, saveOutput, securityNotice } from "./output.js";
10
+ import { compressOutput, duplicateNote, evaluateOutput, outputKey, saveOutput, securityNotice } from "./output.js";
11
+ import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
10
12
  import { redact } from "./redact.js";
11
13
  import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
12
14
  import { openTracePanel } from "./panel.js";
@@ -14,7 +16,7 @@ import { completeConfig, shapeWarning } from "./shape.js";
14
16
  import { ContextLedger, formatLedger } from "./saver.js";
15
17
  import { actionDetails, doneDetails, proseDetails, stuckDetails, Trace } from "./trace.js";
16
18
  import { proseTokens, renderTemplate, TOKEN_NAMES } from "./widget.js";
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.";
19
+ 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 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.";
18
20
  const WIDGET = PACKAGE_NAME;
19
21
  const CONFIRM_TEXT_LIMIT = 500;
20
22
  const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, slop: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, errors: 0 });
@@ -69,7 +71,7 @@ export function siblingToolCalls(ctx) {
69
71
  if (!Array.isArray(content))
70
72
  return [];
71
73
  return content.flatMap(part => part.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string"
72
- ? [{ id: part.id, name: part.name, input: (part.arguments ?? {}) }]
74
+ ? [{ id: part.id, tool: part.name, input: (part.arguments ?? {}) }]
73
75
  : []);
74
76
  }
75
77
  return [];
@@ -119,21 +121,15 @@ export default function wardenExtension(pi) {
119
121
  const trace = new Trace();
120
122
  let panel;
121
123
  let lastUi;
122
- /** Judgments for tool calls of the current assistant message, keyed by tool call id; `used` marks preflighted ones. */
123
- const prejudged = new Map();
124
- /**
125
- * Steer-mode hold state. After a hold, the next guarded call that runs under a *new* user prompt asks Jev whether that
126
- * prompt approves it. The retry rarely repeats the held string byte for byte (a `command -v` dropped, a different
127
- * timeout), so approval is judged against the action itself, not matched against the earlier command text.
128
- */
129
- let lastHoldPrompt;
130
- let holdPending = false;
124
+ const actionGuard = new ActionGuard();
131
125
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
132
126
  let evidence = emptyEvidence();
133
127
  let doneNudged = false;
134
128
  const prose = new ProseTrend();
135
129
  const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
136
130
  const ledger = new ContextLedger();
131
+ // Probed once per session, outside any tool_result handler; the footer under excerpts names this command.
132
+ let searchTool;
137
133
  // A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
138
134
  let shapeReported = false;
139
135
  const configFor = (ctx) => {
@@ -206,13 +202,13 @@ export default function wardenExtension(pi) {
206
202
  widget.clear();
207
203
  trace.clear();
208
204
  panel?.close();
209
- lastHoldPrompt = undefined;
210
- holdPending = false;
205
+ actionGuard.reset();
211
206
  attempts.reset();
212
207
  evidence = emptyEvidence();
213
208
  doneNudged = false;
214
209
  prose.reset();
215
210
  ledger.reset();
211
+ searchTool = undefined;
216
212
  for (const symptom of Object.keys(slopCounts))
217
213
  slopCounts[symptom] = 0;
218
214
  if (ctx.hasUI)
@@ -222,7 +218,7 @@ export default function wardenExtension(pi) {
222
218
  pi.on("before_agent_start", async (_event, ctx) => {
223
219
  attempts = new AttemptWindow(configFor(ctx).stuck.window);
224
220
  doneNudged = false;
225
- prejudged.clear();
221
+ actionGuard.turnEnd();
226
222
  });
227
223
  // Each low-level run collects its own evidence of changes and checks.
228
224
  pi.on("agent_start", async () => {
@@ -231,44 +227,28 @@ export default function wardenExtension(pi) {
231
227
  // Every turn that runs after a compression is a turn that did not carry the removed text.
232
228
  pi.on("turn_end", async () => {
233
229
  ledger.turnEnd();
234
- // Siblings that were never preflighted (an earlier one terminated the batch, or Esc) do not outlive their turn.
235
- prejudged.clear();
230
+ actionGuard.turnEnd();
236
231
  });
237
232
  pi.on("tool_call", async (event, ctx) => {
238
233
  const config = configFor(ctx);
239
234
  if (!config.enabled)
240
235
  return;
241
236
  // A read of a stored full output means the excerpt was not enough; that is the number that tunes context.confidence.
242
- const recalled = ledger.noteAccess(JSON.stringify(event.input));
243
- if (recalled)
244
- record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: "full output recalled" }), [`the agent went back to ${recalled}`, formatLedger(ledger.snapshot())]);
237
+ // A whole-file read also undoes the saving, so the kind of access is kept apart.
238
+ const serializedInput = JSON.stringify(event.input);
239
+ const storedPath = ledger.storedPathIn(serializedInput);
240
+ if (storedPath) {
241
+ const kind = classifyRecall(event.toolName, event.input, storedPath);
242
+ const recalled = ledger.noteAccess(serializedInput, kind);
243
+ if (recalled)
244
+ record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: `full output recalled (${kind})` }), [`the agent went back to ${recalled} (${kind === "full" ? "whole-file read" : "scoped access"})`, formatLedger(ledger.snapshot())]);
245
+ }
245
246
  if (!config.action.enabled || !config.action.tools.includes(event.toolName))
246
247
  return;
247
248
  stats.inspected++;
248
249
  const task = latestUserPrompt(ctx);
249
- // A hold happened under an earlier prompt and the user has since replied: ask whether the reply approves this action.
250
- const retryAfterHold = holdPending && lastHoldPrompt !== task;
251
250
  const judge = judgeFor(config);
252
- const context = recentTaskContext(ctx);
253
- const options = { config: config.action, judge, signal: ctx.signal, slop: config.slop, security: config.security, retryAfterHold };
254
- const judgeCall = (tool, input) => evaluateAction({ tool, input, cwd: ctx.cwd, task, context }, options);
255
- // Sibling calls in the same assistant message are preflighted one after another; their requests go out together.
256
- // A retry after a hold stays sequential because an approval consumed by one sibling changes the question for the next.
257
- if (judge && !retryAfterHold) {
258
- for (const sibling of siblingToolCalls(ctx)) {
259
- if (sibling.id === event.toolCallId || prejudged.has(sibling.id) || !config.action.tools.includes(sibling.name))
260
- continue;
261
- const verdict = judgeCall(sibling.name, sibling.input);
262
- verdict.catch(() => undefined);
263
- prejudged.set(sibling.id, { key: JSON.stringify(sibling.input), verdict, used: false });
264
- }
265
- }
266
- // An earlier hook may have changed this call's input; a stale judgment is discarded, not reused.
267
- const key = JSON.stringify(event.input);
268
- const ready = prejudged.get(event.toolCallId);
269
- const pending = ready && !ready.used && ready.key === key && !retryAfterHold ? ready.verdict : judgeCall(event.toolName, event.input);
270
- prejudged.set(event.toolCallId, { key, verdict: pending, used: true });
271
- const verdict = await pending;
251
+ const verdict = await actionGuard.inspect({ id: event.toolCallId, tool: event.toolName, input: event.input }, { task, context: recentTaskContext(ctx), siblings: siblingToolCalls(ctx) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security });
272
252
  if (verdict.source === "skipped")
273
253
  return;
274
254
  if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
@@ -278,16 +258,8 @@ export default function wardenExtension(pi) {
278
258
  stats.judged++;
279
259
  if (verdict.source === "error")
280
260
  noteError(ctx, verdict.error ?? "TypeSafe request failed.", verdict.errorCode);
281
- // Offline stand-in for the approval question: the user has replied since the hold and the reply reads as approval.
282
- if (retryAfterHold && !judge && verdict.level === "confirm" && textApproves(task)) {
283
- verdict.level = "allow";
284
- verdict.approvedByUser = true;
285
- verdict.reasons = ["user approved in the latest message", ...verdict.reasons];
286
- }
287
- if (verdict.approvedByUser) {
261
+ if (verdict.approvedByUser)
288
262
  stats.approved++;
289
- holdPending = false;
290
- }
291
263
  const mode = activeMode(config, ctx.hasUI);
292
264
  const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
293
265
  if (verdict.source !== "read-only")
@@ -324,10 +296,7 @@ export default function wardenExtension(pi) {
324
296
  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.` };
325
297
  }
326
298
  stats.held++;
327
- // A re-hold under the user's reply keeps the original reference prompt; otherwise the reply could never approve anything.
328
- if (!holdPending)
329
- lastHoldPrompt = task;
330
- holdPending = true;
299
+ actionGuard.hold(task);
331
300
  if (ctx.hasUI)
332
301
  ctx.ui.notify(`warden · held ${event.toolName}: ${reasons}. The agent was told why and asked to re-plan or ask you.`, "warning");
333
302
  return { block: true, reason: told ?? steerReason(verdict, { canApprove: judge !== undefined }) };
@@ -349,7 +318,12 @@ export default function wardenExtension(pi) {
349
318
  stats.stuckChecks++;
350
319
  return evaluateStuck(attempts, latestUserPrompt(ctx), { config: config.stuck, judge: judgeFor(config), timeoutMs: config.timeoutMs, signal: ctx.signal });
351
320
  })();
352
- const output = await evaluateOutput(event.toolName, text, latestUserPrompt(ctx), {
321
+ // A recall brings stored text back on purpose: it is not judged, compressed, or dropped again.
322
+ const recallRead = ledger.storedPathIn(JSON.stringify(event.input)) !== undefined;
323
+ // Duplicate detection is code only: an identical result adds nothing, whatever Jev would say about it.
324
+ const key = config.context.enabled && !recallRead && textBlocks.length === 1 && text.length >= config.context.duplicateMinChars ? outputKey(text) : undefined;
325
+ const earlier = key ? ledger.duplicateOf(key) : undefined;
326
+ const output = earlier || recallRead ? { secret: false, suspicious: false, retention: "all" } : await evaluateOutput(event.toolName, text, latestUserPrompt(ctx), {
353
327
  security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
354
328
  signal: ctx.signal, compressible: textBlocks.length === 1, taskContext: recentTaskContext(ctx),
355
329
  });
@@ -359,19 +333,41 @@ export default function wardenExtension(pi) {
359
333
  noteError(ctx, output.error, output.errorCode);
360
334
  let content = event.content;
361
335
  const notice = securityNotice(output);
362
- if (config.context.enabled && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
336
+ const recallTool = await (searchTool ??= detectSearchTool(config.context.recallTool));
337
+ let storedPath;
338
+ if (earlier && key) {
339
+ try {
340
+ storedPath = earlier.path ?? await saveOutput(text);
341
+ const replacement = `${duplicateNote(text, earlier.tool)}\n\n${recallInstruction(recallTool, storedPath)}`;
342
+ const bytesSaved = Buffer.byteLength(text) - Buffer.byteLength(replacement);
343
+ if (bytesSaved > 0) {
344
+ content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
345
+ ledger.duplicate(bytesSaved);
346
+ ledger.remember(key, earlier.tool, storedPath);
347
+ record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: "duplicate", bytesSaved: String(bytesSaved) }), [
348
+ `identical to an earlier ${earlier.tool} result; saved ${bytesSaved} bytes; full output: ${storedPath}`,
349
+ formatLedger(ledger.snapshot()),
350
+ ]);
351
+ }
352
+ }
353
+ catch {
354
+ noteError(ctx, "Could not store full output; keeping it unchanged.", undefined);
355
+ }
356
+ }
357
+ if (config.context.enabled && !earlier && !recallRead && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
363
358
  ledger.candidate();
364
- const excerpt = compressOutput(text, output.retention);
359
+ const excerpt = earlier ? undefined : compressOutput(text, output.retention, output.format);
365
360
  if (excerpt && !ctx.signal?.aborted) {
366
361
  try {
367
362
  const path = await saveOutput(text);
368
- const replacement = `${excerpt}\n\nFull output: ${path}`;
363
+ const replacement = `${excerpt}\n\n${recallInstruction(recallTool, path)}`;
369
364
  const bytesSaved = Buffer.byteLength(text) - Buffer.byteLength(replacement) - (notice ? Buffer.byteLength(notice) * 2 + 4 : 0);
370
365
  if (bytesSaved > 0) {
371
366
  content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
372
367
  ledger.record(path, bytesSaved);
368
+ storedPath = path;
373
369
  record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: output.retention, bytesSaved: String(bytesSaved) }), [
374
- `retention: ${output.retention}; confidence ${output.confidence?.toFixed(2)}; ${output.model}; ${output.elapsedMs} ms`,
370
+ `retention: ${output.retention}; confidence ${output.confidence?.toFixed(2)}; format ${output.format ?? "generic"}${output.formatConfidence === undefined ? "" : ` (${output.formatConfidence.toFixed(2)})`}; ${output.model}; ${output.elapsedMs} ms`,
375
371
  `saved ${bytesSaved} bytes; full output: ${path}`,
376
372
  formatLedger(ledger.snapshot()),
377
373
  ]);
@@ -381,6 +377,8 @@ export default function wardenExtension(pi) {
381
377
  noteError(ctx, "Could not store full output; keeping it unchanged.", undefined);
382
378
  }
383
379
  }
380
+ if (key && !earlier)
381
+ ledger.remember(key, event.toolName, storedPath);
384
382
  if (notice && textBlocks.length) {
385
383
  let index = 0;
386
384
  content = content.map(part => {
@@ -406,7 +404,7 @@ export default function wardenExtension(pi) {
406
404
  if (!verdict)
407
405
  return patch;
408
406
  if (verdict.error)
409
- noteError(ctx, verdict.error, undefined);
407
+ noteError(ctx, verdict.error, verdict.errorCode);
410
408
  if (verdict.source === "repeat" && !verdict.stuck)
411
409
  return patch;
412
410
  const nudge = verdict.stuck && config.stuck.nudge ? stuckNudge(verdict) : undefined;
@@ -440,7 +438,7 @@ export default function wardenExtension(pi) {
440
438
  stats.proseChecks++;
441
439
  const verdict = await proseCheck;
442
440
  if (verdict.error)
443
- noteError(ctx, verdict.error, undefined);
441
+ noteError(ctx, verdict.error, verdict.errorCode);
444
442
  else
445
443
  prose.record(verdict.flagged);
446
444
  const due = verdict.error ? [] : prose.due(config.slop.prose.trend);
@@ -462,7 +460,7 @@ export default function wardenExtension(pi) {
462
460
  stats.doneChecks++;
463
461
  const verdict = await doneCheck;
464
462
  if (verdict.error)
465
- noteError(ctx, verdict.error, undefined);
463
+ noteError(ctx, verdict.error, verdict.errorCode);
466
464
  const nudge = verdict.unverified && config.done.nudge ? doneNudge(verdict) : undefined;
467
465
  record(ctx, config, "done", formatDone(verdict, config.widget.done), doneDetails(verdict, finalMessage, nudge));
468
466
  if (!verdict.unverified)
@@ -505,7 +503,7 @@ export default function wardenExtension(pi) {
505
503
  const key = resolveApiKey();
506
504
  const source = consentSource(config);
507
505
  const usage = client?.getUsage();
508
- const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop", config.slop.enabled && config.slop.prose.enabled && `prose (${config.slop.prose.audience})`, config.security.enabled && "security", config.context.enabled && "context (tail-only)"].filter(Boolean).join(", ");
506
+ const guards = [config.action.enabled && "action", config.stuck.enabled && "stuck", config.done.enabled && "done-check", config.slop.enabled && "slop", config.slop.enabled && config.slop.prose.enabled && `prose (${config.slop.prose.audience})`, config.security.enabled && "security", config.context.enabled && "context"].filter(Boolean).join(", ");
509
507
  report([
510
508
  `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)"}.`,
511
509
  `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)"}.`,
package/dist/guard.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { IntegrationErrorCode, TypeSafe } from "pi-typesafe";
1
+ import type { IntegrationErrorCode } from "pi-typesafe";
2
2
  import type { ActionGuardConfig, SecurityConfig, SlopGuardConfig } from "./config.js";
3
+ import type { Judge } from "./jev.js";
3
4
  export type Level = "allow" | "warn" | "confirm";
4
5
  export type Severity = "destructive" | "risky" | "sensitive";
5
6
  export interface PatternHit {
@@ -78,8 +79,7 @@ export interface Verdict {
78
79
  error?: string;
79
80
  errorCode?: IntegrationErrorCode;
80
81
  }
81
- /** The subset of pi-typesafe's client the guard needs; tests supply a fake. */
82
- export type Judge = Pick<TypeSafe, "evaluate">;
82
+ export type { Judge } from "./jev.js";
83
83
  export interface EvaluateOptions {
84
84
  config: ActionGuardConfig;
85
85
  /** Omit to run offline pattern checks only (no consent, no network). */
package/dist/guard.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, relative, resolve, sep } from "node:path";
4
- import { choice, noul, score, TypeSafeIntegrationError } from "pi-typesafe";
4
+ import { choice, noul, score } from "pi-typesafe";
5
+ import { askJev } from "./jev.js";
5
6
  import { redact } from "./redact.js";
6
7
  import { commandOf } from "./tools.js";
7
8
  import { actionTokens, DEFAULT_TEMPLATES, renderTemplate } from "./widget.js";
@@ -271,10 +272,6 @@ export function buildRequest(summary, task, extras = {}) {
271
272
  questions: { ...questions, ...(wantSlop ? slopQuestions : {}), ...(extras.approval ? approvalQuestion : {}), ...(extras.security && (summary.tool === "write" || summary.tool === "edit") && hasContent(summary) ? securityQuestion : {}) },
272
273
  };
273
274
  }
274
- function combineSignals(timeoutMs, signal) {
275
- const timeout = AbortSignal.timeout(timeoutMs);
276
- return signal ? AbortSignal.any([signal, timeout]) : timeout;
277
- }
278
275
  const percent = (value) => value.toFixed(2);
279
276
  const APPROVAL_THRESHOLD = 0.7;
280
277
  // ---------------------------------------------------------------------------
@@ -312,91 +309,87 @@ export async function evaluateAction(action, options) {
312
309
  }
313
310
  if (!judge)
314
311
  return { level, source: "pattern", summary, patterns, reasons };
315
- try {
316
- const request = buildRequest(summary, action.task, { slop: options.slop?.enabled ?? false, approval: options.retryAfterHold ?? false, security: options.security?.enabled ?? false, context: action.context });
317
- const result = await judge.evaluate(request, { signal: combineSignals(config.timeoutMs, options.signal) });
318
- const answers = result.answers;
319
- const judgment = {
320
- irreversible: answers.irreversible.noul,
321
- offTask: answers.off_task.noul,
322
- scope: answers.scope.choice,
323
- scopeConfidence: answers.scope.confidence,
324
- model: result.model,
325
- elapsedMs: result.elapsedMs,
326
- };
327
- if (typeof answers.approved?.noul === "number")
328
- judgment.approved = answers.approved.noul;
329
- if (typeof answers.mutates?.noul === "number")
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
- }
343
- // write/edit always change something; a command that Jev judges read-only is warned about, never held, for scope alone.
344
- const canChange = summary.tool === "write" || summary.tool === "edit" || (judgment.mutates ?? 1) >= 0.5;
345
- if (judgment.irreversible >= config.irreversible.confirm) {
346
- level = higher(level, "confirm");
347
- reasons.push(`irreversible ${percent(judgment.irreversible)}`);
348
- }
349
- else if (judgment.irreversible >= config.irreversible.warn) {
350
- level = higher(level, "warn");
351
- reasons.push(`possibly irreversible ${percent(judgment.irreversible)}`);
352
- }
353
- if (judgment.offTask >= config.offTask.confirm && judgment.scope === "unrelated" && canChange) {
312
+ const request = buildRequest(summary, action.task, { slop: options.slop?.enabled ?? false, approval: options.retryAfterHold ?? false, security: options.security?.enabled ?? false, context: action.context });
313
+ const result = await askJev(judge, request, { timeoutMs: config.timeoutMs, signal: options.signal });
314
+ if (!result.ok) {
315
+ if (!config.failOpen) {
354
316
  level = higher(level, "confirm");
355
- reasons.push(`off-task ${percent(judgment.offTask)} (unrelated to the request)`);
356
- }
357
- else if (judgment.offTask >= config.offTask.confirm && judgment.scope === "unrelated") {
358
- level = higher(level, "warn");
359
- reasons.push(`off-task ${percent(judgment.offTask)} (unrelated, but read-only: not held)`);
317
+ reasons.push("TypeSafe unavailable and failOpen is false");
360
318
  }
361
- else if (judgment.offTask >= config.offTask.warn && judgment.scope !== "unclear") {
362
- level = higher(level, "warn");
363
- reasons.push(`off-task ${percent(judgment.offTask)} (${judgment.scope.replace(/_/g, " ")})`);
319
+ else {
320
+ reasons.push("TypeSafe unavailable; allowed by failOpen");
364
321
  }
365
- if (options.security?.enabled && typeof answers.security_risk?.noul === "number") {
366
- judgment.securityRisk = answers.security_risk.noul;
367
- if (judgment.securityRisk >= options.security.threshold) {
322
+ return { level, source: "error", summary, patterns, reasons, error: result.error, ...(result.errorCode ? { errorCode: result.errorCode } : {}) };
323
+ }
324
+ const answers = result.answers;
325
+ const judgment = {
326
+ irreversible: answers.irreversible.noul,
327
+ offTask: answers.off_task.noul,
328
+ scope: answers.scope.choice,
329
+ scopeConfidence: answers.scope.confidence,
330
+ model: result.model,
331
+ elapsedMs: result.elapsedMs,
332
+ };
333
+ if (typeof answers.approved?.noul === "number")
334
+ judgment.approved = answers.approved.noul;
335
+ if (typeof answers.mutates?.noul === "number")
336
+ judgment.mutates = answers.mutates.noul;
337
+ if (deferSensitive) {
338
+ for (const hit of patterns) {
339
+ if (hit.severity !== "sensitive")
340
+ continue;
341
+ if ((judgment.mutates ?? 1) >= 0.5) {
368
342
  level = higher(level, "warn");
369
- reasons.push(`possible security weakness ${percent(judgment.securityRisk)} in written content`);
343
+ reasons.push(`${hit.severity}: ${hit.label}`);
370
344
  }
345
+ else
346
+ reasons.push(`${hit.label} (read-only, not warned)`);
371
347
  }
372
- const verdict = { level, source: "typesafe", summary, patterns, reasons, judgment };
373
- if (options.slop?.enabled && SLOP_SYMPTOMS.every(symptom => typeof answers[`slop_${symptom}`]?.noul === "number")) {
374
- verdict.slop = { stub: answers.slop_stub.noul, comments: answers.slop_comments.noul, dead: answers.slop_dead.noul, hedging: answers.slop_hedging.noul };
375
- const flagged = SLOP_SYMPTOMS.filter(symptom => verdict.slop[symptom] >= options.slop.threshold).sort((a, b) => verdict.slop[b] - verdict.slop[a]);
376
- if (flagged.length) {
377
- verdict.slopSymptoms = flagged;
378
- verdict.slopReasons = flagged.map(symptom => `${SLOP_LABELS[symptom]} (${percent(verdict.slop[symptom])})`);
379
- }
380
- }
381
- if (level === "confirm" && judgment.approved !== undefined && judgment.approved >= APPROVAL_THRESHOLD) {
382
- verdict.level = "allow";
383
- verdict.approvedByUser = true;
384
- verdict.reasons = [`user approved in the latest message (${percent(judgment.approved)})`, ...reasons];
385
- }
386
- return verdict;
387
348
  }
388
- catch (error) {
389
- const known = error instanceof TypeSafeIntegrationError ? error : undefined;
390
- const message = known?.message ?? "TypeSafe request failed.";
391
- if (!config.failOpen) {
392
- level = higher(level, "confirm");
393
- reasons.push("TypeSafe unavailable and failOpen is false");
349
+ // write/edit always change something; a command that Jev judges read-only is warned about, never held, for scope alone.
350
+ const canChange = summary.tool === "write" || summary.tool === "edit" || (judgment.mutates ?? 1) >= 0.5;
351
+ if (judgment.irreversible >= config.irreversible.confirm) {
352
+ level = higher(level, "confirm");
353
+ reasons.push(`irreversible ${percent(judgment.irreversible)}`);
354
+ }
355
+ else if (judgment.irreversible >= config.irreversible.warn) {
356
+ level = higher(level, "warn");
357
+ reasons.push(`possibly irreversible ${percent(judgment.irreversible)}`);
358
+ }
359
+ if (judgment.offTask >= config.offTask.confirm && judgment.scope === "unrelated" && canChange) {
360
+ level = higher(level, "confirm");
361
+ reasons.push(`off-task ${percent(judgment.offTask)} (unrelated to the request)`);
362
+ }
363
+ else if (judgment.offTask >= config.offTask.confirm && judgment.scope === "unrelated") {
364
+ level = higher(level, "warn");
365
+ reasons.push(`off-task ${percent(judgment.offTask)} (unrelated, but read-only: not held)`);
366
+ }
367
+ else if (judgment.offTask >= config.offTask.warn && judgment.scope !== "unclear") {
368
+ level = higher(level, "warn");
369
+ reasons.push(`off-task ${percent(judgment.offTask)} (${judgment.scope.replace(/_/g, " ")})`);
370
+ }
371
+ if (options.security?.enabled && typeof answers.security_risk?.noul === "number") {
372
+ judgment.securityRisk = answers.security_risk.noul;
373
+ if (judgment.securityRisk >= options.security.threshold) {
374
+ level = higher(level, "warn");
375
+ reasons.push(`possible security weakness ${percent(judgment.securityRisk)} in written content`);
394
376
  }
395
- else {
396
- reasons.push("TypeSafe unavailable; allowed by failOpen");
377
+ }
378
+ const verdict = { level, source: "typesafe", summary, patterns, reasons, judgment };
379
+ if (options.slop?.enabled && SLOP_SYMPTOMS.every(symptom => typeof answers[`slop_${symptom}`]?.noul === "number")) {
380
+ verdict.slop = { stub: answers.slop_stub.noul, comments: answers.slop_comments.noul, dead: answers.slop_dead.noul, hedging: answers.slop_hedging.noul };
381
+ const flagged = SLOP_SYMPTOMS.filter(symptom => verdict.slop[symptom] >= options.slop.threshold).sort((a, b) => verdict.slop[b] - verdict.slop[a]);
382
+ if (flagged.length) {
383
+ verdict.slopSymptoms = flagged;
384
+ verdict.slopReasons = flagged.map(symptom => `${SLOP_LABELS[symptom]} (${percent(verdict.slop[symptom])})`);
397
385
  }
398
- return { level, source: "error", summary, patterns, reasons, error: message, ...(known ? { errorCode: known.code } : {}) };
399
386
  }
387
+ if (level === "confirm" && judgment.approved !== undefined && judgment.approved >= APPROVAL_THRESHOLD) {
388
+ verdict.level = "allow";
389
+ verdict.approvedByUser = true;
390
+ verdict.reasons = [`user approved in the latest message (${percent(judgment.approved)})`, ...reasons];
391
+ }
392
+ return verdict;
400
393
  }
401
394
  /** Offline stand-in for the approval question when TypeSafe is not available. */
402
395
  export function textApproves(task) {
package/dist/index.d.ts CHANGED
@@ -1,21 +1,27 @@
1
- export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, textApproves, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
1
+ export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
2
2
  export type { ActionInput, ActionSummary, EvaluateOptions, Judge, Judgment, SlopJudgment, SlopSymptom, Level, PatternHit, ScopeLabel, Severity, Verdict } from "./guard.js";
3
3
  export { SLOP_SYMPTOMS, SLOP_LABELS } from "./guard.js";
4
+ export { ActionGuard } from "./action-guard.js";
5
+ export type { Conversation, InspectOptions, ToolCallRef } from "./action-guard.js";
4
6
  export { evaluateProse, buildProseRequest, proseQuestions, proseNudge, describeAudience, ProseTrend, PROSE_SYMPTOMS, PROSE_LABELS } from "./prose.js";
5
7
  export type { ProseSymptom, ProseVerdict, ProseOptions } from "./prose.js";
6
8
  export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, buildStuckRequest, stuckQuestions, stuckNudge, formatStuck } from "./stuck.js";
7
9
  export type { Attempt, StuckJudgment, StuckVerdict, StuckOptions } from "./stuck.js";
8
10
  export { classifyToolResult, recordOutcome, emptyEvidence, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
9
11
  export type { ToolOutcome, RunEvidence, DoneJudgment, DoneVerdict, DoneOptions } from "./done.js";
10
- export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, saveOutput, securityNotice } from "./output.js";
12
+ export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, saveOutput, securityNotice, outputKey, duplicateNote } from "./output.js";
11
13
  export type { Retention, OutputVerdict, OutputOptions } from "./output.js";
14
+ export { formatExcerpt, formatQuestion } from "./excerpt.js";
15
+ export type { OutputFormat } from "./excerpt.js";
16
+ export { detectSearchTool, recallInstruction, classifyRecall } from "./recall.js";
17
+ export type { SearchTool } from "./recall.js";
12
18
  export { redact } from "./redact.js";
13
19
  export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
14
20
  export { ContextLedger, formatLedger } from "./saver.js";
15
- export type { ContextLedgerSnapshot } from "./saver.js";
21
+ export type { ContextLedgerSnapshot, RecallKind } from "./saver.js";
16
22
  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";
18
- export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, ContextConfig, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
23
+ export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, isRecallTool, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
24
+ export type { ActionGuardConfig, StuckGuardConfig, DoneGuardConfig, SlopGuardConfig, ProseConfig, SecurityConfig, ContextConfig, RecallTool, Threshold, WardenConfig, WardenMode, LoadOptions } from "./config.js";
19
25
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
20
26
  export type { CommandView } from "./tools.js";
21
27
  export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
package/dist/index.js CHANGED
@@ -1,13 +1,16 @@
1
- export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, textApproves, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
1
+ export { evaluateAction, describeAction, matchPatterns, isReadOnlyCommand, buildRequest, formatVerdict, steerReason, questions, slopQuestions, approvalQuestion, securityQuestion } from "./guard.js";
2
2
  export { SLOP_SYMPTOMS, SLOP_LABELS } from "./guard.js";
3
+ export { ActionGuard } from "./action-guard.js";
3
4
  export { evaluateProse, buildProseRequest, proseQuestions, proseNudge, describeAudience, ProseTrend, PROSE_SYMPTOMS, PROSE_LABELS } from "./prose.js";
4
5
  export { AttemptWindow, makeAttempt, resultFailed, resultText, evaluateStuck, buildStuckRequest, stuckQuestions, stuckNudge, formatStuck } from "./stuck.js";
5
6
  export { classifyToolResult, recordOutcome, emptyEvidence, needsDoneCheck, finalAssistantText, evaluateDone, buildDoneRequest, doneQuestions, doneNudge, formatDone } from "./done.js";
6
- export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, saveOutput, securityNotice } from "./output.js";
7
+ export { evaluateOutput, buildOutputRequest, outputQuestions, compressOutput, saveOutput, securityNotice, outputKey, duplicateNote } from "./output.js";
8
+ export { formatExcerpt, formatQuestion } from "./excerpt.js";
9
+ export { detectSearchTool, recallInstruction, classifyRecall } from "./recall.js";
7
10
  export { redact } from "./redact.js";
8
11
  export { completeConfig, shapeWarning, EXPECTED_SCHEMA } from "./shape.js";
9
12
  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";
13
+ export { CONFIG_SCHEMA, defaultConfig, loadConfig, applyUserOverrides, applyProjectOverrides, isMode, isRecallTool, userConfigPath, projectConfigPath, readUserConfig, writeUserConfig, setUserSetting, PACKAGE_NAME } from "./config.js";
11
14
  export { commandOf, outputReportsFailure, COMMAND_TOOLS } from "./tools.js";
12
15
  export { renderTemplate, actionTokens, stuckTokens, doneTokens, proseTokens, defaultWidgetConfig, DEFAULT_TEMPLATES, TOKEN_NAMES } from "./widget.js";
13
16
  export { Trace, actionDetails, stuckDetails, doneDetails, proseDetails } from "./trace.js";
package/dist/jev.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { Evaluation, IntegrationErrorCode, Questions, SystemOneRequest, TypeSafe } from "pi-typesafe";
2
+ /** Anything with pi-typesafe's `evaluate`: the real client in a session, a stub in tests. */
3
+ export type Judge = Pick<TypeSafe, "evaluate">;
4
+ export interface AskOptions {
5
+ timeoutMs: number;
6
+ signal?: AbortSignal | undefined;
7
+ }
8
+ export type JevAnswer<Q extends Questions> = {
9
+ ok: true;
10
+ answers: Evaluation<Q>["answers"];
11
+ model: string;
12
+ elapsedMs: number;
13
+ } | {
14
+ ok: false;
15
+ error: string;
16
+ errorCode?: IntegrationErrorCode;
17
+ };
18
+ /**
19
+ * One Jev request with the per-guard timeout merged into the caller's abort signal. Never throws: a failure comes back as
20
+ * `{ ok: false }` with pi-typesafe's own message (which carries no upstream body) and its code, so the caller can stop
21
+ * asking after a `budget` error. Unknown errors get a fixed message so nothing from the transport reaches the user.
22
+ */
23
+ export declare function askJev<Q extends Questions>(judge: Judge, request: SystemOneRequest<Q>, options: AskOptions): Promise<JevAnswer<Q>>;
package/dist/jev.js ADDED
@@ -0,0 +1,19 @@
1
+ import { TypeSafeIntegrationError } from "pi-typesafe";
2
+ const FALLBACK_MESSAGE = "TypeSafe request failed.";
3
+ /**
4
+ * One Jev request with the per-guard timeout merged into the caller's abort signal. Never throws: a failure comes back as
5
+ * `{ ok: false }` with pi-typesafe's own message (which carries no upstream body) and its code, so the caller can stop
6
+ * asking after a `budget` error. Unknown errors get a fixed message so nothing from the transport reaches the user.
7
+ */
8
+ export async function askJev(judge, request, options) {
9
+ const timeout = AbortSignal.timeout(options.timeoutMs);
10
+ try {
11
+ const result = await judge.evaluate(request, { signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout });
12
+ return { ok: true, answers: result.answers, model: result.model, elapsedMs: result.elapsedMs };
13
+ }
14
+ catch (error) {
15
+ if (error instanceof TypeSafeIntegrationError)
16
+ return { ok: false, error: error.message, errorCode: error.code };
17
+ return { ok: false, error: FALLBACK_MESSAGE };
18
+ }
19
+ }