pi-warden 0.40.2 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/dist/action-guard.d.ts +9 -2
- package/dist/action-guard.js +1 -1
- package/dist/backend.d.ts +4 -0
- package/dist/backend.js +4 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +15 -1
- package/dist/conscience.d.ts +9 -3
- package/dist/conscience.js +13 -4
- package/dist/extension.d.ts +8 -1
- package/dist/extension.js +238 -67
- package/dist/guard.d.ts +98 -1
- package/dist/guard.js +345 -10
- package/dist/holds.d.ts +2 -0
- package/dist/holds.js +5 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/judge-cooldown.d.ts +44 -0
- package/dist/judge-cooldown.js +92 -0
- package/dist/panel.d.ts +5 -2
- package/dist/panel.js +5 -2
- package/dist/redact.d.ts +5 -3
- package/dist/redact.js +17 -4
- package/dist/rules.d.ts +2 -0
- package/dist/rules.js +23 -0
- package/dist/shape.d.ts +46 -0
- package/dist/shape.js +76 -1
- package/dist/trace-file.d.ts +50 -0
- package/dist/trace-file.js +141 -0
- package/dist/trace.d.ts +12 -1
- package/dist/trace.js +3 -3
- package/package.json +1 -1
package/dist/extension.js
CHANGED
|
@@ -6,13 +6,13 @@ import * as tuiModule from "@earendil-works/pi-tui";
|
|
|
6
6
|
const MouseRegion = tuiModule.MouseRegion;
|
|
7
7
|
import { authState, createTypeSafe, describeAuth } from "pi-typesafe";
|
|
8
8
|
import { ensureApiKey } from "pi-typesafe/ui";
|
|
9
|
-
import { backendHost, disclosureFor, judgeOptions, keyEnvFor, resolveBackend } from "./backend.js";
|
|
9
|
+
import { backendHost, disclosureFor, judgeOptions, keyEnvFor, loginStoresKey, resolveBackend } from "./backend.js";
|
|
10
10
|
import { ActionGuard } from "./action-guard.js";
|
|
11
11
|
import { ArmingTracker, unparseableArmingRules } from "./arming.js";
|
|
12
12
|
import * as configModule from "./config.js";
|
|
13
13
|
import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
|
|
14
14
|
import { classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, needsDoneCheck, recordOutcome as recordDoneOutcome } from "./done.js";
|
|
15
|
-
import { evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, offTaskSteer, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
|
|
15
|
+
import { createdScratch, evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, largeOutputNotice, offTaskSteer, pruneScratch, scratchCandidates, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, writeSinkTargets } from "./guard.js";
|
|
16
16
|
import { commandOf } from "./tools.js";
|
|
17
17
|
import { formatHolds, HoldLedger, HoldLog, holdLogPath, outcomeNote, regretsAt, textRegrets } from "./holds.js";
|
|
18
18
|
import { initSchema, recordHold, recordOutcome, toHoldRecord, holdStats, generateRecommendations, analyzeSteerEffectivenessReport } from "./learning.js";
|
|
@@ -31,39 +31,19 @@ import { assess } from "./conscience.js";
|
|
|
31
31
|
import { loadSkillBody, buildLoadMessage, policyMatches, CONSCIENCE_BETA_POLICY, recordFileIdentity, clearFileIdentityCache } from "./load.js";
|
|
32
32
|
import { buildIndexPrompt, readIndex, writeIndex, validateIndex, indexStats, indexPath, ensureIndexDir } from "./index-cmd.js";
|
|
33
33
|
import { fileContentHash } from "./hashing.js";
|
|
34
|
-
|
|
35
|
-
function classifyConscienceError(err) {
|
|
36
|
-
if (err && typeof err === "object" && "code" in err) {
|
|
37
|
-
const code = err.code;
|
|
38
|
-
if (code === "timeout")
|
|
39
|
-
return "timeout";
|
|
40
|
-
if (code === "http" || code === "connection" || code === "response")
|
|
41
|
-
return "network";
|
|
42
|
-
if (code === "configuration" || code === "validation")
|
|
43
|
-
return "configuration";
|
|
44
|
-
if (code === "budget" || code === "aborted")
|
|
45
|
-
return "other";
|
|
46
|
-
}
|
|
47
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
48
|
-
if (/timeout|timed out/i.test(msg))
|
|
49
|
-
return "timeout";
|
|
50
|
-
if (/auth|key|credential|401|403/i.test(msg))
|
|
51
|
-
return "auth";
|
|
52
|
-
if (/network|fetch|connect|ECONNREFUSED|ENOTFOUND/i.test(msg))
|
|
53
|
-
return "network";
|
|
54
|
-
return "other";
|
|
55
|
-
}
|
|
34
|
+
import { classifyJudgeError, cooldownFailureKind, JudgeCooldown } from "./judge-cooldown.js";
|
|
56
35
|
import { openConfigPanel, openTracePanel } from "./panel.js";
|
|
57
|
-
import { completeConfig, shapeWarning } from "./shape.js";
|
|
36
|
+
import { completeConfig, shapeWarning, taskSpine } from "./shape.js";
|
|
58
37
|
import { ContextLedger, formatLedger } from "./saver.js";
|
|
59
38
|
import { buildCompactSnapshot, compactAppendix } from "./compact.js";
|
|
60
39
|
import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
|
|
61
40
|
import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
|
|
41
|
+
import { TraceFile, judgmentsState, traceDir, traceFilePath } from "./trace-file.js";
|
|
62
42
|
import { actionTokens, DEFAULT_TEMPLATES, LEVEL_COLOR, pickSentenceTemplate, proseTokens, renderTemplate, SENTENCE_TEMPLATES, statusWidget, TOKEN_NAMES } from "./widget.js";
|
|
63
|
-
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); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; 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. For the conscience coach (recommend mode): your current request (2000 redacted characters), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. 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, redacted command preview, outcomes) for held and judged-allowed calls, 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.";
|
|
43
|
+
export const disclosure = "With TypeSafe judgments enabled, pi-warden sends to api.typesafe.ai: your latest request, the task spine it is judged against (the first request of the thread and up to four redacted earlier requests), 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); the resolved active rules file content (pi-warden.md, the configured files, or README/CLAUDE/AGENTS as fallback, token-aware truncated at ~4000 tokens) sent with every action request unless the rules guard is off (`rules.enabled: false`), which keeps that content on this machine; 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. For the conscience coach (recommend mode): your current request (2000 redacted characters), the same task spine (the first request of the thread and up to four redacted earlier requests), up to four recent user/assistant text messages (500 redacted characters each with roles), and sanitized candidate metadata (skill/tool name, role, lead, useWhen, examples when an index entry matches; bare description otherwise; full skill instructions never go to Jev). The index is built locally by the session model; only sanitized entries reach Jev; advertised locations never do. 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, redacted command preview, outcomes) for held and judged-allowed calls, 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.";
|
|
64
44
|
const WIDGET = PACKAGE_NAME;
|
|
65
45
|
const CONFIRM_TEXT_LIMIT = 500;
|
|
66
|
-
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 });
|
|
46
|
+
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, cooldownSkips: 0, steers: 0, steersSkipped: 0, steerGuards: {}, subagentReports: 0, subagentWoken: 0, restatements: 0 });
|
|
67
47
|
/**
|
|
68
48
|
* One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
|
|
69
49
|
* message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
|
|
@@ -154,6 +134,20 @@ export function assistantPlan(ctx) {
|
|
|
154
134
|
}
|
|
155
135
|
return undefined;
|
|
156
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* The once-per-session notice: why Jev judgments are off and what turns them on. `rejectedEnv` is the environment
|
|
139
|
+
* variable that holds a rejected key, or undefined when the rejected key is the one `/typesafe login` saved. Never
|
|
140
|
+
* names a key value or a path.
|
|
141
|
+
*/
|
|
142
|
+
export function judgmentsOffText(reason, backend, headless, rejectedEnv) {
|
|
143
|
+
switch (reason) {
|
|
144
|
+
case "no_consent": return `warden: Jev judgments are off (no consent). ${headless ? "Set PI_WARDEN_ENABLED=1." : "Run /warden enable."}`;
|
|
145
|
+
case "no_key": return `warden: Jev judgments are off (no key for ${backend}). Set ${keyEnvFor(backend)}${loginStoresKey(backend) ? " or run /typesafe login" : ""}.`;
|
|
146
|
+
case "key_rejected": return rejectedEnv
|
|
147
|
+
? `warden: Jev judgments are off (the key in ${rejectedEnv} was rejected). Check the key, then run /warden status.`
|
|
148
|
+
: "warden: Jev judgments are off (the key saved by /typesafe login was rejected). Run /typesafe login.";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
157
151
|
function activeMode(config, hasUI) {
|
|
158
152
|
const env = process.env.PI_WARDEN_MODE?.trim();
|
|
159
153
|
const mode = isMode(env) ? env : config.mode;
|
|
@@ -256,10 +250,17 @@ export default function wardenExtension(pi) {
|
|
|
256
250
|
// fail-closes (no delivery) until the policy is re-measured.
|
|
257
251
|
const consciencePolicy = CONSCIENCE_BETA_POLICY;
|
|
258
252
|
let client;
|
|
253
|
+
const cooldown = new JudgeCooldown();
|
|
254
|
+
let judgeConfig;
|
|
255
|
+
/** Where cooldown notices go; unset headless, where a failure is already silent (see noteError). */
|
|
256
|
+
let noticeUi;
|
|
259
257
|
let budgetExhausted = false;
|
|
260
258
|
let stats = freshStats();
|
|
261
259
|
const widget = new Map();
|
|
262
260
|
const trace = new Trace();
|
|
261
|
+
// The trace file for a host without the terminal UI; set per session when PI_WARDEN_TRACE_DIR is an absolute path.
|
|
262
|
+
let traceFile;
|
|
263
|
+
let unsubscribeTraceFile;
|
|
263
264
|
let panel;
|
|
264
265
|
let configPanel;
|
|
265
266
|
let lastUi;
|
|
@@ -309,6 +310,13 @@ export default function wardenExtension(pi) {
|
|
|
309
310
|
const secretsSeen = new Set();
|
|
310
311
|
// Subagent report entries already triaged, by session entry id; the wake window outlives one scan.
|
|
311
312
|
const subagentSeen = new Set();
|
|
313
|
+
// Command families already steered toward filtered output this session: one steer per family.
|
|
314
|
+
const largeOutputSteered = new Set();
|
|
315
|
+
// Real paths the agent created under the temp directory this session, with the identity each had when recorded.
|
|
316
|
+
// A recursive rm of only these is not destructive; a record whose path is gone or replaced is dropped after each call.
|
|
317
|
+
const sessionScratch = new Map();
|
|
318
|
+
// Per tool call id: when it started and the temp paths it may create that did not exist yet.
|
|
319
|
+
const scratchPending = new Map();
|
|
312
320
|
const wakePolicy = new WakePolicy(0);
|
|
313
321
|
const runaway = new RunawayMonitor();
|
|
314
322
|
// Runs stopped by the runaway guard for the current user prompt; the first one gets a recovery turn, later ones wait for the user.
|
|
@@ -368,14 +376,79 @@ export default function wardenExtension(pi) {
|
|
|
368
376
|
return config;
|
|
369
377
|
};
|
|
370
378
|
const consentGiven = (config) => config.typesafe || process.env.PI_WARDEN_ENABLED === "1";
|
|
379
|
+
/** Why no judge is available, or undefined when one is. The notice, the trace file, and the conscience all use it. */
|
|
380
|
+
const judgmentsOffReason = (config) => {
|
|
381
|
+
if (!consentGiven(config))
|
|
382
|
+
return "no_consent";
|
|
383
|
+
if (budgetExhausted)
|
|
384
|
+
return "budget";
|
|
385
|
+
const auth = authState({ backend: config.typesafeBackend });
|
|
386
|
+
if (auth.usable)
|
|
387
|
+
return undefined;
|
|
388
|
+
// No key in effect, or one whose last request came back 401 or 403.
|
|
389
|
+
return auth.source === undefined ? "no_key" : "key_rejected";
|
|
390
|
+
};
|
|
391
|
+
// Where the judgments-off notice goes: the session's UI, or a status message for a headless session.
|
|
392
|
+
let judgmentsNotify;
|
|
393
|
+
let judgmentsHeadless = false;
|
|
394
|
+
const judgmentsReported = new Set();
|
|
395
|
+
/** Say once per session and reason that judgments are off, and record the state in the trace file. */
|
|
396
|
+
const noteJudgments = (config, reason) => {
|
|
397
|
+
traceFile?.judgments(judgmentsState(reason));
|
|
398
|
+
// The budget has its own notice in noteError.
|
|
399
|
+
if (reason === undefined || reason === "budget" || judgmentsReported.has(reason))
|
|
400
|
+
return;
|
|
401
|
+
judgmentsReported.add(reason);
|
|
402
|
+
const auth = reason === "key_rejected" ? authState({ backend: config.typesafeBackend }) : undefined;
|
|
403
|
+
judgmentsNotify?.(judgmentsOffText(reason, config.typesafeBackend, judgmentsHeadless, auth?.kind === "environment" ? auth.keyName : undefined));
|
|
404
|
+
};
|
|
371
405
|
const consentSource = (config) => config.typesafe ? "/warden enable" : process.env.PI_WARDEN_ENABLED === "1" ? "PI_WARDEN_ENABLED" : undefined;
|
|
372
406
|
/** A consent flag is not proof that judgments happen; check the key state for the chosen backend. */
|
|
373
407
|
const judgeFor = (config) => {
|
|
374
|
-
|
|
408
|
+
const off = judgmentsOffReason(config);
|
|
409
|
+
noteJudgments(config, off);
|
|
410
|
+
if (off)
|
|
375
411
|
return undefined;
|
|
376
|
-
|
|
412
|
+
judgeConfig = config;
|
|
413
|
+
// Absent, exactly as with no judge configured, so no guard needs to know a cooldown exists.
|
|
414
|
+
if (cooldown.active()) {
|
|
415
|
+
stats.cooldownSkips++;
|
|
377
416
|
return undefined;
|
|
378
|
-
|
|
417
|
+
}
|
|
418
|
+
return client ??= watched(createTypeSafe(judgeOptions(config)));
|
|
419
|
+
};
|
|
420
|
+
/** Every guard reaches the backend through `evaluate`, so this one seam sees each failure and each success. */
|
|
421
|
+
const watched = (typesafe) => new Proxy(typesafe, {
|
|
422
|
+
get(target, prop, receiver) {
|
|
423
|
+
if (prop !== "evaluate")
|
|
424
|
+
return Reflect.get(target, prop, receiver);
|
|
425
|
+
return async (...args) => {
|
|
426
|
+
try {
|
|
427
|
+
const result = await target.evaluate(...args);
|
|
428
|
+
tellCooldown(cooldown.success());
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
const kind = cooldownFailureKind(err, args[1]?.signal);
|
|
433
|
+
if (kind)
|
|
434
|
+
tellCooldown(cooldown.failure(kind, judgeConfig?.judge ?? defaultConfig().judge));
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
const tellCooldown = (event) => {
|
|
441
|
+
if (!event || !noticeUi)
|
|
442
|
+
return;
|
|
443
|
+
if (event.type === "recovered") {
|
|
444
|
+
noticeUi.notify("warden: judgments resumed.", "info");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const seconds = Math.round(event.ms / 1000);
|
|
448
|
+
const remedy = event.kind === "auth"
|
|
449
|
+
? ` Set ${keyEnvFor(judgeConfig?.typesafeBackend ?? "typesafe")} or run /warden enable.`
|
|
450
|
+
: event.kind === "configuration" ? " /warden status shows the backend setup." : "";
|
|
451
|
+
noticeUi.notify(`warden: judgments paused for ${seconds}s after a ${event.kind} failure; pattern checks run alone until then.${remedy}`, "warning");
|
|
379
452
|
};
|
|
380
453
|
const noteError = (ctx, message, code) => {
|
|
381
454
|
stats.errors++;
|
|
@@ -387,15 +460,22 @@ export default function wardenExtension(pi) {
|
|
|
387
460
|
/** Click, shortcut, and /warden trace all toggle the same sidebar. */
|
|
388
461
|
const togglePanel = (ui, config) => {
|
|
389
462
|
if (!ui)
|
|
390
|
-
return;
|
|
463
|
+
return undefined;
|
|
391
464
|
if (panel) {
|
|
392
465
|
panel.close();
|
|
393
|
-
return;
|
|
466
|
+
return undefined;
|
|
394
467
|
}
|
|
395
468
|
const opened = openTracePanel(ui, trace, { width: config.widget.panelWidth });
|
|
396
469
|
panel = opened;
|
|
397
470
|
opened.closed.catch(() => undefined).finally(() => { if (panel === opened)
|
|
398
471
|
panel = undefined; });
|
|
472
|
+
return opened;
|
|
473
|
+
};
|
|
474
|
+
/** The last 20 trace entries as text, newest last, for a host that cannot show the sidebar. */
|
|
475
|
+
const traceText = () => {
|
|
476
|
+
const entries = trace.entries();
|
|
477
|
+
const text = entries.length ? entries.slice(-20).map(entry => `${new Date(entry.at).toTimeString().slice(0, 8)} ${entry.guard}: ${entry.line}${entry.details.length ? `\n ${entry.details.join("\n ")}` : ""}`).join("\n") : "No guarded activity yet this session.";
|
|
478
|
+
return traceFile ? `${text}\nTrace file: ${traceFile.path}` : text;
|
|
399
479
|
};
|
|
400
480
|
/** The config overlay toggles the same way the sidebar does, so the hint in its header is true. */
|
|
401
481
|
const toggleConfigPanel = (ui, config) => {
|
|
@@ -617,6 +697,8 @@ export default function wardenExtension(pi) {
|
|
|
617
697
|
pi.on("session_start", async (_event, ctx) => {
|
|
618
698
|
if (ctx.hasUI)
|
|
619
699
|
lastUi = ctx.ui;
|
|
700
|
+
noticeUi = ctx.hasUI ? ctx.ui : undefined;
|
|
701
|
+
cooldown.reset();
|
|
620
702
|
if (_event.reason === "reload" || _event.reason === "new")
|
|
621
703
|
conscienceGeneration++;
|
|
622
704
|
client = undefined;
|
|
@@ -634,7 +716,27 @@ export default function wardenExtension(pi) {
|
|
|
634
716
|
rulesGuard.reset();
|
|
635
717
|
holds.reset();
|
|
636
718
|
regretCandidates = [];
|
|
637
|
-
|
|
719
|
+
const sessionId = typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid);
|
|
720
|
+
holdLog = new HoldLog(holdLogPath(sessionId));
|
|
721
|
+
unsubscribeTraceFile?.();
|
|
722
|
+
unsubscribeTraceFile = undefined;
|
|
723
|
+
traceFile = undefined;
|
|
724
|
+
judgmentsReported.clear();
|
|
725
|
+
judgmentsHeadless = !ctx.hasUI;
|
|
726
|
+
judgmentsNotify = text => { if (ctx.hasUI)
|
|
727
|
+
ctx.ui.notify(text, "warning");
|
|
728
|
+
else
|
|
729
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
|
|
730
|
+
const dir = traceDir();
|
|
731
|
+
if (dir) {
|
|
732
|
+
const warn = (text) => { if (ctx.hasUI)
|
|
733
|
+
ctx.ui.notify(text, "warning");
|
|
734
|
+
else
|
|
735
|
+
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: text, display: true }); };
|
|
736
|
+
const opening = loadConfig();
|
|
737
|
+
traceFile = new TraceFile(traceFilePath(dir, sessionId), dir, { sessionId, cwd: ctx.cwd, mode: opening.mode, judgments: judgmentsState(judgmentsOffReason(opening)) }, warn);
|
|
738
|
+
unsubscribeTraceFile = trace.subscribe(traceFile.listener);
|
|
739
|
+
}
|
|
638
740
|
arming.reset();
|
|
639
741
|
attempts.reset();
|
|
640
742
|
evidence = emptyEvidence();
|
|
@@ -653,6 +755,9 @@ export default function wardenExtension(pi) {
|
|
|
653
755
|
compressionLearner.reset();
|
|
654
756
|
secretsSeen.clear();
|
|
655
757
|
subagentSeen.clear();
|
|
758
|
+
largeOutputSteered.clear();
|
|
759
|
+
sessionScratch.clear();
|
|
760
|
+
scratchPending.clear();
|
|
656
761
|
wakePolicy.reset();
|
|
657
762
|
steerRepeats.reset();
|
|
658
763
|
steersThisRun = 0;
|
|
@@ -688,6 +793,7 @@ export default function wardenExtension(pi) {
|
|
|
688
793
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
689
794
|
if (ctx.hasUI)
|
|
690
795
|
lastUi = ctx.ui;
|
|
796
|
+
noticeUi = ctx.hasUI ? ctx.ui : undefined;
|
|
691
797
|
const config = configFor(ctx);
|
|
692
798
|
attempts = new AttemptWindow(config.stuck.window);
|
|
693
799
|
fullOutputs = new Map();
|
|
@@ -744,6 +850,7 @@ export default function wardenExtension(pi) {
|
|
|
744
850
|
const truncationRecorded = event.prompt.length > 2000 ? [`prompt truncated from ${event.prompt.length} to 2000 chars`] : [];
|
|
745
851
|
// Build recent context from session branch (up to 4 messages, 500 chars each)
|
|
746
852
|
const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
|
|
853
|
+
const spine = taskSpine(branch, event.prompt);
|
|
747
854
|
const recentMessages = [];
|
|
748
855
|
for (const entry of branch.slice(-6)) {
|
|
749
856
|
if (recentMessages.length >= 4)
|
|
@@ -804,7 +911,7 @@ export default function wardenExtension(pi) {
|
|
|
804
911
|
// it. Empty until the first evaluate call, and an empty model never matches a policy, so the gate fails closed.
|
|
805
912
|
let judgeModel = "";
|
|
806
913
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); judgeModel = r.model; return { answers: r.answers }; } } : undefined;
|
|
807
|
-
const result = await assess(redactedPrompt, recentContext, skills, toolInfos, activeSkills, suppliedSkills, { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
914
|
+
const result = await assess(redactedPrompt, recentContext, skills, toolInfos, activeSkills, suppliedSkills, { judge: judgeAdapter, judgmentsOff: judgmentsOffReason(config), config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
808
915
|
// Check generation after await
|
|
809
916
|
if (conscienceGeneration !== myGeneration) {
|
|
810
917
|
record(ctx, config, "conscience", "stale: generation changed during assessment", ["trigger: before_agent_start", `generation: ${myGeneration} → ${conscienceGeneration}`, `skipReason: stale`]);
|
|
@@ -889,7 +996,7 @@ export default function wardenExtension(pi) {
|
|
|
889
996
|
}
|
|
890
997
|
}
|
|
891
998
|
catch (err) {
|
|
892
|
-
const category =
|
|
999
|
+
const category = classifyJudgeError(err);
|
|
893
1000
|
if (!warnedErrorCategories.has(category)) {
|
|
894
1001
|
warnedErrorCategories.add(category);
|
|
895
1002
|
console.warn(`pi-warden: conscience ${category}`);
|
|
@@ -925,7 +1032,10 @@ export default function wardenExtension(pi) {
|
|
|
925
1032
|
const judge = judgeFor(config);
|
|
926
1033
|
if (judge && cachedSkills.length > 0) {
|
|
927
1034
|
const myGeneration = conscienceGeneration;
|
|
928
|
-
const
|
|
1035
|
+
const prompt = typeof event.message.content === "string" ? event.message.content : "";
|
|
1036
|
+
const redactedText = redact(prompt).slice(0, 2000);
|
|
1037
|
+
const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
|
|
1038
|
+
const spine = taskSpine(branch, prompt);
|
|
929
1039
|
let toolInfos = [];
|
|
930
1040
|
try {
|
|
931
1041
|
toolInfos = pi.getAllTools().map((t) => ({ name: t.name, description: t.description }));
|
|
@@ -936,13 +1046,13 @@ export default function wardenExtension(pi) {
|
|
|
936
1046
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
937
1047
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
938
1048
|
try {
|
|
939
|
-
const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
1049
|
+
const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
940
1050
|
if (conscienceGeneration !== myGeneration)
|
|
941
1051
|
return;
|
|
942
1052
|
record(ctx, config, "conscience", `queued prompt assessed → ${result.selected ? `${result.selected.kind}:${result.selected.id}` : "none"} (${result.skipReason ?? "none"})`, ["trigger: message_start", `origin: queued`, `skipReason: ${result.skipReason ?? "none"}`]);
|
|
943
1053
|
}
|
|
944
1054
|
catch (err) {
|
|
945
|
-
const cat =
|
|
1055
|
+
const cat = classifyJudgeError(err);
|
|
946
1056
|
if (!warnedErrorCategories.has(cat)) {
|
|
947
1057
|
warnedErrorCategories.add(cat);
|
|
948
1058
|
console.warn(`pi-warden: conscience ${cat}`);
|
|
@@ -1002,11 +1112,13 @@ export default function wardenExtension(pi) {
|
|
|
1002
1112
|
catch {
|
|
1003
1113
|
toolInfos = [];
|
|
1004
1114
|
}
|
|
1005
|
-
const
|
|
1115
|
+
const prompt = latestUserPrompt(ctx);
|
|
1116
|
+
const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
|
|
1117
|
+
const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
|
|
1006
1118
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
1007
1119
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
1008
1120
|
try {
|
|
1009
|
-
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
1121
|
+
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
1010
1122
|
if (conscienceGeneration !== myGeneration)
|
|
1011
1123
|
return;
|
|
1012
1124
|
assessmentsThisPrompt++;
|
|
@@ -1031,6 +1143,8 @@ export default function wardenExtension(pi) {
|
|
|
1031
1143
|
const config = configFor(ctx);
|
|
1032
1144
|
if (!config.enabled)
|
|
1033
1145
|
return;
|
|
1146
|
+
if (event.toolName === "bash" || event.toolName === "write")
|
|
1147
|
+
scratchPending.set(event.toolCallId, { started: Date.now(), candidates: scratchCandidates(event.toolName, event.input, ctx.cwd) });
|
|
1034
1148
|
// ── Conscience: track tool attempts on the selected capability ──
|
|
1035
1149
|
if (config.conscience.enabled && selectedCapability && !triggerConsumed) {
|
|
1036
1150
|
if (selectedCapability.kind === "tool" && event.toolName === selectedCapability.id) {
|
|
@@ -1106,7 +1220,7 @@ export default function wardenExtension(pi) {
|
|
|
1106
1220
|
? rulesGuard.inspect(call, siblings, { cwd: ctx.cwd, config: config.rules, judge, timeoutMs: config.timeoutMs, signal: ctx.signal })
|
|
1107
1221
|
: undefined;
|
|
1108
1222
|
rulesCheck?.catch(() => undefined);
|
|
1109
|
-
const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined });
|
|
1223
|
+
const verdict = await actionGuard.inspect(call, { task, context: recentTaskContext(ctx), siblings, plan: assistantPlan(ctx), spine: taskSpine(ctx.sessionManager.getBranch()) }, { config: config.action, cwd: ctx.cwd, judge, signal: ctx.signal, slop: config.slop, security: config.security, largeOutput: config.context.largeOutput, rules: config.rules, previousActions: regretCandidates.length ? regretCandidates : undefined, scratch: sessionScratch });
|
|
1110
1224
|
if (verdict.source === "skipped")
|
|
1111
1225
|
return;
|
|
1112
1226
|
// Arming check: if any armed rule's command regex matches this call, inject a hit into the verdict.
|
|
@@ -1162,6 +1276,11 @@ export default function wardenExtension(pi) {
|
|
|
1162
1276
|
// Notes for the agent about the content it just wrote: slop, rule violations, and sensitive paths arrive as one message.
|
|
1163
1277
|
const notes = [];
|
|
1164
1278
|
const noteGuards = new Set();
|
|
1279
|
+
/** Slop and rule findings say "just written", so they wait until the call proceeds. A held, denied, or declined write
|
|
1280
|
+
* never lands and drops them; an approved retry is judged again and brings its own. They go between the action notes
|
|
1281
|
+
* and the sensitive-path notes, the order of the combined message. */
|
|
1282
|
+
const contentNotes = [];
|
|
1283
|
+
const pathNotes = [];
|
|
1165
1284
|
/** Sensitive-path notes ride with the combined steer for this call; their trace waits for the delivery result. */
|
|
1166
1285
|
const pathNoteTraces = [];
|
|
1167
1286
|
if (verdict.judgment?.securityRisk !== undefined && verdict.judgment.securityRisk >= config.security.threshold) {
|
|
@@ -1226,15 +1345,24 @@ export default function wardenExtension(pi) {
|
|
|
1226
1345
|
noteGuards.add("action");
|
|
1227
1346
|
notes.push(shouldProceedMessage(verdict));
|
|
1228
1347
|
}
|
|
1348
|
+
const largeOutput = largeOutputNotice(verdict, largeOutputSteered);
|
|
1349
|
+
if (largeOutput) {
|
|
1350
|
+
noteGuards.add("action");
|
|
1351
|
+
notes.push(largeOutput);
|
|
1352
|
+
}
|
|
1229
1353
|
if (verdict.slopSymptoms?.length && verdict.slopReasons) {
|
|
1230
1354
|
stats.slop++;
|
|
1231
|
-
|
|
1232
|
-
for (const symptom of verdict.slopSymptoms)
|
|
1233
|
-
slopCounts[symptom]++;
|
|
1355
|
+
const symptoms = verdict.slopSymptoms;
|
|
1234
1356
|
const where = verdict.summary.path ?? event.toolName;
|
|
1235
1357
|
if (ctx.hasUI && config.notices)
|
|
1236
1358
|
ctx.ui.notify(`warden · slop · ${where}: ${verdict.slopReasons.join("; ")}`, "warning");
|
|
1237
|
-
|
|
1359
|
+
contentNotes.push(proceeds => {
|
|
1360
|
+
if (!proceeds)
|
|
1361
|
+
return undefined;
|
|
1362
|
+
for (const symptom of symptoms)
|
|
1363
|
+
slopCounts[symptom]++;
|
|
1364
|
+
return { guard: "action", text: slopSteer(where, symptoms, slopCounts) };
|
|
1365
|
+
});
|
|
1238
1366
|
}
|
|
1239
1367
|
if (rulesCheck) {
|
|
1240
1368
|
const rules = await rulesCheck;
|
|
@@ -1242,16 +1370,23 @@ export default function wardenExtension(pi) {
|
|
|
1242
1370
|
stats.ruleChecks++;
|
|
1243
1371
|
if (rules.error)
|
|
1244
1372
|
noteError(ctx, rules.error, rules.errorCode);
|
|
1245
|
-
const told = rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
|
|
1246
|
-
record(ctx, config, "rules", formatRules(rules, config.widget.rules), rulesDetails(rules, told));
|
|
1247
1373
|
holds.recordRules({ source: rules.source, path: rules.path, findings: rules.findings.map(f => ({ name: f.name, violation: f.violation })), ...(rules.error ? { error: rules.error } : {}) });
|
|
1248
|
-
if (
|
|
1374
|
+
if (rules.findings.length) {
|
|
1249
1375
|
stats.ruleViolations++;
|
|
1250
|
-
noteGuards.add("rules");
|
|
1251
1376
|
if (ctx.hasUI && config.notices)
|
|
1252
1377
|
ctx.ui.notify(`warden · rules · ${rules.path}: ${rules.findings.map(finding => `${finding.name} (${finding.violation.toFixed(2)})`).join("; ")}`, "warning");
|
|
1253
|
-
notes.push(told);
|
|
1254
1378
|
}
|
|
1379
|
+
contentNotes.push(proceeds => {
|
|
1380
|
+
const told = proceeds && rules.findings.length ? rulesSteer(rules, rulesGuard.count(rules)) : undefined;
|
|
1381
|
+
const details = rulesDetails(rules, told);
|
|
1382
|
+
if (!proceeds && rules.findings.length)
|
|
1383
|
+
details.push("agent not told: the write was held");
|
|
1384
|
+
record(ctx, config, "rules", formatRules(rules, config.widget.rules), details);
|
|
1385
|
+
return told ? { guard: "rules", text: told } : undefined;
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
else {
|
|
1389
|
+
record(ctx, config, "rules", formatRules(rules, config.widget.rules), [`${rules.tool} ${rules.path}: ${rules.skippedReason}`]);
|
|
1255
1390
|
}
|
|
1256
1391
|
const hits = rulesGuard.notesFor(verdict.summary.location === "inside_project" ? verdict.summary.path : undefined, config.rules.sensitivePaths);
|
|
1257
1392
|
if (hits.length && verdict.summary.path) {
|
|
@@ -1265,17 +1400,29 @@ export default function wardenExtension(pi) {
|
|
|
1265
1400
|
ctx.ui.notify(`warden · sensitive path · ${verdict.summary.path} (${hits.map(hit => hit.glob).join(", ")}); the agent was given the note`, "warning");
|
|
1266
1401
|
});
|
|
1267
1402
|
noteGuards.add("rules");
|
|
1268
|
-
|
|
1403
|
+
pathNotes.push(told);
|
|
1269
1404
|
}
|
|
1270
1405
|
}
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
for (const
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1406
|
+
/** Sends the combined steer once the call's fate is known; `proceeds` is false when the call does not run. */
|
|
1407
|
+
const deliverNotes = (proceeds) => {
|
|
1408
|
+
for (const contentNote of contentNotes) {
|
|
1409
|
+
const note = contentNote(proceeds);
|
|
1410
|
+
if (!note)
|
|
1411
|
+
continue;
|
|
1412
|
+
noteGuards.add(note.guard);
|
|
1413
|
+
notes.push(note.text);
|
|
1414
|
+
}
|
|
1415
|
+
notes.push(...pathNotes);
|
|
1416
|
+
if (notes.length) {
|
|
1417
|
+
const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
|
|
1418
|
+
for (const traceNote of pathNoteTraces)
|
|
1419
|
+
traceNote(delivered);
|
|
1420
|
+
pathNoteTraces.length = 0;
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1277
1423
|
const warnSteer = (reasons) => reasons.length > 0 && steer(config, "action", `pi-warden: this ${event.toolName} call ran with a warning (${reasons.join("; ")}). Nobody sees this in a headless run, so it is on you: if the flagged risk is expected, continue; otherwise fix it or ask the user before building on it.`);
|
|
1278
1424
|
if (verdict.level === "warn") {
|
|
1425
|
+
deliverNotes(true);
|
|
1279
1426
|
stats.warned++;
|
|
1280
1427
|
if (ctx.hasUI && config.notices)
|
|
1281
1428
|
ctx.ui.notify(`warden · ${event.toolName}: ${verdict.reasons.join("; ")}`, "warning");
|
|
@@ -1287,6 +1434,7 @@ export default function wardenExtension(pi) {
|
|
|
1287
1434
|
return undefined;
|
|
1288
1435
|
}
|
|
1289
1436
|
if (verdict.level === "deny") {
|
|
1437
|
+
deliverNotes(false);
|
|
1290
1438
|
stats.held++;
|
|
1291
1439
|
track(true, "declined", "deny");
|
|
1292
1440
|
if (ctx.hasUI && config.notices)
|
|
@@ -1295,6 +1443,7 @@ export default function wardenExtension(pi) {
|
|
|
1295
1443
|
return { block: true, reason: `pi-warden blocked this ${event.toolName} call (${deliveryReasons.join("; ")}). A deny rule matched; this command is not allowed to run. Ask the user if this is genuinely required.` };
|
|
1296
1444
|
}
|
|
1297
1445
|
if (verdict.level !== "confirm") {
|
|
1446
|
+
deliverNotes(true);
|
|
1298
1447
|
track(false);
|
|
1299
1448
|
return undefined;
|
|
1300
1449
|
}
|
|
@@ -1307,6 +1456,7 @@ export default function wardenExtension(pi) {
|
|
|
1307
1456
|
if (dialogRule && ctx.hasUI) {
|
|
1308
1457
|
notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
|
|
1309
1458
|
const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
|
|
1459
|
+
deliverNotes(allowed);
|
|
1310
1460
|
if (allowed) {
|
|
1311
1461
|
track(true, "approved", "dialog");
|
|
1312
1462
|
return undefined;
|
|
@@ -1316,6 +1466,7 @@ export default function wardenExtension(pi) {
|
|
|
1316
1466
|
return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${deliveredReasons}). Do not retry it unchanged; ask the user how to proceed.` };
|
|
1317
1467
|
}
|
|
1318
1468
|
if (mode === "advise") {
|
|
1469
|
+
deliverNotes(true);
|
|
1319
1470
|
stats.warned++;
|
|
1320
1471
|
if (ctx.hasUI && config.notices)
|
|
1321
1472
|
ctx.ui.notify(`warden · ${event.toolName} (advise mode, not held): ${reasons}`, "warning");
|
|
@@ -1327,6 +1478,7 @@ export default function wardenExtension(pi) {
|
|
|
1327
1478
|
if (mode === "confirm") {
|
|
1328
1479
|
notifyDesktop(ctx, config, `Waiting for you: allow this ${event.toolName} call? ${reasons}`);
|
|
1329
1480
|
const allowed = await ctx.ui.confirm(`warden: allow this ${event.toolName} call?`, confirmMessage(verdict), ctx.signal ? { signal: ctx.signal } : {});
|
|
1481
|
+
deliverNotes(allowed);
|
|
1330
1482
|
if (allowed) {
|
|
1331
1483
|
track(true, "approved", "dialog");
|
|
1332
1484
|
return undefined;
|
|
@@ -1335,6 +1487,7 @@ export default function wardenExtension(pi) {
|
|
|
1335
1487
|
track(true, "declined", "dialog");
|
|
1336
1488
|
return { block: true, reason: `pi-warden: the user declined this ${event.toolName} call (${deliveredReasons}). Do not retry it unchanged; ask the user how to proceed.` };
|
|
1337
1489
|
}
|
|
1490
|
+
deliverNotes(false);
|
|
1338
1491
|
stats.held++;
|
|
1339
1492
|
actionGuard.hold(task);
|
|
1340
1493
|
track(true);
|
|
@@ -1349,6 +1502,13 @@ export default function wardenExtension(pi) {
|
|
|
1349
1502
|
return;
|
|
1350
1503
|
const textBlocks = event.content.filter(part => part.type === "text");
|
|
1351
1504
|
const text = textBlocks.map(part => part.text).join("\n");
|
|
1505
|
+
const scratch = scratchPending.get(event.toolCallId);
|
|
1506
|
+
if (scratch) {
|
|
1507
|
+
scratchPending.delete(event.toolCallId);
|
|
1508
|
+
for (const [path, identity] of createdScratch(event.toolName, event.input, text, scratch.started, scratch.candidates))
|
|
1509
|
+
sessionScratch.set(path, identity);
|
|
1510
|
+
}
|
|
1511
|
+
pruneScratch(sessionScratch);
|
|
1352
1512
|
// Repeat detection uses the original result, so its request goes out together with the output check.
|
|
1353
1513
|
const failed = resultFailed(event.isError, event.details, event.content);
|
|
1354
1514
|
// ── Conscience: track tool_result for the pending capability ──
|
|
@@ -1471,7 +1631,9 @@ export default function wardenExtension(pi) {
|
|
|
1471
1631
|
}
|
|
1472
1632
|
if (config.context.enabled && !earlier && !recallRead && textBlocks.length === 1 && text.length >= config.context.tailMinChars)
|
|
1473
1633
|
ledger.candidate();
|
|
1474
|
-
|
|
1634
|
+
// Captured before the loop so the notice block below knows whether each block already carries its banner.
|
|
1635
|
+
const bannersPlacedWithBlocks = multiBlock && !ctx.signal?.aborted;
|
|
1636
|
+
if (bannersPlacedWithBlocks) {
|
|
1475
1637
|
// Retention and banners per block; block order and non-text parts are never touched.
|
|
1476
1638
|
content = [...content];
|
|
1477
1639
|
let textIndex = 0;
|
|
@@ -1539,8 +1701,11 @@ export default function wardenExtension(pi) {
|
|
|
1539
1701
|
}
|
|
1540
1702
|
if (key && !earlier)
|
|
1541
1703
|
ledger.remember(key, event.toolName, storedPath);
|
|
1704
|
+
// The banner in the result already tells the agent. A steer custom message sent here arrives after the turn ends
|
|
1705
|
+
// when this result closes the turn and starts a new turn to answer a message that asks for nothing, so the notice
|
|
1706
|
+
// rides the result content alone and is never steered.
|
|
1542
1707
|
if (notice && textBlocks.length) {
|
|
1543
|
-
if (!
|
|
1708
|
+
if (!bannersPlacedWithBlocks) {
|
|
1544
1709
|
let index = 0;
|
|
1545
1710
|
content = content.map(part => {
|
|
1546
1711
|
if (part.type !== "text")
|
|
@@ -1549,13 +1714,12 @@ export default function wardenExtension(pi) {
|
|
|
1549
1714
|
return { ...part, text: `${index === 1 ? `${notice}\n\n` : ""}${part.text}${index === textBlocks.length ? `\n\n${notice}` : ""}` };
|
|
1550
1715
|
});
|
|
1551
1716
|
}
|
|
1552
|
-
const delivered = steer(config, "security", notice);
|
|
1553
1717
|
record(ctx, config, "security", renderTemplate(config.widget.security, {
|
|
1554
1718
|
tool: event.toolName, injection: output.injection?.toFixed(2), exfiltration: output.exfiltration?.toFixed(2),
|
|
1555
1719
|
status: [output.suspicious && "untrusted instructions", output.secret && "possible credentials"].filter(Boolean).join(", "),
|
|
1556
1720
|
}), [
|
|
1557
1721
|
`jev: injection ${output.injection?.toFixed(2) ?? "not judged"}; exfiltration ${output.exfiltration?.toFixed(2) ?? "not judged"}`,
|
|
1558
|
-
`output sample: ${redact(text).slice(0, 300)}`,
|
|
1722
|
+
`output sample: ${redact(text).slice(0, 300)}`, `agent told by the banner in the tool result: ${notice}`,
|
|
1559
1723
|
]);
|
|
1560
1724
|
}
|
|
1561
1725
|
// Stuck-loop diff: when the verdict is stuck on a failed result and a previous failed output
|
|
@@ -1745,11 +1909,13 @@ export default function wardenExtension(pi) {
|
|
|
1745
1909
|
catch {
|
|
1746
1910
|
toolInfos = [];
|
|
1747
1911
|
}
|
|
1748
|
-
const
|
|
1912
|
+
const prompt = latestUserPrompt(ctx);
|
|
1913
|
+
const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
|
|
1914
|
+
const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
|
|
1749
1915
|
const activeSkills = cachedSkills.map(s => s.name);
|
|
1750
1916
|
const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
|
|
1751
1917
|
try {
|
|
1752
|
-
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile });
|
|
1918
|
+
const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
|
|
1753
1919
|
if (conscienceGeneration !== myGeneration)
|
|
1754
1920
|
return;
|
|
1755
1921
|
assessmentsThisPrompt++;
|
|
@@ -1863,7 +2029,7 @@ export default function wardenExtension(pi) {
|
|
|
1863
2029
|
: `Lifetime here: ${ls.held} hold${ls.held === 1 ? "" : "s"}, ${ls.labeled} labeled, ${ls.declined + ls.replanned} stood (${ls.declined + ls.replanned}/${ls.labeled}), ${ls.allowed} allowed (${ls.accepted} accepted, ${ls.regretted} regretted).`;
|
|
1864
2030
|
report([
|
|
1865
2031
|
`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}`,
|
|
1866
|
-
`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`}.`,
|
|
2032
|
+
`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, ${stats.cooldownSkips} checks without Jev during a judge cooldown; ${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`}.`,
|
|
1867
2033
|
formatSteers(stats),
|
|
1868
2034
|
`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}.`,
|
|
1869
2035
|
formatLedger(ledger.snapshot()),
|
|
@@ -1881,11 +2047,16 @@ export default function wardenExtension(pi) {
|
|
|
1881
2047
|
}
|
|
1882
2048
|
if (action === "trace") {
|
|
1883
2049
|
if (!ctx.hasUI) {
|
|
1884
|
-
|
|
1885
|
-
report(entries.length ? entries.slice(-20).map(entry => `${new Date(entry.at).toTimeString().slice(0, 8)} ${entry.guard}: ${entry.line}${entry.details.length ? `\n ${entry.details.join("\n ")}` : ""}`).join("\n") : "No guarded activity yet this session.");
|
|
2050
|
+
report(traceText());
|
|
1886
2051
|
return;
|
|
1887
2052
|
}
|
|
1888
|
-
togglePanel(ctx.ui, config);
|
|
2053
|
+
const opened = togglePanel(ctx.ui, config);
|
|
2054
|
+
// RPC mode has a UI but settles custom() without building the component, so the sidebar never shows. hasUI
|
|
2055
|
+
// is true there; only the host not asking for the component tells the two apart. Not awaited: in the
|
|
2056
|
+
// terminal `closed` settles when the user closes the sidebar.
|
|
2057
|
+
const fallback = () => { if (opened && !opened.built())
|
|
2058
|
+
ctx.ui.notify(traceText(), "info"); };
|
|
2059
|
+
void opened?.closed.then(fallback, fallback);
|
|
1889
2060
|
return;
|
|
1890
2061
|
}
|
|
1891
2062
|
if (action === "recommend") {
|