pi-warden 1.7.0 → 1.11.2

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
@@ -6,7 +6,7 @@ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
  import { defaultHostDirs } from "./host-dirs.js";
7
7
  import * as tuiModule from "@earendil-works/pi-tui";
8
8
  const MouseRegion = tuiModule.MouseRegion;
9
- import { authState, backendHost, createTypeSafe, describeAuth, resolveBackend, ask } from "pi-typesafe";
9
+ import { authState, backendHost, createTypeSafe, describeAuth, resolveBackend, chunkEvaluationRequest } from "pi-typesafe";
10
10
  import { ensureApiKey } from "pi-typesafe/ui";
11
11
  import { backendName, describeBackend, disclosureFor, judgeOptions, loginStoresKey } from "./backend.js";
12
12
  import { ActionGuard } from "./action-guard.js";
@@ -17,7 +17,7 @@ import * as configModule from "./config.js";
17
17
  import { applyUserOverrides, defaultConfig, getNestedValue, isMode, loadConfig, PACKAGE_NAME, parseConfigValue, PROJECT_CONFIG_FILE, projectConfigPath, readUserConfig, setNestedValue, setUserSetting, userConfigPath, writeUserConfig } from "./config.js";
18
18
  import { checkSkipReason, classifyToolResult, doneNudge, emptyEvidence, evaluateDone, finalAssistantText, formatDone, isVisualCheck, localUrl, needsDoneCheck, projectCheckCommand, recordOutcome as recordDoneOutcome, recordUi } from "./done.js";
19
19
  import { causeNudge, causePreFilter, emptyCauseActivity, evaluateCause, formatCause, recordCauseActivity } from "./cause.js";
20
- import { createdScratch, describeAsked, evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, largeOutputNotice, movedInTargets, offTaskSteer, pruneScratch, scratchCandidates, SCRATCH_PATHS_ENV, scratchPaths, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, wardenHostPaths, writeSinkTargets, isVisibleCommand } from "./guard.js";
20
+ import { createdScratch, describeAsked, evaluateAction, formatVerdictTokens, higher, inertPathRules, intentSteer, largeOutputNotice, movedInTargets, offTaskSteer, pruneScratch, scratchCandidates, SCRATCH_PATHS_ENV, scratchPaths, scoreFreeReason, shouldProceedMessage, SLOP_LABELS, SteerRepeatWindow, steerReason, stripDataText, unknownExemptIds, wardenHostPaths, writeSinkTargets, isVisibleCommand } from "./guard.js";
21
21
  import { commandOf } from "./tools.js";
22
22
  import { mergeWrites, shellWrites } from "./shell-writes.js";
23
23
  import { formatHolds, HoldLedger, HoldLog, holdLogPath, mutatingCall, outcomeNote, regretsAt, textRegrets } from "./holds.js";
@@ -27,7 +27,7 @@ import { compressOutput, duplicateNote, evaluateOutput, isGenericExcerpt, mergeO
27
27
  import { filterOutput } from "./filter.js";
28
28
  import { classifyRecall, detectSearchTool, recallInstruction } from "./recall.js";
29
29
  import { maskSecrets, redact } from "./redact.js";
30
- import { formatRules, pathNoteSteer, projectPath, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES, turnRulesFor } from "./rules.js";
30
+ import { askChunks, formatRules, pathNoteSteer, projectPath, questionLimit, RulesGuard, rulesSteer, RULES_FILE, FALLBACK_FILES, turnRulesFor } from "./rules.js";
31
31
  import { evaluateTurnRun, SHELL_RULES_CHECKS, snapshotTree, turnSteer } from "./turn-rules.js";
32
32
  import { checkRules, formatRulesCheck } from "./rules-lint.js";
33
33
  import { parseRulesAuditArgs, parseBenchArgs, runRulesAudit, runBench, formatRulesAudit, formatBench } from "./rules-audit.js";
@@ -50,11 +50,12 @@ import { openConfigPanel, openTracePanel } from "./panel.js";
50
50
  import { completeConfig, shapeWarning, taskSpine } from "./shape.js";
51
51
  import { ContextLedger, formatFilterLedger, formatLedger } from "./saver.js";
52
52
  import { buildCuratorRequest, curatedRuleList, curatedRules, CURATOR_TIMEOUT_MS, CURATOR_TYPE, CuratorLedger, formatCuratedRules, formatCurator, ruleScores } from "./curator.js";
53
- import { SeenText, collapseRuns, seenItem } from "./dedupe.js";
53
+ import { SeenText, collapseRuns, savedResultIn, seenItems } from "./dedupe.js";
54
54
  import { buildCompactSnapshot, compactAppendix, recallText } from "./compact.js";
55
55
  import { formatCompaction, relevanceCompaction } from "./relevance.js";
56
56
  import { applyLoopAction, formatLoopsForUser, formatOpenLoops, LOOP_ACTIONS, LOOP_CHARS, loopsFingerprint, loopsPath, openLoops, readLoops, updateLoops } from "./loops.js";
57
57
  import { changePrefsStore, emptyWordCounts, evaluatePrefs, forgetPref, formatPrefs, LESSON_CHARS, prefsMessage, prefsStorePath, readPrefsStore, recordLesson, scanPreferences } from "./prefs.js";
58
+ import { armFor, isSuppressed, steerDeliveryKind, TRIAL_ENTRY_TYPE, trialText } from "./trial.js";
58
59
  import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
59
60
  import { actionDetails, causeDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
60
61
  import { TraceFile, judgmentsState, traceDir, traceFilePath } from "./trace-file.js";
@@ -340,6 +341,9 @@ export function guardCurrentSections(result) {
340
341
  config.subagent = { ...config.subagent, observer: false };
341
342
  if (typeof config.widget.subagent !== "string")
342
343
  config.widget = { ...config.widget, subagent: DEFAULT_TEMPLATES.subagent };
344
+ // The trial section is new and off by default: a stale shape module hands the config over without it, and no delivery changes.
345
+ if (typeof config.trial !== "object" || config.trial === null || typeof config.trial.silentHalf !== "boolean")
346
+ config.trial = { silentHalf: false };
343
347
  return { config, missing };
344
348
  }
345
349
  /** Native Pi registration; importing the root library does not load this module. */
@@ -443,6 +447,38 @@ export default function wardenExtension(host) {
443
447
  let holdLog;
444
448
  /** The session id of the open session; every hold row of it carries it. */
445
449
  let activeSessionId;
450
+ /** The silent-half trial arm of the open session: "live" unless the user turned the trial on and the id hashed silent. */
451
+ let trialArm = "live";
452
+ /**
453
+ * One line of the trial record, appended as a custom session entry: Pi keeps it in the session log and never adds it
454
+ * to the model's context. A host without the entry API keeps the session running; the failure is local and loud once.
455
+ */
456
+ let trialRecordFailed = false;
457
+ const appendTrial = (record) => {
458
+ try {
459
+ if (typeof pi.appendEntry === "function")
460
+ pi.appendEntry(TRIAL_ENTRY_TYPE, record);
461
+ else if (!trialRecordFailed) {
462
+ trialRecordFailed = true;
463
+ console.warn("pi-warden: the host has no appendEntry; the trial record cannot be written");
464
+ }
465
+ }
466
+ catch (error) {
467
+ if (trialRecordFailed)
468
+ return;
469
+ trialRecordFailed = true;
470
+ console.warn("pi-warden: trial record failed:", error instanceof Error ? error.message : String(error));
471
+ }
472
+ };
473
+ /** Whether the silent arm withholds this delivery; a withheld delivery is recorded, redacted and clipped to 300 chars. */
474
+ const trialHolds = (kind, guard, text) => {
475
+ if (trialArm !== "silent" || !isSuppressed(kind))
476
+ return false;
477
+ appendTrial({ v: 1, record: "suppressed", kind, guard, at: Date.now(), text: trialText(redact(text)) });
478
+ return true;
479
+ };
480
+ /** The same test for a delivery whose kind is not yet narrowed; false keeps it live. */
481
+ const trialHoldsKind = (kind, guard, text) => trialArm === "silent" && isSuppressed(kind) ? trialHolds(kind, guard, text) : false;
446
482
  // Local rules verdict log for this session; a write failure is silent to the agent and shown once in the trace.
447
483
  let rulesLog;
448
484
  // Allowed calls of the turn the user just replied to; the regret question about them rides the next action request.
@@ -609,6 +645,8 @@ export default function wardenExtension(host) {
609
645
  const last = [...branch].reverse().find(entry => entry.type === "custom_message" && entry.customType === LOOPS_TYPE);
610
646
  if (last?.content === content)
611
647
  return;
648
+ if (trialHolds("loops", "loops", content))
649
+ return;
612
650
  pi.sendMessage({ customType: LOOPS_TYPE, content, display: config.steerVisible });
613
651
  }
614
652
  catch (error) {
@@ -897,7 +935,7 @@ export default function wardenExtension(host) {
897
935
  const manager = ctx.sessionManager;
898
936
  // Compaction-aware when the host has it: text a compaction summarized is no longer in context, so nothing may point to it.
899
937
  const entries = typeof manager.buildContextEntries === "function" ? manager.buildContextEntries() : typeof manager.getBranch === "function" ? manager.getBranch() : [];
900
- seenText.sync(entries.map(seenItem).filter(item => item !== undefined));
938
+ seenText.sync(seenItems(entries, ctx.model?.provider));
901
939
  };
902
940
  /** `text` with each run that repeats context replaced by a pointer line, or undefined when nothing repeats or the copy cannot be stored. */
903
941
  const dedupeText = async (ctx, config, text, source) => {
@@ -1044,7 +1082,14 @@ export default function wardenExtension(host) {
1044
1082
  }
1045
1083
  stats.steers++;
1046
1084
  steersThisRun++;
1047
- const { display, ...delivery } = options ?? { deliverAs: "steer" };
1085
+ const { display, kind, ...delivery } = options ?? {};
1086
+ // A call site that names only a kind, or nothing, keeps the old default; one that passes its own delivery options keeps them exactly.
1087
+ if (delivery.deliverAs === undefined && delivery.triggerTurn === undefined)
1088
+ delivery.deliverAs = "steer";
1089
+ // The silent arm records what it would have said and sends nothing, after the same budget and repeat checks as
1090
+ // the live arm, so both arms spend a run's steers alike.
1091
+ if (trialHoldsKind(kind ?? steerDeliveryKind(names[0] ?? "action"), names.join(", "), content))
1092
+ return false;
1048
1093
  pi.sendMessage({ customType: `${PACKAGE_NAME}-steer`, content, display: display ?? config.steerVisible }, delivery);
1049
1094
  if (delivery.triggerTurn)
1050
1095
  wardenContinuation = true;
@@ -1062,6 +1107,8 @@ export default function wardenExtension(host) {
1062
1107
  const notices = pendingNotices;
1063
1108
  pendingNotices = [];
1064
1109
  for (const notice of notices) {
1110
+ if (trialHolds(notice.customType === CURATOR_TYPE ? "turn-rules" : "conscience", notice.guard, notice.content))
1111
+ continue;
1065
1112
  pi.sendMessage({ customType: notice.customType, content: notice.content, display: config.steerVisible }, { deliverAs: "steer" });
1066
1113
  record(ctx, config, notice.guard, notice.delivered, notice.details);
1067
1114
  }
@@ -1288,6 +1335,7 @@ export default function wardenExtension(host) {
1288
1335
  regretCandidates = [];
1289
1336
  const sessionId = typeof ctx.sessionManager.getSessionId === "function" ? ctx.sessionManager.getSessionId() : String(process.pid);
1290
1337
  activeSessionId = sessionId;
1338
+ trialArm = "live";
1291
1339
  holdLog = new HoldLog(holdLogPath(sessionId, new Date(), dirs));
1292
1340
  rulesLog = new RulesLog(ctx.cwd, sessionId, { path: rulesLogPath(ctx.cwd, dirs), onFailure: message => record(ctx, loadConfig({ dirs }), "rules", "rules log write failed", [message]) });
1293
1341
  unsubscribeTraceFile?.();
@@ -1389,6 +1437,13 @@ export default function wardenExtension(host) {
1389
1437
  pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: msg, display: true });
1390
1438
  }
1391
1439
  const opening = configFor(ctx);
1440
+ // ── Silent-half trial ──
1441
+ // The arm is fixed for the whole session from its id, so a resume or reload keeps it. With the setting off, no
1442
+ // record is written and no delivery changes; only the user's config file can turn it on.
1443
+ if (opening.trial.silentHalf === true) {
1444
+ trialArm = armFor(sessionId);
1445
+ appendTrial({ v: 1, record: "arm", arm: trialArm, sessionId, at: Date.now() });
1446
+ }
1392
1447
  // A resumed or reloaded session already carries the message; sending it again would repeat it in the context.
1393
1448
  const openingBranch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
1394
1449
  const prefsSent = openingBranch.some(entry => entry.type === "custom_message" && entry.customType === PREFS_TYPE);
@@ -1396,7 +1451,7 @@ export default function wardenExtension(host) {
1396
1451
  try {
1397
1452
  const message = prefsMessage((await evaluatedPrefs(ctx)).items);
1398
1453
  // Not a steer; one message per session; do not spend a steer unit.
1399
- if (message)
1454
+ if (message && !trialHolds("prefs", "prefs", message))
1400
1455
  pi.sendMessage({ customType: PREFS_TYPE, content: message, display: opening.steerVisible });
1401
1456
  }
1402
1457
  catch (error) {
@@ -1414,6 +1469,17 @@ export default function wardenExtension(host) {
1414
1469
  * says why in the trace, and an answer that arrives after the run ended is dropped, also with a trace line.
1415
1470
  */
1416
1471
  const curateTurnStart = (ctx, config, prompt) => {
1472
+ // A newer Pi also fires this event for a run a custom message starts (pi.sendMessage with triggerTurn), and that
1473
+ // run has no operator prompt. There is no request to judge for rule relevance, so the reminder skips with a trace
1474
+ // line instead of crashing on `undefined`, like the skips below.
1475
+ if (typeof prompt !== "string") {
1476
+ record(ctx, config, "rules", "warden · rules · turn start · skipped", [
1477
+ "trigger: before_agent_start",
1478
+ "skipReason: custom_trigger",
1479
+ "the run started from a custom message, not an operator prompt; no rule question was sent",
1480
+ ]);
1481
+ return;
1482
+ }
1417
1483
  // A prompt the conscience already skips needs no rules reminder either: a short continuation or a relayed child
1418
1484
  // report carries no work of its own, so no question is sent. The reason is traced either way.
1419
1485
  const gated = redact(prompt).slice(0, 2000);
@@ -1453,7 +1519,7 @@ export default function wardenExtension(host) {
1453
1519
  const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
1454
1520
  const request = buildCuratorRequest(prompt, taskSpine(branch, prompt), rules);
1455
1521
  const started = Date.now();
1456
- void ask(judge, request, { timeoutMs: Math.min(config.timeoutMs, CURATOR_TIMEOUT_MS), ...(ctx.signal ? { signal: ctx.signal } : {}) })
1522
+ void askChunks(judge, chunkEvaluationRequest(request, { maxQuestions: questionLimit(judge) }), { timeoutMs: Math.min(config.timeoutMs, CURATOR_TIMEOUT_MS), ...(ctx.signal ? { signal: ctx.signal } : {}) })
1457
1523
  .then(answer => {
1458
1524
  const elapsedMs = Date.now() - started;
1459
1525
  // The run this request belonged to has ended: nothing may be appended after it (a delivery must never start a turn).
@@ -1528,14 +1594,20 @@ export default function wardenExtension(host) {
1528
1594
  // re-append the same text, which is what keeps it in the prompt instead of being diffed away.
1529
1595
  const wasteOptions = event.systemPromptOptions;
1530
1596
  if (config.enabled && config.waste.enabled && config.waste.tip && wasteOptions) {
1531
- wasteOptions.appendSystemPrompt = withWasteTip(wasteOptions.appendSystemPrompt);
1597
+ // The silent arm withholds the tip and records it once, the way the live arm traces it once.
1598
+ const held = trialArm === "silent";
1599
+ if (held && !wasteTipOffered)
1600
+ trialHolds("waste", "waste", WASTE_TIP);
1601
+ if (!held)
1602
+ wasteOptions.appendSystemPrompt = withWasteTip(wasteOptions.appendSystemPrompt);
1532
1603
  if (!wasteTipOffered) {
1533
1604
  wasteTipOffered = true;
1534
- record(ctx, config, "waste", "warden · waste · session tip · instructions_supplied", [
1535
- "trigger: before_agent_start",
1536
- "delivery: appended to the system prompt (appendSystemPrompt)",
1537
- `tip: ${WASTE_TIP}`,
1538
- ]);
1605
+ if (!held)
1606
+ record(ctx, config, "waste", "warden · waste · session tip · instructions_supplied", [
1607
+ "trigger: before_agent_start",
1608
+ "delivery: appended to the system prompt (appendSystemPrompt)",
1609
+ `tip: ${WASTE_TIP}`,
1610
+ ]);
1539
1611
  }
1540
1612
  }
1541
1613
  fullOutputs = new Map();
@@ -1578,6 +1650,17 @@ export default function wardenExtension(host) {
1578
1650
  loadedSkillNames = new Set();
1579
1651
  queuedRevision++;
1580
1652
  clearFileIdentityCache();
1653
+ // The same custom-message case as the rules reminder: no operator prompt, nothing to assess. The state above
1654
+ // still resets, so nothing from an older prompt leaks into a later one.
1655
+ const prompt = event.prompt;
1656
+ if (typeof prompt !== "string") {
1657
+ record(ctx, config, "conscience", "warden · conscience · turn start · skipped", [
1658
+ "trigger: before_agent_start",
1659
+ "skipReason: custom_trigger",
1660
+ "the run started from a custom message, not an operator prompt; no assessment was sent",
1661
+ ]);
1662
+ return;
1663
+ }
1581
1664
  const judge = judgeFor(config);
1582
1665
  // Get the resolved skill catalog from the event's system prompt options (spec §3 rule 1)
1583
1666
  const skills = event.systemPromptOptions?.skills ?? [];
@@ -1590,17 +1673,17 @@ export default function wardenExtension(host) {
1590
1673
  toolInfos = [];
1591
1674
  }
1592
1675
  // Detect explicit /skill:name invocation (spec §3 rule 5)
1593
- const explicitSkillMatch = event.prompt.match(/\/skill:([\w-]+)/);
1676
+ const explicitSkillMatch = prompt.match(/\/skill:([\w-]+)/);
1594
1677
  if (explicitSkillMatch) {
1595
1678
  record(ctx, config, "conscience", `explicit skill invocation: ${explicitSkillMatch[1]}`, ["trigger: input", `explicit_skill: ${explicitSkillMatch[1]}`]);
1596
1679
  }
1597
1680
  else {
1598
1681
  // Redact and truncate prompt to 2000 chars (spec §6 payload limit)
1599
- const redactedPrompt = redact(event.prompt).slice(0, 2000);
1600
- const truncationRecorded = event.prompt.length > 2000 ? [`prompt truncated from ${event.prompt.length} to 2000 chars`] : [];
1682
+ const redactedPrompt = redact(prompt).slice(0, 2000);
1683
+ const truncationRecorded = prompt.length > 2000 ? [`prompt truncated from ${prompt.length} to 2000 chars`] : [];
1601
1684
  // Build recent context from session branch (up to 4 messages, 500 chars each)
1602
1685
  const branch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
1603
- const spine = taskSpine(branch, event.prompt);
1686
+ const spine = taskSpine(branch, prompt);
1604
1687
  const recentMessages = [];
1605
1688
  for (const entry of branch.slice(-6)) {
1606
1689
  if (recentMessages.length >= 4)
@@ -1660,7 +1743,7 @@ export default function wardenExtension(host) {
1660
1743
  // The evaluate result carries the model that answered; the activation gate compares the policy's model against
1661
1744
  // it. Empty until the first evaluate call, and an empty model never matches a policy, so the gate fails closed.
1662
1745
  let judgeModel = "";
1663
- const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); judgeModel = r.model; return { answers: r.answers }; } } : undefined;
1746
+ const judgeAdapter = judge ? { limits: judge.limits, evaluate: async (req) => { const r = await judge.evaluate(req); judgeModel = r.model; return { answers: r.answers }; } } : undefined;
1664
1747
  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);
1665
1748
  // Check generation and run after await: a result that belongs to an ended or replaced run must not deliver.
1666
1749
  if (conscienceGeneration !== myGeneration || myPrompt !== promptEpoch || !agentRunActive) {
@@ -1814,7 +1897,7 @@ export default function wardenExtension(host) {
1814
1897
  toolInfos = [];
1815
1898
  }
1816
1899
  const activeSkills = cachedSkills.map(s => s.name);
1817
- const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
1900
+ const judgeAdapter = judge ? { limits: judge.limits, evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
1818
1901
  try {
1819
1902
  const result = await assess(redactedText, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
1820
1903
  if (conscienceGeneration !== myGeneration)
@@ -1917,7 +2000,7 @@ export default function wardenExtension(host) {
1917
2000
  const redactedPrompt = redact(prompt ?? "").slice(0, 2000);
1918
2001
  const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
1919
2002
  const activeSkills = cachedSkills.map(s => s.name);
1920
- const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
2003
+ const judgeAdapter = judge ? { limits: judge.limits, evaluate: async (req) => { const r = await judge.evaluate(req); return { answers: r.answers }; } } : undefined;
1921
2004
  try {
1922
2005
  const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
1923
2006
  if (conscienceGeneration !== myGeneration)
@@ -2091,6 +2174,8 @@ export default function wardenExtension(host) {
2091
2174
  }
2092
2175
  const deliveryVerdict = agentDeliveryVerdict(verdict);
2093
2176
  const deliveryReasons = deliveryVerdict.reasons;
2177
+ // The agent reads reasons without their scores; the trace, holds, and learning records keep them.
2178
+ const agentReasons = deliveryReasons.map(scoreFreeReason);
2094
2179
  if (regretCandidates.length && verdict.judgment?.regretted !== undefined) {
2095
2180
  settleRegret(config, { regretted: regretsAt(verdict.judgment.regretted), target: verdict.judgment.regretTarget, probability: verdict.judgment.regretted, via: "jev" });
2096
2181
  }
@@ -2279,7 +2364,7 @@ export default function wardenExtension(host) {
2279
2364
  }
2280
2365
  notes.push(...pathNotes);
2281
2366
  if (notes.length) {
2282
- const delivered = steer(config, [...noteGuards], notes.join("\n\n"));
2367
+ const delivered = steer(config, [...noteGuards], notes.join("\n\n"), { kind: "action-notes" });
2283
2368
  const notePath = verdict.summary.path ?? shell?.writes[0]?.path;
2284
2369
  if (delivered)
2285
2370
  watchSteer(ctx, config, noteKinds, notePath ? { path: notePath } : {});
@@ -2291,7 +2376,7 @@ export default function wardenExtension(host) {
2291
2376
  const warnSteer = (reasons) => {
2292
2377
  if (!reasons.length || !adaptiveSend(ctx, config, "warn-headless"))
2293
2378
  return false;
2294
- const sent = 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.`);
2379
+ const sent = steer(config, "action", `pi-warden: this ${event.toolName} call ran with a warning (${reasons.map(scoreFreeReason).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.`, { kind: "warn-headless" });
2295
2380
  if (sent)
2296
2381
  watchSteer(ctx, config, ["warn-headless"]);
2297
2382
  return sent;
@@ -2315,7 +2400,7 @@ export default function wardenExtension(host) {
2315
2400
  if (ctx.hasUI && config.notices)
2316
2401
  ctx.ui.notify(`warden · blocked ${event.toolName}: ${verdict.reasons.join("; ")}`, "error");
2317
2402
  notifyDesktop(ctx, config, `Blocked ${event.toolName}: ${verdict.reasons.join("; ")}. A deny rule matched; the call never ran.`);
2318
- 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.` };
2403
+ return { block: true, reason: `pi-warden blocked this ${event.toolName} call (${agentReasons.join("; ")}). A deny rule matched; this command is not allowed to run. Ask the user if this is genuinely required.` };
2319
2404
  }
2320
2405
  if (verdict.level !== "confirm") {
2321
2406
  deliverNotes(true);
@@ -2323,7 +2408,7 @@ export default function wardenExtension(host) {
2323
2408
  return undefined;
2324
2409
  }
2325
2410
  const reasons = verdict.reasons.join("; ");
2326
- const deliveredReasons = deliveryReasons.join("; ");
2411
+ const deliveredReasons = agentReasons.join("; ");
2327
2412
  // A user-defined confirm rule prompts the user in every mode, advise included: the operator wrote the rule to be
2328
2413
  // asked. Advise mode keeps Jev holds advisory; it does not soften a prompt the operator asked for by name.
2329
2414
  // The action defaults to dialog at parse time (the reason one writes such a rule); hold restores steer semantics.
@@ -2533,7 +2618,8 @@ export default function wardenExtension(host) {
2533
2618
  storedPath = earlier.path ?? await saveOutput(text);
2534
2619
  const replacement = `${duplicateNote(text, earlier.tool)}\n\n${recallInstruction(recallTool, storedPath)}`;
2535
2620
  const bytesSaved = Buffer.byteLength(text) - Buffer.byteLength(replacement);
2536
- if (bytesSaved > 0) {
2621
+ // The silent arm records the duplicate note and leaves the tool result whole.
2622
+ if (bytesSaved > 0 && !trialHolds("context-compression", "context", replacement)) {
2537
2623
  content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
2538
2624
  ledger.duplicate(bytesSaved);
2539
2625
  ledger.remember(key, earlier.tool, storedPath);
@@ -2591,6 +2677,9 @@ export default function wardenExtension(host) {
2591
2677
  }
2592
2678
  if (!compressed && verdict.confidence !== undefined)
2593
2679
  recordKeptWhole(config, event.toolName, verdict, `text block ${textIndex} of ${blockVerdicts.length}: `);
2680
+ // The silent arm records the compressed block and leaves the original, with its security banner, in place.
2681
+ if (compressed && replacement && trialHolds("context-compression", "context", replacement))
2682
+ replacement = undefined;
2594
2683
  if (!replacement && blockNotice)
2595
2684
  replacement = `${blockNotice}\n\n${blockText}\n\n${blockNotice}`;
2596
2685
  if (replacement)
@@ -2609,7 +2698,9 @@ export default function wardenExtension(host) {
2609
2698
  const replacement = `${filtered?.ok ? filtered.text : excerpt}\n\n${recallInstruction(recallTool, path)}`;
2610
2699
  const bytesSaved = Buffer.byteLength(text) - Buffer.byteLength(replacement) - (notice ? Buffer.byteLength(notice) * 2 + 4 : 0);
2611
2700
  if (bytesSaved > 0) {
2612
- content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
2701
+ // The silent arm records the excerpt and leaves the tool result whole; the security banner is placed below.
2702
+ if (!trialHolds("context-compression", "context", replacement))
2703
+ content = content.map(part => part.type === "text" ? { ...part, text: replacement } : part);
2613
2704
  compressed = true;
2614
2705
  ledger.record(path, bytesSaved, { tool: event.toolName, bytes: Buffer.byteLength(text), kind: filtered?.ok ? "filtered" : generic ? "excerpt" : "parser", ...(filtered?.ok ? { keptChars: filtered.keptChars } : {}) });
2615
2706
  storedPath = path;
@@ -2631,11 +2722,12 @@ export default function wardenExtension(host) {
2631
2722
  if (!compressed && !ctx.signal?.aborted && output.confidence !== undefined)
2632
2723
  recordKeptWhole(config, event.toolName, output);
2633
2724
  }
2634
- // A recall brings the stored text back on purpose, and a duplicate note has nothing left to cut.
2635
- if (config.context.enabled && config.context.dedupeRuns && !earlier && !recallRead && !ctx.signal?.aborted) {
2725
+ // A recall brings the stored text back on purpose, and a duplicate note has nothing left to cut. A result the
2726
+ // provider saved to a file never reached the model, so a read of that file is never a repeat.
2727
+ if (config.context.enabled && config.context.dedupeRuns && !earlier && !recallRead && !savedResultIn(JSON.stringify(event.input), ctx.model?.provider) && !ctx.signal?.aborted) {
2636
2728
  syncSeen(ctx);
2637
2729
  const deduped = await dedupeContent(ctx, config, content, event.toolName);
2638
- if (deduped)
2730
+ if (deduped && !trialHolds("context-compression", "context", `repeated runs collapsed in a ${event.toolName} result`))
2639
2731
  content = deduped;
2640
2732
  }
2641
2733
  if (key && !earlier)
@@ -2675,7 +2767,7 @@ export default function wardenExtension(host) {
2675
2767
  const saved = await saveOutput(text);
2676
2768
  const diffNote = stuckDiff(prevEntry.text, text, { diffLimit: config.stuck.diffLimit, tailLimit: config.stuck.tailLimit, fullPath: saved });
2677
2769
  const currentLen = Buffer.byteLength(content.map(part => part.type === "text" ? part.text : "").join("\n"));
2678
- if (Buffer.byteLength(diffNote) < currentLen) {
2770
+ if (Buffer.byteLength(diffNote) < currentLen && !trialHolds("context-compression", "context", diffNote)) {
2679
2771
  content = [{ type: "text", text: diffNote }, ...content.filter(part => part.type !== "text")];
2680
2772
  }
2681
2773
  fullOutputs.set(currentKey, { text, path: saved });
@@ -2689,7 +2781,7 @@ export default function wardenExtension(host) {
2689
2781
  // Byte-identical outputs: one-line note reusing the existing saved path.
2690
2782
  const diffNote = stuckDiff(text, text, { diffLimit: config.stuck.diffLimit, tailLimit: config.stuck.tailLimit, fullPath: prevEntry.path });
2691
2783
  const currentLen = Buffer.byteLength(content.map(part => part.type === "text" ? part.text : "").join("\n"));
2692
- if (Buffer.byteLength(diffNote) < currentLen) {
2784
+ if (Buffer.byteLength(diffNote) < currentLen && !trialHolds("context-compression", "context", diffNote)) {
2693
2785
  content = [{ type: "text", text: diffNote }, ...content.filter(part => part.type !== "text")];
2694
2786
  }
2695
2787
  }
@@ -2709,6 +2801,8 @@ export default function wardenExtension(host) {
2709
2801
  const withWaste = (current) => {
2710
2802
  if (!wasteNudge)
2711
2803
  return current;
2804
+ if (trialHolds("waste", "waste", wasteNudge.text))
2805
+ return current;
2712
2806
  const base = current?.content ?? event.content;
2713
2807
  const lastText = base.reduce((last, part, index) => part.type === "text" ? index : last, -1);
2714
2808
  if (lastText < 0)
@@ -2798,10 +2892,12 @@ export default function wardenExtension(host) {
2798
2892
  const source = message.role === "custom" ? `${message.customType} message` : "user message";
2799
2893
  if (typeof message.content === "string") {
2800
2894
  const text = await dedupeText(ctx, config, message.content, source);
2801
- return text === undefined ? undefined : { message: { ...message, content: text } };
2895
+ if (text === undefined || trialHolds("context-compression", "context", text))
2896
+ return undefined;
2897
+ return { message: { ...message, content: text } };
2802
2898
  }
2803
2899
  const content = await dedupeContent(ctx, config, message.content, source);
2804
- return content ? { message: { ...message, content } } : undefined;
2900
+ return content && !trialHolds("context-compression", "context", `repeated runs collapsed in a ${source}`) ? { message: { ...message, content } } : undefined;
2805
2901
  });
2806
2902
  // The agent has caught up and Pi will not continue on its own: the one moment a wake costs the user nothing.
2807
2903
  pi.on("agent_settled", async (_event, ctx) => {
@@ -2843,7 +2939,8 @@ export default function wardenExtension(host) {
2843
2939
  if (!appendix)
2844
2940
  return;
2845
2941
  // Not a steer; one message per compaction; do not spend a steer unit.
2846
- pi.sendMessage({ customType: `${PACKAGE_NAME}-compact-evidence`, content: appendix, display: config.steerVisible });
2942
+ if (!trialHolds("compact-evidence", "context", appendix))
2943
+ pi.sendMessage({ customType: `${PACKAGE_NAME}-compact-evidence`, content: appendix, display: config.steerVisible });
2847
2944
  }
2848
2945
  catch (error) {
2849
2946
  const message = error instanceof Error ? error.message : String(error);
@@ -2902,6 +2999,9 @@ export default function wardenExtension(host) {
2902
2999
  // Pi checks the signal only after the hook returns; an aborted compaction is not replaced.
2903
3000
  if (event.signal.aborted)
2904
3001
  return fallback("aborted", [counts], stats);
3002
+ // The silent arm records what the compaction would have kept and lets Pi's own summary run.
3003
+ if (trialHolds("context-compression", "context", `relevance compaction kept ${stats.kept} of ${stats.candidates}`))
3004
+ return undefined;
2905
3005
  compactions.replaced++;
2906
3006
  compactions.last = stats;
2907
3007
  record(ctx, config, "context", `warden · context · relevance compaction · kept ${stats.kept} of ${stats.candidates}`, [
@@ -3026,7 +3126,7 @@ export default function wardenExtension(host) {
3026
3126
  const spine = taskSpine(typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [], prompt ?? undefined);
3027
3127
  const activeSkills = cachedSkills.map(s => s.name);
3028
3128
  let judgeModel = "";
3029
- const judgeAdapter = judge ? { evaluate: async (req) => { const r = await judge.evaluate(req); judgeModel = r.model; return { answers: r.answers }; } } : undefined;
3129
+ const judgeAdapter = judge ? { limits: judge.limits, evaluate: async (req) => { const r = await judge.evaluate(req); judgeModel = r.model; return { answers: r.answers }; } } : undefined;
3030
3130
  try {
3031
3131
  const result = await assess(redactedPrompt, "", cachedSkills, toolInfos, activeSkills, [], { judge: judgeAdapter, config: config.conscience, sharedTimeoutMs: config.timeoutMs, now: () => Date.now(), globalIndex: globalIndexFile, projectIndex: projectIndexFile }, spine);
3032
3132
  if (conscienceGeneration !== myGeneration)
@@ -3913,8 +4013,9 @@ export default function wardenExtension(host) {
3913
4013
  // Accept both the full format and entries-only; warden always writes the final metadata.
3914
4014
  let globalResult = rawGlobal ? validateIndex(rawGlobal) : { file: undefined, rejections: [] };
3915
4015
  let projectResult = rawProject ? validateIndex(rawProject) : { file: undefined, rejections: [] };
3916
- // Entries-only fallback: read raw JSON and extract entries array
3917
- if (!globalResult.file && rawGlobal) {
4016
+ // Entries-only fallback: read raw JSON and extract entries array. It must not depend on rawGlobal/rawProject:
4017
+ // the strict read returns nothing for the entries-only shape the index prompt asks for.
4018
+ if (!globalResult.file) {
3918
4019
  try {
3919
4020
  const parsed = JSON.parse(readFileSync(globalPath, "utf8"));
3920
4021
  if (parsed && typeof parsed === "object" && Array.isArray(parsed.entries)) {
@@ -3927,7 +4028,7 @@ export default function wardenExtension(host) {
3927
4028
  record(ctx, config, "conscience", `global entries-only parse: ${msg}`, ["trigger: warden_index"]);
3928
4029
  }
3929
4030
  }
3930
- if (!projectResult.file && rawProject) {
4031
+ if (!projectResult.file) {
3931
4032
  try {
3932
4033
  const parsed = JSON.parse(readFileSync(projectPath, "utf8"));
3933
4034
  if (parsed && typeof parsed === "object" && Array.isArray(parsed.entries)) {
@@ -3943,9 +4044,9 @@ export default function wardenExtension(host) {
3943
4044
  }
3944
4045
  // Collect rejections from both files
3945
4046
  const allRejections = [];
3946
- if (rawGlobal && !globalResult.file)
4047
+ if (!globalResult.file)
3947
4048
  allRejections.push(...globalResult.rejections.map(r => `global index: entry ${r}`));
3948
- if (rawProject && !projectResult.file)
4049
+ if (!projectResult.file)
3949
4050
  allRejections.push(...projectResult.rejections.map(r => `project index: entry ${r}`));
3950
4051
  if (allRejections.length > 0) {
3951
4052
  report(`Index rejected:\n${allRejections.map(r => ` ${r}`).join("\n")}\nOld files kept.`, "warning");
package/dist/filter.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { JsonValue, Judge } from "pi-typesafe";
1
+ import type { JsonValue } from "pi-typesafe";
2
2
  import type { JudgmentsOffReason } from "./backend.js";
3
3
  import type { FilterConfig } from "./config.js";
4
4
  import type { TaskSpine } from "./shape.js";
5
+ import type { LimitedJudge } from "./rules.js";
5
6
  /**
6
7
  * Context filter (beta, off by default). Where the saver would keep only the blind head/diagnostic/tail excerpt, Jev
7
8
  * scores line-bounded chunks of the output for the agent's current task, and code keeps the chunks that at least
@@ -15,7 +16,7 @@ export type FilterFallback = JudgmentsOffReason | "cooldown" | "timeout" | "erro
15
16
  export declare const FILTER_TAIL_CHARS = 1000;
16
17
  /** pi-typesafe refuses a request body over 64 KiB of JSON; the margin covers the model field and normalisation. */
17
18
  export declare const FILTER_REQUEST_BYTES = 60000;
18
- /** pi-typesafe's question limit per request. */
19
+ /** pi-typesafe's question limit per request; a backend with a lower one gets its own. */
19
20
  export declare const FILTER_MAX_QUESTIONS = 32;
20
21
  export declare const FILTER_NOTE = "Passages selected for the current task; omitted text is in the full-output file.";
21
22
  export interface FilterInput {
@@ -30,7 +31,7 @@ export interface FilterInput {
30
31
  }
31
32
  export interface FilterOptions {
32
33
  config: FilterConfig;
33
- judge: Judge;
34
+ judge: LimitedJudge;
34
35
  signal?: AbortSignal | undefined;
35
36
  }
36
37
  export type FilterResult = {
@@ -64,7 +65,7 @@ export declare function filterState(input: FilterInput): {
64
65
  [key: string]: JsonValue;
65
66
  };
66
67
  /** As few requests as the size and question limits allow; every chunk lands in exactly one, named `c<index+1>`. */
67
- export declare function buildFilterBatches(chunks: readonly string[], input: FilterInput, maxBytes?: number): Batch[];
68
+ export declare function buildFilterBatches(chunks: readonly string[], input: FilterInput, maxBytes?: number, maxQuestions?: number): Batch[];
68
69
  /**
69
70
  * Chunks scoring at least `minScore`, in original order, within `maxKeptChars` including the tail. When more qualify
70
71
  * than fit, the highest scores win and the original order is restored. Returns the kept text with gap markers, or
package/dist/filter.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ask, score } from "pi-typesafe";
1
+ import { ask, DEFAULT_BACKEND_LIMITS, score } from "pi-typesafe";
2
2
  import { redact } from "./redact.js";
3
3
  /**
4
4
  * Context filter (beta, off by default). Where the saver would keep only the blind head/diagnostic/tail excerpt, Jev
@@ -16,7 +16,7 @@ export const FILTER_RUBRIC = [
16
16
  export const FILTER_TAIL_CHARS = 1000;
17
17
  /** pi-typesafe refuses a request body over 64 KiB of JSON; the margin covers the model field and normalisation. */
18
18
  export const FILTER_REQUEST_BYTES = 60_000;
19
- /** pi-typesafe's question limit per request. */
19
+ /** pi-typesafe's question limit per request; a backend with a lower one gets its own. */
20
20
  export const FILTER_MAX_QUESTIONS = 32;
21
21
  export const FILTER_NOTE = "Passages selected for the current task; omitted text is in the full-output file.";
22
22
  /** Chunks at line boundaries near `size`; a line is split only when it alone is longer than `size`. Joined, they are `text`. */
@@ -57,7 +57,7 @@ export function filterState(input) {
57
57
  };
58
58
  }
59
59
  /** As few requests as the size and question limits allow; every chunk lands in exactly one, named `c<index+1>`. */
60
- export function buildFilterBatches(chunks, input, maxBytes = FILTER_REQUEST_BYTES) {
60
+ export function buildFilterBatches(chunks, input, maxBytes = FILTER_REQUEST_BYTES, maxQuestions = FILTER_MAX_QUESTIONS) {
61
61
  const base = filterState(input);
62
62
  const batches = [];
63
63
  let batch;
@@ -65,7 +65,7 @@ export function buildFilterBatches(chunks, input, maxBytes = FILTER_REQUEST_BYTE
65
65
  for (const [index, chunk] of chunks.entries()) {
66
66
  const id = `c${index + 1}`;
67
67
  const field = redact(chunk);
68
- if (batch && batch.ids.length < FILTER_MAX_QUESTIONS) {
68
+ if (batch && batch.ids.length < Math.min(FILTER_MAX_QUESTIONS, maxQuestions)) {
69
69
  const next = { state: { ...batch.state, [id]: field }, questions: { ...batch.questions, [id]: question(id) }, ids: [...batch.ids, index] };
70
70
  if (size(next) <= maxBytes) {
71
71
  batch = next;
@@ -149,7 +149,7 @@ export function filteredHeader(text) {
149
149
  export async function filterOutput(text, input, options) {
150
150
  const started = Date.now();
151
151
  const chunks = chunkLines(text, options.config.chunkChars);
152
- const batches = buildFilterBatches(chunks, input);
152
+ const batches = buildFilterBatches(chunks, input, FILTER_REQUEST_BYTES, options.judge.limits?.maxQuestions ?? DEFAULT_BACKEND_LIMITS.maxQuestions);
153
153
  const results = await Promise.all(batches.map(batch => ask(options.judge, { state: batch.state, questions: batch.questions }, { timeoutMs: options.config.timeoutMs, ...(options.signal ? { signal: options.signal } : {}) })));
154
154
  const elapsedMs = Date.now() - started;
155
155
  const base = { chunks: chunks.length, requests: batches.length, elapsedMs };
package/dist/guard.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Stats } from "node:fs";
2
2
  import type { IntegrationErrorCode, Judge, Questions } from "pi-typesafe";
3
3
  import type { ActionGuardConfig, ArmingRule, CommandRule, LargeOutputConfig, PathRule, RulesConfig, SecurityConfig, SlopGuardConfig } from "./config.js";
4
4
  import type { TaskSpine } from "./shape.js";
5
- import type { RulesSourceConfig } from "./rules.js";
5
+ import type { LimitedJudge, RulesSourceConfig } from "./rules.js";
6
6
  import type { HostDirs } from "./host-dirs.js";
7
7
  import { type ScriptSource } from "./script-bodies.js";
8
8
  export type Level = "allow" | "warn" | "confirm" | "deny";
@@ -198,7 +198,7 @@ export type { Judge } from "pi-typesafe";
198
198
  export interface EvaluateOptions {
199
199
  config: ActionGuardConfig;
200
200
  /** Omit to run offline pattern checks only (no consent, no network). */
201
- judge?: Judge | undefined;
201
+ judge?: LimitedJudge | undefined;
202
202
  signal?: AbortSignal | undefined;
203
203
  /** Adds quality questions for write/edit content to the same request. */
204
204
  slop?: SlopGuardConfig | undefined;
@@ -268,6 +268,14 @@ export interface ScannedCommand {
268
268
  * (`python3 - <<EOF`) are kept when the script calls out to a shell or process API.
269
269
  */
270
270
  export declare function stripDataText(command: string): ScannedCommand;
271
+ /**
272
+ * The command text Jev and the pattern floor read: shell comments removed, and each backslash-newline the shell removes
273
+ * joined (outside quotes, in double quotes and `${…}`, and in a heredoc body with an unquoted delimiter); quotes,
274
+ * escapes, heredoc bodies, `$#`, `${#…}`, and a `#` mid-word kept. Quote state carries across lines. A construct the
275
+ * scan does not model (backticks, arithmetic, `case` inside `$(…)`, quotes inside `${…}`), or an unclosed quote,
276
+ * returns the command unchanged.
277
+ */
278
+ export declare function stripShellComments(command: string): string;
271
279
  /**
272
280
  * Real paths this call moves or links data into, when the destination lies strictly inside a volatile temp root or a
273
281
  * scratch root the host declared. The session records them, so a later `rm` of a path equal to, inside, or above one is
@@ -681,9 +689,11 @@ export declare function largeOutputSteer(verdict: Verdict): string;
681
689
  export declare function largeOutputNotice(verdict: Verdict, steered: Set<string>): string | undefined;
682
690
  /** Offline stand-in for the approval question when TypeSafe is not available. */
683
691
  export declare function textApproves(task: string | undefined): boolean;
692
+ /** A reason with its Jev score removed, for text the agent reads: the words stay, the number goes. */
693
+ export declare function scoreFreeReason(reason: string): string;
684
694
  /**
685
695
  * The text the agent receives when a call is held. It explains the judgment and the two acceptable next moves,
686
- * so the model re-plans instead of retrying. Contains no command text (the model already has it) and no secrets.
696
+ * so the model re-plans instead of retrying. Contains no command text (the model already has it), no scores, and no secrets.
687
697
  */
688
698
  export declare function steerReason(verdict: Verdict, options: {
689
699
  canApprove: boolean;