pi-warden 0.25.0 → 0.28.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/dist/extension.js CHANGED
@@ -1,30 +1,33 @@
1
- import { MouseRegion } from "@earendil-works/pi-tui";
1
+ import { rm } from "node:fs/promises";
2
+ import * as tuiModule from "@earendil-works/pi-tui";
3
+ const MouseRegion = tuiModule.MouseRegion;
2
4
  import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
3
5
  import { ensureApiKey } from "pi-typesafe/ui";
6
+ import { backendHost, disclosureFor, judgeOptions, keyAvailable, resolveBackend } from "./backend.js";
4
7
  import { ActionGuard } from "./action-guard.js";
5
8
  import { ArmingTracker, unparseableArmingRules } from "./arming.js";
6
9
  import * as configModule from "./config.js";
7
- import { applyUserOverrides, defaultConfig, isMode, loadConfig, PACKAGE_NAME, projectConfigPath, readUserConfig, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
10
+ import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
8
11
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome as recordDoneOutcome } from "./done.js";
9
- import { evaluateAction, formatVerdict, higher, inertPathRules, intentSteer, offTaskSteer, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
12
+ import { evaluateAction, formatVerdict, formatVerdictTokens, higher, inertPathRules, intentSteer, offTaskSteer, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
10
13
  import { commandOf } from "./tools.js";
11
14
  import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
12
- import { initSchema, recordHold, recordOutcome, toHoldRecord } from "./learning.js";
15
+ import { initSchema, recordHold, recordOutcome, toHoldRecord, generateRecommendations, analyzeSteerEffectivenessReport } from "./learning.js";
13
16
  import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_SHARE, RestatementWindow, substantiveSentences } from "./prose.js";
14
- import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice } from "./output.js";
17
+ import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice, CompressionLearner } from "./output.js";
15
18
  import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
16
19
  import { redact } from "./redact.js";
17
20
  import { formatRules, pathNoteSteer, RulesGuard, rulesSteer } from "./rules.js";
18
21
  import { detectNotifier, sendNotification } from "./notify.js";
19
22
  import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
20
23
  import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
21
- import { openTracePanel } from "./panel.js";
24
+ import { openConfigPanel, openTracePanel } from "./panel.js";
22
25
  import { completeConfig, shapeWarning } from "./shape.js";
23
26
  import { ContextLedger, formatLedger } from "./saver.js";
24
27
  import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
25
28
  import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
26
- import { DEFAULT_TEMPLATES, proseTokens, renderTemplate, statusWidget, TOKEN_NAMES } from "./widget.js";
27
- 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, with the agent's own words from the message that makes the call (its stated plan); for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; 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; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory. Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
29
+ import { DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, SENTENCE_TEMPLATES, statusWidget, TOKEN_NAMES } from "./widget.js";
30
+ 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, with the agent's own words from the message that makes the call (its stated plan); for a write or edit in a project with a rules file (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback), a larger redacted sample of the written content with the current file around each edit and the rule text; 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; redacted tool-output samples for security and context saving (retention and output format); a redacted sample of an async subagent report that names a failure, a stop, or a question, with your latest request, when warden decides whether that report should wake the agent; and, on the first guarded call after your reply, the redacted summaries of the calls allowed in the previous turn, so Jev can say whether your reply regrets one of them. Compression and duplicate notes store an exact, owner-only copy in a temporary file on this machine; the hold feedback log stores tool names, pattern ids, scores, and outcomes (never commands) in an owner-only file under Pi's agent directory; an owner-only SQLite database under Pi's agent directory stores redacted hold context (plan, summary, reason) for learning and retention (configurable, default 365 days). Requests may incur charges. Secret redaction is best-effort. Results are model judgments, not proof or authorization; offline pattern checks stay active either way.";
28
31
  const WIDGET = PACKAGE_NAME;
29
32
  const CONFIRM_TEXT_LIMIT = 500;
30
33
  const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offTask: 0, slop: 0, ruleChecks: 0, ruleViolations: 0, pathNotes: 0, stuckChecks: 0, stuck: 0, doneChecks: 0, unverified: 0, proseChecks: 0, proseNudges: 0, runaway: 0, errors: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
@@ -199,7 +202,6 @@ export default function wardenExtension(pi) {
199
202
  const rulesGuard = new RulesGuard();
200
203
  // Hold feedback: what the user did after each judged call, the trace entry each label lands on, and the per-session log.
201
204
  const holds = new HoldLedger();
202
- initSchema();
203
205
  const learningIds = new Map(); // holds.id -> learning.id
204
206
  // Prune learningIds when it grows large to prevent memory leaks
205
207
  function pruneLearningIds() {
@@ -223,9 +225,12 @@ export default function wardenExtension(pi) {
223
225
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
224
226
  let evidence = emptyEvidence();
225
227
  let doneNudged = false;
228
+ let warnedFallback = false;
226
229
  const prose = new ProseTrend();
227
230
  const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
228
231
  const ledger = new ContextLedger();
232
+ // Learns which compression strategies work best per tool, so the next call skips the judge when confident.
233
+ const compressionLearner = new CompressionLearner();
229
234
  // Secrets already announced this session, by fingerprint: the same key read twice earns one banner and one steer.
230
235
  const secretsSeen = new Set();
231
236
  // Subagent report entries already triaged, by session entry id; the wake window outlives one scan.
@@ -290,11 +295,13 @@ export default function wardenExtension(pi) {
290
295
  };
291
296
  const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
292
297
  const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
293
- /** A consent flag is not proof that judgments happen; ask pi-typesafe for the real key state. */
298
+ /** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
294
299
  const judgeFor = (config) => {
295
- if (!consentGiven(config) || budgetExhausted || !authState().usable)
300
+ if (!consentGiven(config) || budgetExhausted)
296
301
  return undefined;
297
- return client ??= createTypeSafe({ maxRequests: config.maxRequests, timeoutMs: config.timeoutMs });
302
+ if (!keyAvailable(config.typesafeBackend, process.env, () => authState().usable))
303
+ return undefined;
304
+ return client ??= createTypeSafe(judgeOptions(config));
298
305
  };
299
306
  const noteError = (ctx, message, code) => {
300
307
  stats.errors++;
@@ -324,18 +331,47 @@ export default function wardenExtension(pi) {
324
331
  ctx.ui.setWidget(WIDGET, undefined);
325
332
  return;
326
333
  }
327
- const entries = [...widget].map(([guard, line]) => ({ guard, line }));
334
+ let entries = [...widget].map(([guard, line]) => ({ guard, line }));
335
+ // In live mode: only show the most recent guard, re-rendered with sentence templates.
336
+ if (config.widget.barMode === "live" && entries.length > 0) {
337
+ const lastEntry = trace.entries().at(-1);
338
+ if (lastEntry?.tokens) {
339
+ const template = pickSentenceTemplate(lastEntry.guard, lastEntry.tokens);
340
+ const sentence = renderTemplate(template, lastEntry.tokens);
341
+ const chip = lastEntry.tokens.level ?? lastEntry.tokens.status;
342
+ ctx.ui.setWidget(WIDGET, (_tui, theme) => {
343
+ const color = LEVEL_COLOR[chip ?? ""] ?? "text";
344
+ const chipText = chip ? `${theme.bold(theme.fg(color, chip.toUpperCase()))} ` : "";
345
+ const guardText = theme.fg("muted", lastEntry.guard + " ");
346
+ const body = { render: (width) => [chipText + guardText + sentence], invalidate: () => { } };
347
+ return MouseRegion ? new MouseRegion(body, event => {
348
+ if (event.type !== "click" || event.button !== "left")
349
+ return undefined;
350
+ togglePanel(lastUi, config);
351
+ return { handled: true };
352
+ }) : body;
353
+ }, { placement: config.widget.placement });
354
+ return;
355
+ }
356
+ entries = [entries.at(-1)];
357
+ }
328
358
  // A custom component so the lines wrap to the pane and a click (fullscreen mode) opens the trace panel.
329
- ctx.ui.setWidget(WIDGET, (_tui, theme) => new MouseRegion(statusWidget(entries, theme), event => {
330
- if (event.type !== "click" || event.button !== "left")
331
- return undefined;
332
- togglePanel(lastUi, config);
333
- return { handled: true };
334
- }), { placement: config.widget.placement });
359
+ // When the host TUI lacks MouseRegion (e.g. omp 18.2.5), the extension still loads — the widget
360
+ // renders the same text, but a click does nothing. A missing named import is a link-time error
361
+ // that kills the entire extension silently; a missing namespace property is undefined.
362
+ ctx.ui.setWidget(WIDGET, (_tui, theme) => {
363
+ const body = statusWidget(entries, theme);
364
+ return MouseRegion ? new MouseRegion(body, event => {
365
+ if (event.type !== "click" || event.button !== "left")
366
+ return undefined;
367
+ togglePanel(lastUi, config);
368
+ return { handled: true };
369
+ }) : body;
370
+ }, { placement: config.widget.placement });
335
371
  };
336
- const record = (ctx, config, guard, line, details) => {
372
+ const record = (ctx, config, guard, line, details, tokens) => {
337
373
  widget.set(guard, line);
338
- const entry = { at: Date.now(), guard, line, details };
374
+ const entry = { at: Date.now(), guard, line, details, tokens };
339
375
  trace.push(entry);
340
376
  paint(ctx, config);
341
377
  return entry;
@@ -443,8 +479,12 @@ export default function wardenExtension(pi) {
443
479
  }
444
480
  };
445
481
  pi.on("session_start", async (_event, ctx) => {
482
+ if (ctx.hasUI)
483
+ lastUi = ctx.ui;
446
484
  client = undefined;
447
485
  budgetExhausted = false;
486
+ warnedFallback = false;
487
+ await initSchema(loadConfig().learning.retentionDays);
448
488
  stats = freshStats();
449
489
  widget.clear();
450
490
  trace.clear();
@@ -459,7 +499,17 @@ export default function wardenExtension(pi) {
459
499
  evidence = emptyEvidence();
460
500
  doneNudged = false;
461
501
  prose.reset();
502
+ // Clean up temp output dirs from the previous session.
503
+ for (const dir of ledger.storedPaths()) {
504
+ try {
505
+ await rm(dir, { recursive: true, force: true });
506
+ }
507
+ catch (err) {
508
+ console.warn("pi-warden: temp cleanup failed:", err);
509
+ }
510
+ }
462
511
  ledger.reset();
512
+ compressionLearner.reset();
463
513
  secretsSeen.clear();
464
514
  subagentSeen.clear();
465
515
  wakePolicy.reset();
@@ -476,10 +526,21 @@ export default function wardenExtension(pi) {
476
526
  slopCounts[symptom] = 0;
477
527
  if (ctx.hasUI)
478
528
  ctx.ui.setWidget(WIDGET, undefined);
529
+ // One-time notice when confirm mode falls back to steer (headless).
530
+ if (!warnedFallback && loadConfig().mode === "confirm" && !ctx.hasUI) {
531
+ warnedFallback = true;
532
+ const msg = "warden: confirm mode requires a UI; falling back to steer mode for this session.";
533
+ if (ctx.hasUI)
534
+ ctx.ui.notify(msg, "warning");
535
+ else
536
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: msg, display: true });
537
+ }
479
538
  });
480
539
  // A new user prompt starts a new attempt history, a new steer budget, and a new restatement window; answering the
481
540
  // user is never a restatement.
482
541
  pi.on("before_agent_start", async (event, ctx) => {
542
+ if (ctx.hasUI)
543
+ lastUi = ctx.ui;
483
544
  const config = configFor(ctx);
484
545
  attempts = new AttemptWindow(config.stuck.window);
485
546
  doneNudged = false;
@@ -545,8 +606,10 @@ export default function wardenExtension(pi) {
545
606
  if (storedPath) {
546
607
  const kind = classifyRecall(event.toolName, event.input, storedPath);
547
608
  const recalled = ledger.noteAccess(serializedInput, kind);
548
- if (recalled)
609
+ if (recalled) {
610
+ compressionLearner.noteRecall(event.toolName);
549
611
  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())]);
612
+ }
550
613
  }
551
614
  if (!config.action.enabled || !config.action.tools.includes(event.toolName))
552
615
  return;
@@ -643,13 +706,14 @@ export default function wardenExtension(pi) {
643
706
  noteOutcomes(config, [released]);
644
707
  const learningId = learningIds.get(released.id);
645
708
  if (learningId)
646
- recordOutcome(learningId, "approved");
709
+ recordOutcome(learningId, "approved").catch(err => console.warn("pi-warden: recordOutcome failed:", err));
647
710
  }
648
711
  }
649
712
  const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
650
713
  // SAFETY: verdict.source is a string union; the read-only → pattern rewrite above may have narrowed it in TS's view,
651
714
  // but the field is still one of the source values at runtime when no armed hits fired.
652
- const entry = verdict.source !== "read-only" ? record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode, ...(told ? { told } : {}) })) : undefined;
715
+ const actionFmt = formatVerdictTokens(verdict, config.widget.action);
716
+ const entry = verdict.source !== "read-only" ? record(ctx, config, "action", actionFmt.line, actionDetails(verdict, { mode, ...(told ? { told } : {}) }), actionFmt.tokens) : undefined;
653
717
  // What happens to this call is the label for its scores; a decision made in the dialog lands at once, a steer-mode hold waits for the user.
654
718
  const track = (held, outcome, via) => {
655
719
  if (!entry)
@@ -669,10 +733,11 @@ export default function wardenExtension(pi) {
669
733
  noteOutcomes(config, outcome ? [item] : []);
670
734
  // Record to SQLite for learning
671
735
  if (held) {
672
- const id = recordHold(toHoldRecord({ at: item.at, tool: item.tool, level: item.level, reasons: item.reasons, scores: item.scores }, ctx.cwd, { task, plan: verdict.plan, contextSummary: ctxSummary, agentReason: steerReason(verdict, { canApprove: judge !== undefined }) }));
673
- learningIds.set(item.id, id);
674
- if (outcome)
675
- recordOutcome(id, outcome);
736
+ recordHold(toHoldRecord({ at: item.at, tool: item.tool, level: item.level, reasons: item.reasons, scores: item.scores }, ctx.cwd, { task: task ? redact(task) : task, plan: verdict.plan, contextSummary: ctxSummary, agentReason: steerReason(verdict, { canApprove: judge !== undefined }) })).then(id => {
737
+ learningIds.set(item.id, id);
738
+ if (outcome)
739
+ recordOutcome(id, outcome).catch(err => console.warn("pi-warden: recordOutcome failed:", err));
740
+ }).catch(err => console.warn("pi-warden: recordHold failed:", err));
676
741
  pruneLearningIds();
677
742
  }
678
743
  };
@@ -839,7 +904,7 @@ export default function wardenExtension(pi) {
839
904
  blockVerdicts.push(await evaluateOutput(event.toolName, blockText, latestUserPrompt(ctx), {
840
905
  security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
841
906
  signal: ctx.signal, compressible: true, taskContext: recentTaskContext(ctx),
842
- }));
907
+ }, compressionLearner));
843
908
  }
844
909
  output = mergeOutput(blockVerdicts);
845
910
  }
@@ -847,7 +912,7 @@ export default function wardenExtension(pi) {
847
912
  output = await evaluateOutput(event.toolName, text, latestUserPrompt(ctx), {
848
913
  security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
849
914
  signal: ctx.signal, compressible: true, taskContext: recentTaskContext(ctx),
850
- });
915
+ }, compressionLearner);
851
916
  }
852
917
  if (ctx.signal?.aborted)
853
918
  return;
@@ -933,6 +998,7 @@ export default function wardenExtension(pi) {
933
998
  replacement = body;
934
999
  ledger.record(path, bytesSaved);
935
1000
  storedPath = path;
1001
+ compressionLearner.record(event.toolName, verdict.retention, verdict.format, false);
936
1002
  record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: verdict.retention, bytesSaved: String(bytesSaved) }), [
937
1003
  `text block ${textIndex} of ${blockVerdicts.length}: retention ${verdict.retention}; confidence ${verdict.confidence?.toFixed(2)}; format ${verdict.format ?? "generic"}${verdict.formatConfidence === undefined ? "" : ` (${verdict.formatConfidence.toFixed(2)})`}; saved ${bytesSaved} bytes; full output: ${path}`,
938
1004
  formatLedger(ledger.snapshot()),
@@ -960,6 +1026,7 @@ export default function wardenExtension(pi) {
960
1026
  content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
961
1027
  ledger.record(path, bytesSaved);
962
1028
  storedPath = path;
1029
+ compressionLearner.record(event.toolName, output.retention, output.format, false);
963
1030
  record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: output.retention, bytesSaved: String(bytesSaved) }), [
964
1031
  `retention: ${output.retention}; confidence ${output.confidence?.toFixed(2)}; format ${output.format ?? "generic"}${output.formatConfidence === undefined ? "" : ` (${output.formatConfidence.toFixed(2)})`}; ${output.model}; ${output.elapsedMs} ms`,
965
1032
  `saved ${bytesSaved} bytes; full output: ${path}`,
@@ -1117,7 +1184,7 @@ export default function wardenExtension(pi) {
1117
1184
  }
1118
1185
  const actions = ["status", "enable", "disable", "mode", "config", "test", "trace"];
1119
1186
  pi.registerCommand("warden", {
1120
- description: "pi-warden status, TypeSafe consent, steer/confirm/advise mode, config editor, trace panel, and a synthetic guard test",
1187
+ description: "pi-warden status, config (set/get/editor), TypeSafe consent, mode, trace panel, recommend, and a synthetic guard test",
1121
1188
  getArgumentCompletions(prefix) {
1122
1189
  const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
1123
1190
  return matches.length ? matches : null;
@@ -1138,11 +1205,12 @@ export default function wardenExtension(pi) {
1138
1205
  const usage = client?.getUsage();
1139
1206
  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.rules.enabled && "rules", config.context.enabled && "context", config.runaway.enabled && "runaway", config.subagent.enabled && "subagent triage", config.notify.enabled && "desktop notifications"].filter(Boolean).join(", ");
1140
1207
  report([
1141
- `pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `consented via ${source}` : "not consented (run /warden enable)"}; ${auth.text}`,
1208
+ `pi-warden: ${config.enabled ? `guarding ${config.action.tools.join(", ")} (${guards})` : "off"}; mode ${activeMode(config, ctx.hasUI)}; TypeSafe judgments ${source ? `consented via ${source}` : "not consented (run /warden enable)"}${config.typesafeBackend !== "typesafe" ? ` (${config.typesafeBackend})` : ""}; ${auth.text}`,
1142
1209
  `Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan, ${stats.offTask} off task, ${stats.slop} slop notes, ${stats.ruleViolations}/${stats.ruleChecks} rule violations, ${stats.pathNotes} sensitive-path notes, ${stats.stuck}/${stats.stuckChecks} stuck, ${stats.unverified}/${stats.doneChecks} unverified done, ${stats.proseNudges}/${stats.proseChecks} prose nudges, ${stats.runaway} runaway stops, ${stats.subagentWoken}/${stats.subagentReports} subagent reports woken, ${stats.restatements} restatements, ${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)"}. Steer budget: ${config.steerBudget === 0 ? "off" : `${config.steerBudget} per run`}.`,
1143
1210
  formatSteers(stats),
1144
1211
  `Thresholds: irreversible warn ${config.action.irreversible.warn} / hold ${config.action.irreversible.confirm}; off-task warn ${config.action.offTask.warn} / steer ${config.action.offTask.steer} (never holds); intent mismatch ${config.action.intentMismatch} (${config.action.visibleMismatch} on a visible action); stuck same-strategy ${config.stuck.sameStrategy} after ${config.stuck.minFailures} failures; done claims ${config.done.claimsDone}; slop ${config.slop.threshold}, rules ${config.rules.threshold}, prose ${config.slop.prose.threshold} in ${config.slop.prose.trend}/3 replies; runaway ${config.runaway.repeats} repeats (thinking ${config.runaway.thinkingRepeats}), recover ${config.runaway.recover}; failOpen ${config.action.failOpen}.`,
1145
1212
  formatLedger(ledger.snapshot()),
1213
+ ...(config.learning.patternAnalysis ? [`Learning: ${(await generateRecommendations(ctx.cwd)).length} recommendations, steer effectiveness ${Math.round((await analyzeSteerEffectivenessReport(ctx.cwd)).overall * 100)}% (use /warden recommend for details)`] : []),
1146
1214
  `${formatHolds(holds.snapshot(), config.action.feedbackLog ? holdLog?.path : undefined)}${holdLog?.lastFailure ? ` Log write failed: ${holdLog.lastFailure}.` : ""}`,
1147
1215
  `Rules: ${config.rules.enabled ? `${rulesGuard.describe(ctx.cwd, config.rules)}${Object.keys(config.rules.sensitivePaths).length ? `; ${Object.keys(config.rules.sensitivePaths).length} sensitive path${Object.keys(config.rules.sensitivePaths).length === 1 ? "" : "s"}` : ""}` : "off"}.`,
1148
1216
  ...(config.action.armingRules.length > 0 ? [`Arming: ${arming.statusLine() || "no rules armed"}.`] : []),
@@ -1162,6 +1230,49 @@ export default function wardenExtension(pi) {
1162
1230
  togglePanel(ctx.ui, config);
1163
1231
  return;
1164
1232
  }
1233
+ if (action === "recommend") {
1234
+ // Learning-driven recommendations based on hold history
1235
+ const recommendations = await generateRecommendations(ctx.cwd);
1236
+ const steerReport = await analyzeSteerEffectivenessReport(ctx.cwd);
1237
+ const lines = [];
1238
+ lines.push("pi-warden learning recommendations:");
1239
+ if (recommendations.length === 0 && steerReport.suggestions.length === 0) {
1240
+ lines.push(`No recommendations yet. Need more hold data (current: ${holds.snapshot().holds} holds).`);
1241
+ }
1242
+ else {
1243
+ if (recommendations.length > 0) {
1244
+ lines.push("");
1245
+ lines.push("Threshold & pattern recommendations:");
1246
+ for (const rec of recommendations.slice(0, 5)) {
1247
+ lines.push(` [${rec.priority}] ${rec.message}`);
1248
+ }
1249
+ }
1250
+ if (steerReport.suggestions.length > 0) {
1251
+ lines.push("");
1252
+ lines.push("Steer effectiveness:");
1253
+ lines.push(` Overall effectiveness: ${(steerReport.overall * 100).toFixed(0)}%`);
1254
+ for (const suggestion of steerReport.suggestions.slice(0, 3)) {
1255
+ lines.push(` ${suggestion}`);
1256
+ }
1257
+ }
1258
+ if (Object.keys(steerReport.byType).length > 0) {
1259
+ lines.push("");
1260
+ lines.push("Effectiveness by type:");
1261
+ for (const [type, stats] of Object.entries(steerReport.byType)) {
1262
+ lines.push(` ${type}: ${(stats.rate * 100).toFixed(0)}% (${stats.effective}/${stats.total})`);
1263
+ }
1264
+ }
1265
+ if (steerReport.topPatterns.length > 0) {
1266
+ lines.push("");
1267
+ lines.push("Top performing patterns:");
1268
+ for (const pattern of steerReport.topPatterns.slice(0, 3)) {
1269
+ lines.push(` ${pattern.pattern}: ${(pattern.effectiveness * 100).toFixed(0)}% effectiveness (${pattern.sampleSize} samples)`);
1270
+ }
1271
+ }
1272
+ }
1273
+ report(lines.join("\n"));
1274
+ return;
1275
+ }
1165
1276
  if (action === "enable") {
1166
1277
  if (!ctx.hasUI) {
1167
1278
  report("Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and TYPESAFE_API_KEY explicitly.", "warning");
@@ -1196,39 +1307,45 @@ export default function wardenExtension(pi) {
1196
1307
  return;
1197
1308
  }
1198
1309
  if (action === "config") {
1199
- if (!ctx.hasUI) {
1200
- report(`Edit ${userConfigPath()} directly. Defaults: ${JSON.stringify(defaultConfig())}`);
1310
+ if (argument && argument.startsWith("set ")) {
1311
+ const rest = argument.slice(4).trim();
1312
+ const spaceIndex = rest.indexOf(" ");
1313
+ if (spaceIndex === -1) {
1314
+ report("Usage: /warden config set <key> <value>", "warning");
1315
+ return;
1316
+ }
1317
+ const keyPath = rest.slice(0, spaceIndex).trim();
1318
+ const rawValue = rest.slice(spaceIndex + 1).trim();
1319
+ const value = parseConfigValue(rawValue);
1320
+ const current = readUserConfig();
1321
+ const updated = setNestedValue(current, keyPath, value);
1322
+ const savedPath = writeUserConfig(updated);
1323
+ client = undefined;
1324
+ report(`Saved ${keyPath} = ${JSON.stringify(value)}.`);
1201
1325
  return;
1202
1326
  }
1203
- const current = readUserConfig();
1204
- const seed = Object.keys(current).length ? current : { ...defaultConfig(), typesafe: config.typesafe };
1205
- const text = await ctx.ui.editor(`pi-warden config · ${userConfigPath()}`, JSON.stringify(seed, null, 2));
1206
- if (text === undefined)
1327
+ if (argument && argument.startsWith("get ")) {
1328
+ const keyPath = argument.slice(4).trim();
1329
+ const current = readUserConfig();
1330
+ const value = getNestedValue(current, keyPath);
1331
+ const defaultValue = getNestedValue(defaultConfig(), keyPath);
1332
+ report(value === undefined ? `${keyPath} not set (default: ${JSON.stringify(defaultValue)})` : `${keyPath} = ${JSON.stringify(value)}`);
1207
1333
  return;
1208
- let parsed;
1209
- try {
1210
- parsed = JSON.parse(text);
1211
1334
  }
1212
- catch {
1213
- report("Invalid JSON; nothing was saved.", "error");
1335
+ if (!ctx.hasUI || !lastUi) {
1336
+ report(`Edit ${userConfigPath()} directly. Use /warden config set <key> <value> for quick changes.`);
1214
1337
  return;
1215
1338
  }
1216
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1217
- report("The config must be a JSON object; nothing was saved.", "error");
1218
- return;
1219
- }
1220
- const path = writeUserConfig(parsed);
1221
- const effective = applyUserOverrides(defaultConfig(), parsed);
1222
- client = undefined;
1223
- report(`Saved ${path}. Effective: guard ${effective.enabled && effective.action.enabled ? "on" : "off"}, mode ${effective.mode}, TypeSafe ${effective.typesafe ? "on" : "off"}, tools ${effective.action.tools.join(", ")}, irreversible hold ≥ ${effective.action.irreversible.confirm}, off-task steer ≥ ${effective.action.offTask.steer}, stuck ${effective.stuck.enabled ? "on" : "off"}, done-check ${effective.done.enabled ? "on" : "off"}, slop ${effective.slop.enabled ? "on" : "off"}.`);
1339
+ openConfigPanel(lastUi, config, { width: config.widget.panelWidth });
1224
1340
  return;
1225
1341
  }
1226
1342
  if (action === "test") {
1227
1343
  const judge = judgeFor(config);
1228
- if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Clean up the demo directory") goes to api.typesafe.ai and may incur charges. ${disclosure}`))
1344
+ if (judge && ctx.hasUI && !await ctx.ui.confirm("Send one synthetic pi-warden test request?", `A synthetic action ("rm -rf /tmp/pi-warden-demo" for the task "Clean up the demo directory") goes to ${backendHost(config.typesafeBackend)} and may incur charges. ${disclosureFor(config.typesafeBackend, disclosure)}`))
1229
1345
  return;
1230
1346
  const verdict = await evaluateAction({ tool: "bash", input: { command: "rm -rf /tmp/pi-warden-demo" }, cwd: ctx.cwd, task: "Clean up the demo directory" }, { config: { ...config.action, enabled: true, tools: ["bash"] }, judge });
1231
- record(ctx, config, "action", formatVerdict(verdict, config.widget.action), actionDetails(verdict, { mode: activeMode(config, ctx.hasUI) }));
1347
+ const fmt = formatVerdictTokens(verdict, config.widget.action);
1348
+ record(ctx, config, "action", fmt.line, actionDetails(verdict, { mode: activeMode(config, ctx.hasUI) }), fmt.tokens);
1232
1349
  report(`${formatVerdict(verdict)}${verdict.reasons.length ? ` — ${verdict.reasons.join("; ")}` : ""}${judge ? "" : " (pattern checks only: TypeSafe judgments are not enabled or no key is configured)"}${verdict.error ? ` — ${verdict.error}` : ""}`);
1233
1350
  if (verdict.level === "confirm") {
1234
1351
  const mode = activeMode(config, ctx.hasUI);