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/CHANGELOG.md +87 -0
- package/README.md +56 -176
- package/dist/backend.d.ts +23 -0
- package/dist/backend.js +36 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +51 -0
- package/dist/extension.d.ts +1 -1
- package/dist/extension.js +170 -53
- package/dist/extension.js.bak +1369 -0
- package/dist/guard.d.ts +5 -0
- package/dist/guard.js +5 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -3
- package/dist/learning.d.ts +51 -5
- package/dist/learning.js +219 -29
- package/dist/output.d.ts +31 -1
- package/dist/output.js +80 -1
- package/dist/panel.d.ts +29 -0
- package/dist/panel.js +257 -10
- package/dist/saver.d.ts +2 -0
- package/dist/saver.js +4 -0
- package/dist/shape.js +4 -1
- package/dist/trace.d.ts +2 -0
- package/dist/widget.d.ts +96 -0
- package/dist/widget.js +259 -1
- package/package.json +2 -2
package/dist/extension.js
CHANGED
|
@@ -1,30 +1,33 @@
|
|
|
1
|
-
import {
|
|
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;
|
|
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
|
|
300
|
+
if (!consentGiven(config) || budgetExhausted)
|
|
296
301
|
return undefined;
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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,
|
|
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 (
|
|
1200
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
1213
|
-
report(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|