pi-warden 0.26.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,3 +1,4 @@
1
+ import { rm } from "node:fs/promises";
1
2
  import * as tuiModule from "@earendil-works/pi-tui";
2
3
  const MouseRegion = tuiModule.MouseRegion;
3
4
  import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
@@ -6,27 +7,27 @@ import { backendHost, disclosureFor, judgeOptions, keyAvailable, resolveBackend
6
7
  import { ActionGuard } from "./action-guard.js";
7
8
  import { ArmingTracker, unparseableArmingRules } from "./arming.js";
8
9
  import * as configModule from "./config.js";
9
- 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";
10
11
  import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome as recordDoneOutcome } from "./done.js";
11
- 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";
12
13
  import { commandOf } from "./tools.js";
13
14
  import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
14
- import { initSchema, recordHold, recordOutcome, toHoldRecord } from "./learning.js";
15
+ import { initSchema, recordHold, recordOutcome, toHoldRecord, generateRecommendations, analyzeSteerEffectivenessReport } from "./learning.js";
15
16
  import { evaluateProse, proseNudge, ProseTrend, RESTATE_MIN_SENTENCES, RESTATE_SHARE, RestatementWindow, substantiveSentences } from "./prose.js";
16
- import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice } from "./output.js";
17
+ import { compressOutput, duplicateNote, evaluateOutput, mergeOutput, outputKey, saveOutput, securityNotice, CompressionLearner } from "./output.js";
17
18
  import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
18
19
  import { redact } from "./redact.js";
19
20
  import { formatRules, pathNoteSteer, RulesGuard, rulesSteer } from "./rules.js";
20
21
  import { detectNotifier, sendNotification } from "./notify.js";
21
22
  import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
22
23
  import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
23
- import { openTracePanel } from "./panel.js";
24
+ import { openConfigPanel, openTracePanel } from "./panel.js";
24
25
  import { completeConfig, shapeWarning } from "./shape.js";
25
26
  import { ContextLedger, formatLedger } from "./saver.js";
26
27
  import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
27
28
  import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
28
- import { DEFAULT_TEMPLATES, proseTokens, renderTemplate, statusWidget, TOKEN_NAMES } from "./widget.js";
29
- 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.";
30
31
  const WIDGET = PACKAGE_NAME;
31
32
  const CONFIRM_TEXT_LIMIT = 500;
32
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 });
@@ -201,7 +202,6 @@ export default function wardenExtension(pi) {
201
202
  const rulesGuard = new RulesGuard();
202
203
  // Hold feedback: what the user did after each judged call, the trace entry each label lands on, and the per-session log.
203
204
  const holds = new HoldLedger();
204
- initSchema();
205
205
  const learningIds = new Map(); // holds.id -> learning.id
206
206
  // Prune learningIds when it grows large to prevent memory leaks
207
207
  function pruneLearningIds() {
@@ -225,9 +225,12 @@ export default function wardenExtension(pi) {
225
225
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
226
226
  let evidence = emptyEvidence();
227
227
  let doneNudged = false;
228
+ let warnedFallback = false;
228
229
  const prose = new ProseTrend();
229
230
  const slopCounts = { stub: 0, comments: 0, dead: 0, hedging: 0 };
230
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();
231
234
  // Secrets already announced this session, by fingerprint: the same key read twice earns one banner and one steer.
232
235
  const secretsSeen = new Set();
233
236
  // Subagent report entries already triaged, by session entry id; the wake window outlives one scan.
@@ -328,7 +331,30 @@ export default function wardenExtension(pi) {
328
331
  ctx.ui.setWidget(WIDGET, undefined);
329
332
  return;
330
333
  }
331
- 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
+ }
332
358
  // A custom component so the lines wrap to the pane and a click (fullscreen mode) opens the trace panel.
333
359
  // When the host TUI lacks MouseRegion (e.g. omp 18.2.5), the extension still loads — the widget
334
360
  // renders the same text, but a click does nothing. A missing named import is a link-time error
@@ -343,9 +369,9 @@ export default function wardenExtension(pi) {
343
369
  }) : body;
344
370
  }, { placement: config.widget.placement });
345
371
  };
346
- const record = (ctx, config, guard, line, details) => {
372
+ const record = (ctx, config, guard, line, details, tokens) => {
347
373
  widget.set(guard, line);
348
- const entry = { at: Date.now(), guard, line, details };
374
+ const entry = { at: Date.now(), guard, line, details, tokens };
349
375
  trace.push(entry);
350
376
  paint(ctx, config);
351
377
  return entry;
@@ -453,8 +479,12 @@ export default function wardenExtension(pi) {
453
479
  }
454
480
  };
455
481
  pi.on("session_start", async (_event, ctx) => {
482
+ if (ctx.hasUI)
483
+ lastUi = ctx.ui;
456
484
  client = undefined;
457
485
  budgetExhausted = false;
486
+ warnedFallback = false;
487
+ await initSchema(loadConfig().learning.retentionDays);
458
488
  stats = freshStats();
459
489
  widget.clear();
460
490
  trace.clear();
@@ -469,7 +499,17 @@ export default function wardenExtension(pi) {
469
499
  evidence = emptyEvidence();
470
500
  doneNudged = false;
471
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
+ }
472
511
  ledger.reset();
512
+ compressionLearner.reset();
473
513
  secretsSeen.clear();
474
514
  subagentSeen.clear();
475
515
  wakePolicy.reset();
@@ -486,10 +526,21 @@ export default function wardenExtension(pi) {
486
526
  slopCounts[symptom] = 0;
487
527
  if (ctx.hasUI)
488
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
+ }
489
538
  });
490
539
  // A new user prompt starts a new attempt history, a new steer budget, and a new restatement window; answering the
491
540
  // user is never a restatement.
492
541
  pi.on("before_agent_start", async (event, ctx) => {
542
+ if (ctx.hasUI)
543
+ lastUi = ctx.ui;
493
544
  const config = configFor(ctx);
494
545
  attempts = new AttemptWindow(config.stuck.window);
495
546
  doneNudged = false;
@@ -555,8 +606,10 @@ export default function wardenExtension(pi) {
555
606
  if (storedPath) {
556
607
  const kind = classifyRecall(event.toolName, event.input, storedPath);
557
608
  const recalled = ledger.noteAccess(serializedInput, kind);
558
- if (recalled)
609
+ if (recalled) {
610
+ compressionLearner.noteRecall(event.toolName);
559
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
+ }
560
613
  }
561
614
  if (!config.action.enabled || !config.action.tools.includes(event.toolName))
562
615
  return;
@@ -653,13 +706,14 @@ export default function wardenExtension(pi) {
653
706
  noteOutcomes(config, [released]);
654
707
  const learningId = learningIds.get(released.id);
655
708
  if (learningId)
656
- recordOutcome(learningId, "approved");
709
+ recordOutcome(learningId, "approved").catch(err => console.warn("pi-warden: recordOutcome failed:", err));
657
710
  }
658
711
  }
659
712
  const told = verdict.level === "confirm" && mode === "steer" ? steerReason(verdict, { canApprove: judge !== undefined }) : undefined;
660
713
  // SAFETY: verdict.source is a string union; the read-only → pattern rewrite above may have narrowed it in TS's view,
661
714
  // but the field is still one of the source values at runtime when no armed hits fired.
662
- 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;
663
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.
664
718
  const track = (held, outcome, via) => {
665
719
  if (!entry)
@@ -679,10 +733,11 @@ export default function wardenExtension(pi) {
679
733
  noteOutcomes(config, outcome ? [item] : []);
680
734
  // Record to SQLite for learning
681
735
  if (held) {
682
- 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 }) }));
683
- learningIds.set(item.id, id);
684
- if (outcome)
685
- 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));
686
741
  pruneLearningIds();
687
742
  }
688
743
  };
@@ -849,7 +904,7 @@ export default function wardenExtension(pi) {
849
904
  blockVerdicts.push(await evaluateOutput(event.toolName, blockText, latestUserPrompt(ctx), {
850
905
  security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
851
906
  signal: ctx.signal, compressible: true, taskContext: recentTaskContext(ctx),
852
- }));
907
+ }, compressionLearner));
853
908
  }
854
909
  output = mergeOutput(blockVerdicts);
855
910
  }
@@ -857,7 +912,7 @@ export default function wardenExtension(pi) {
857
912
  output = await evaluateOutput(event.toolName, text, latestUserPrompt(ctx), {
858
913
  security: config.security, context: config.context, judge: judgeFor(config), timeoutMs: config.timeoutMs,
859
914
  signal: ctx.signal, compressible: true, taskContext: recentTaskContext(ctx),
860
- });
915
+ }, compressionLearner);
861
916
  }
862
917
  if (ctx.signal?.aborted)
863
918
  return;
@@ -943,6 +998,7 @@ export default function wardenExtension(pi) {
943
998
  replacement = body;
944
999
  ledger.record(path, bytesSaved);
945
1000
  storedPath = path;
1001
+ compressionLearner.record(event.toolName, verdict.retention, verdict.format, false);
946
1002
  record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: verdict.retention, bytesSaved: String(bytesSaved) }), [
947
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}`,
948
1004
  formatLedger(ledger.snapshot()),
@@ -970,6 +1026,7 @@ export default function wardenExtension(pi) {
970
1026
  content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
971
1027
  ledger.record(path, bytesSaved);
972
1028
  storedPath = path;
1029
+ compressionLearner.record(event.toolName, output.retention, output.format, false);
973
1030
  record(ctx, config, "context", renderTemplate(config.widget.context, { tool: event.toolName, retention: output.retention, bytesSaved: String(bytesSaved) }), [
974
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`,
975
1032
  `saved ${bytesSaved} bytes; full output: ${path}`,
@@ -1127,7 +1184,7 @@ export default function wardenExtension(pi) {
1127
1184
  }
1128
1185
  const actions = ["status", "enable", "disable", "mode", "config", "test", "trace"];
1129
1186
  pi.registerCommand("warden", {
1130
- 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",
1131
1188
  getArgumentCompletions(prefix) {
1132
1189
  const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
1133
1190
  return matches.length ? matches : null;
@@ -1153,6 +1210,7 @@ export default function wardenExtension(pi) {
1153
1210
  formatSteers(stats),
1154
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}.`,
1155
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)`] : []),
1156
1214
  `${formatHolds(holds.snapshot(), config.action.feedbackLog ? holdLog?.path : undefined)}${holdLog?.lastFailure ? ` Log write failed: ${holdLog.lastFailure}.` : ""}`,
1157
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"}.`,
1158
1216
  ...(config.action.armingRules.length > 0 ? [`Arming: ${arming.statusLine() || "no rules armed"}.`] : []),
@@ -1172,6 +1230,49 @@ export default function wardenExtension(pi) {
1172
1230
  togglePanel(ctx.ui, config);
1173
1231
  return;
1174
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
+ }
1175
1276
  if (action === "enable") {
1176
1277
  if (!ctx.hasUI) {
1177
1278
  report("Consent needs an interactive session. For headless runs set PI_WARDEN_ENABLED=1 and TYPESAFE_API_KEY explicitly.", "warning");
@@ -1206,31 +1307,36 @@ export default function wardenExtension(pi) {
1206
1307
  return;
1207
1308
  }
1208
1309
  if (action === "config") {
1209
- if (!ctx.hasUI) {
1210
- 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)}.`);
1211
1325
  return;
1212
1326
  }
1213
- const current = readUserConfig();
1214
- const seed = Object.keys(current).length ? current : { ...defaultConfig(), typesafe: config.typesafe };
1215
- const text = await ctx.ui.editor(`pi-warden config · ${userConfigPath()}`, JSON.stringify(seed, null, 2));
1216
- 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)}`);
1217
1333
  return;
1218
- let parsed;
1219
- try {
1220
- parsed = JSON.parse(text);
1221
1334
  }
1222
- catch {
1223
- 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.`);
1224
1337
  return;
1225
1338
  }
1226
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1227
- report("The config must be a JSON object; nothing was saved.", "error");
1228
- return;
1229
- }
1230
- const path = writeUserConfig(parsed);
1231
- const effective = applyUserOverrides(defaultConfig(), parsed);
1232
- client = undefined;
1233
- 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 });
1234
1340
  return;
1235
1341
  }
1236
1342
  if (action === "test") {
@@ -1238,7 +1344,8 @@ export default function wardenExtension(pi) {
1238
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)}`))
1239
1345
  return;
1240
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 });
1241
- 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);
1242
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}` : ""}`);
1243
1350
  if (verdict.level === "confirm") {
1244
1351
  const mode = activeMode(config, ctx.hasUI);