pi-warden 0.60.0 → 0.64.1
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 +45 -0
- package/dist/compact.d.ts +9 -0
- package/dist/compact.js +34 -10
- package/dist/config.d.ts +7 -1
- package/dist/config.js +6 -2
- package/dist/dedupe.d.ts +41 -0
- package/dist/dedupe.js +162 -0
- package/dist/extension.d.ts +1 -1
- package/dist/extension.js +263 -38
- package/dist/guard.d.ts +4 -0
- package/dist/guard.js +132 -9
- package/dist/loops.d.ts +54 -0
- package/dist/loops.js +137 -0
- package/dist/prefs.d.ts +153 -10
- package/dist/prefs.js +362 -49
- package/dist/saver.d.ts +9 -1
- package/dist/saver.js +14 -4
- package/dist/shape.js +9 -2
- package/package.json +1 -1
package/dist/extension.js
CHANGED
|
@@ -36,8 +36,10 @@ import { classifyJudgeError, cooldownFailureKind, JudgeCooldown } from "./judge-
|
|
|
36
36
|
import { openConfigPanel, openTracePanel } from "./panel.js";
|
|
37
37
|
import { completeConfig, shapeWarning, taskSpine } from "./shape.js";
|
|
38
38
|
import { ContextLedger, formatLedger } from "./saver.js";
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
39
|
+
import { SeenText, collapseRuns, seenItem } from "./dedupe.js";
|
|
40
|
+
import { buildCompactSnapshot, compactAppendix, recallText } from "./compact.js";
|
|
41
|
+
import { applyLoopAction, formatLoopsForUser, formatOpenLoops, LOOP_ACTIONS, LOOP_CHARS, loopsFingerprint, loopsPath, openLoops, readLoops, writeLoops } from "./loops.js";
|
|
42
|
+
import { emptyWordCounts, evaluatePrefs, forgetPref, formatPrefs, isCorrection, LESSON_CHARS, LESSON_TURNS, NO_LESSON_SIGNAL, prefsMessage, prefsStorePath, readPrefsStore, recordLesson, scanPreferences, writePrefsStore } from "./prefs.js";
|
|
41
43
|
import { formatWake, newReports, reportLabel, triageReport, WakePolicy } from "./subagent.js";
|
|
42
44
|
import { actionDetails, doneDetails, proseDetails, rulesDetails, runawayDetails, stuckDetails, Trace } from "./trace.js";
|
|
43
45
|
import { TraceFile, judgmentsState, traceDir, traceFilePath } from "./trace-file.js";
|
|
@@ -47,7 +49,7 @@ const WIDGET = PACKAGE_NAME;
|
|
|
47
49
|
const CONFIRM_TEXT_LIMIT = 500;
|
|
48
50
|
/** Rules requests one bash command may start; the other files it writes are recorded as skipped. */
|
|
49
51
|
const SHELL_RULES_CHECKS = 5;
|
|
50
|
-
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 });
|
|
52
|
+
const freshStats = () => ({ inspected: 0, judged: 0, warned: 0, held: 0, approved: 0, offPlan: 0, offPlanTraceOnly: 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 });
|
|
51
53
|
/**
|
|
52
54
|
* One steer message can carry notes from more than one guard, so the per-guard numbers may add up to more than the
|
|
53
55
|
* message count; the line says so instead of hiding it. Worst offender first: that is the number worth acting on.
|
|
@@ -208,6 +210,7 @@ function agentDeliveryReasons(verdict) {
|
|
|
208
210
|
const indexes = [
|
|
209
211
|
verdict.offTaskTraceOnly ? verdict.offTaskTraceOnlyReasonIndex : undefined,
|
|
210
212
|
verdict.shouldProceedTraceOnly ? verdict.shouldProceedTraceOnlyReasonIndex : undefined,
|
|
213
|
+
verdict.intentTraceOnly ? verdict.intentTraceOnlyReasonIndex : undefined,
|
|
211
214
|
].filter((index) => index !== undefined && index >= 0 && index < verdict.reasons.length);
|
|
212
215
|
if (!indexes.length)
|
|
213
216
|
return verdict.reasons;
|
|
@@ -223,6 +226,8 @@ function agentDeliveryVerdict(verdict) {
|
|
|
223
226
|
delete deliveryVerdict.offTaskSteer;
|
|
224
227
|
if (verdict.shouldProceedTraceOnly)
|
|
225
228
|
delete deliveryVerdict.shouldProceedSteer;
|
|
229
|
+
if (verdict.intentTraceOnly)
|
|
230
|
+
delete deliveryVerdict.intentMismatch;
|
|
226
231
|
return deliveryVerdict;
|
|
227
232
|
}
|
|
228
233
|
function clip(text) {
|
|
@@ -392,7 +397,48 @@ export default function wardenExtension(pi) {
|
|
|
392
397
|
const manager = ctx.sessionManager;
|
|
393
398
|
const dir = typeof manager.getSessionDir === "function" ? manager.getSessionDir() : undefined;
|
|
394
399
|
const exclude = typeof manager.getSessionFile === "function" ? manager.getSessionFile() : undefined;
|
|
395
|
-
return prefsScan = dir ? scanPreferences({ dir, exclude, cwd: ctx.cwd }) : Promise.resolve({ prefs: [], scanned: 0, directories: 0, ms: 0 });
|
|
400
|
+
return prefsScan = dir ? scanPreferences({ dir, exclude, cwd: ctx.cwd }) : Promise.resolve({ prefs: [], candidates: [], counts: emptyWordCounts(), project: ctx.cwd, scanned: 0, directories: 0, ms: 0 });
|
|
401
|
+
};
|
|
402
|
+
/** The scan, the project's lesson file, and every listed item with its status; read fresh so `forget` numbers match. */
|
|
403
|
+
const evaluatedPrefs = async (ctx) => {
|
|
404
|
+
const scan = await standingPrefs(ctx);
|
|
405
|
+
const path = prefsStorePath(scan.project);
|
|
406
|
+
return { scan, path, items: evaluatePrefs(scan, await readPrefsStore(path)) };
|
|
407
|
+
};
|
|
408
|
+
// Assistant turns this session, and the turn of the last user correction or stuck, repeat, or done-check steer in this
|
|
409
|
+
// run: `warden_remember` records a lesson only within LESSON_TURNS turns of one.
|
|
410
|
+
let assistantTurns = 0;
|
|
411
|
+
let lessonSignalTurn;
|
|
412
|
+
// The open-loop list the last end-of-run reminder named: the same unchanged list is not named again.
|
|
413
|
+
let loopsReminded;
|
|
414
|
+
const LOOPS_TYPE = `${PACKAGE_NAME}-loops`;
|
|
415
|
+
/** The session's loops file, or undefined when the host gives the session no id (nothing to key it by). */
|
|
416
|
+
const loopsFile = (ctx) => {
|
|
417
|
+
const manager = ctx.sessionManager;
|
|
418
|
+
const id = typeof manager?.getSessionId === "function" ? manager.getSessionId() : undefined;
|
|
419
|
+
return id ? loopsPath(ctx.cwd, id) : undefined;
|
|
420
|
+
};
|
|
421
|
+
/**
|
|
422
|
+
* On resume, one message lists the loops still open, so a session picked up later knows what it promised. Not a steer.
|
|
423
|
+
* A branch whose last loops message says the same already has it.
|
|
424
|
+
*/
|
|
425
|
+
const announceLoops = async (ctx, config, branch) => {
|
|
426
|
+
const file = loopsFile(ctx);
|
|
427
|
+
if (!file)
|
|
428
|
+
return;
|
|
429
|
+
try {
|
|
430
|
+
const open = openLoops(await readLoops(file));
|
|
431
|
+
if (!open.length)
|
|
432
|
+
return;
|
|
433
|
+
const content = `Open loops from earlier in this session (warden_loops):\n${formatOpenLoops(open)}\nClose each with warden_loops done or drop once it is finished or no longer needed.`;
|
|
434
|
+
const last = [...branch].reverse().find(entry => entry.type === "custom_message" && entry.customType === LOOPS_TYPE);
|
|
435
|
+
if (last?.content === content)
|
|
436
|
+
return;
|
|
437
|
+
pi.sendMessage({ customType: LOOPS_TYPE, content, display: config.steerVisible });
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
console.warn("pi-warden: reading the loops file failed:", error instanceof Error ? error.message : String(error));
|
|
441
|
+
}
|
|
396
442
|
};
|
|
397
443
|
// A partially updated module graph can hand this build a config without the sections it expects; see shape.ts.
|
|
398
444
|
let shapeReported = false;
|
|
@@ -626,6 +672,51 @@ export default function wardenExtension(pi) {
|
|
|
626
672
|
};
|
|
627
673
|
/** A judged output the saver left whole gets a trace line with its verdict, so the confidence gate can be calibrated.
|
|
628
674
|
* Trace only: nothing happened to the output, so the status line keeps the last real context event. */
|
|
675
|
+
/** Text already in context on the current branch, for repeated-run detection. Synced before each check, not kept by hand. */
|
|
676
|
+
const seenText = new SeenText();
|
|
677
|
+
const syncSeen = (ctx) => {
|
|
678
|
+
const manager = ctx.sessionManager;
|
|
679
|
+
// Compaction-aware when the host has it: text a compaction summarized is no longer in context, so nothing may point to it.
|
|
680
|
+
const entries = typeof manager.buildContextEntries === "function" ? manager.buildContextEntries() : typeof manager.getBranch === "function" ? manager.getBranch() : [];
|
|
681
|
+
seenText.sync(entries.map(seenItem).filter(item => item !== undefined));
|
|
682
|
+
};
|
|
683
|
+
/** `text` with each run that repeats context replaced by a pointer line, or undefined when nothing repeats or the copy cannot be stored. */
|
|
684
|
+
const dedupeText = async (ctx, config, text, source) => {
|
|
685
|
+
const runs = seenText.find(text);
|
|
686
|
+
if (!runs.length)
|
|
687
|
+
return undefined;
|
|
688
|
+
try {
|
|
689
|
+
const path = await saveOutput(text);
|
|
690
|
+
const collapsed = collapseRuns(text, runs, path);
|
|
691
|
+
const bytesSaved = Buffer.byteLength(text) - Buffer.byteLength(collapsed);
|
|
692
|
+
ledger.repeat(path, bytesSaved, { tool: source, bytes: Buffer.byteLength(text) });
|
|
693
|
+
savingEntry = record(ctx, config, "context", renderTemplate(config.widget.context, { tool: source, retention: "repeat", bytesSaved: String(bytesSaved) }), [
|
|
694
|
+
...runs.map(run => `${run.lines} lines (${run.chars} characters) repeat ${run.where}`),
|
|
695
|
+
`saved ${bytesSaved} bytes; full text: ${path}`,
|
|
696
|
+
formatLedger(ledger.snapshot()),
|
|
697
|
+
]);
|
|
698
|
+
return collapsed;
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
noteError(ctx, "Could not store full text; keeping the repeated run.", undefined);
|
|
702
|
+
return undefined;
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
/** Text parts only; images and other parts keep their place and bytes. */
|
|
706
|
+
const dedupeContent = async (ctx, config, content, source) => {
|
|
707
|
+
let changed;
|
|
708
|
+
for (let index = 0; index < content.length; index++) {
|
|
709
|
+
const part = content[index];
|
|
710
|
+
if (part.type !== "text" || typeof part.text !== "string")
|
|
711
|
+
continue;
|
|
712
|
+
const text = await dedupeText(ctx, config, part.text, source);
|
|
713
|
+
if (text === undefined)
|
|
714
|
+
continue;
|
|
715
|
+
changed ??= [...content];
|
|
716
|
+
changed[index] = { ...part, text };
|
|
717
|
+
}
|
|
718
|
+
return changed;
|
|
719
|
+
};
|
|
629
720
|
const recordKeptWhole = (config, tool, verdict, prefix = "") => {
|
|
630
721
|
trace.push({ at: Date.now(), guard: "context", line: renderTemplate(config.widget.context, { tool, retention: "kept whole" }), details: [
|
|
631
722
|
`${prefix}kept whole: retention ${verdict.retention}; confidence ${verdict.confidence?.toFixed(2)}; format ${verdict.format ?? "generic"}${verdict.formatConfidence === undefined ? "" : ` (${verdict.formatConfidence.toFixed(2)})`}; ${verdict.model}; ${verdict.elapsedMs} ms`,
|
|
@@ -693,6 +784,8 @@ export default function wardenExtension(pi) {
|
|
|
693
784
|
const names = typeof guard === "string" ? [guard] : [...guard];
|
|
694
785
|
for (const name of names)
|
|
695
786
|
stats.steerGuards[name] = (stats.steerGuards[name] ?? 0) + 1;
|
|
787
|
+
if (names.some(name => name === "stuck" || name === "repeat" || name === "done"))
|
|
788
|
+
lessonSignalTurn = assistantTurns;
|
|
696
789
|
const critical = names.every(name => CRITICAL_STEER_GUARDS.has(name));
|
|
697
790
|
// A notice delivered once is already in the agent's context. Sending the repeat again costs the accounting turn it
|
|
698
791
|
// forbids, so repeats are recorded only. The same goes for notices past the per-run steer budget: a steer sent during
|
|
@@ -831,6 +924,7 @@ export default function wardenExtension(pi) {
|
|
|
831
924
|
}
|
|
832
925
|
}
|
|
833
926
|
ledger.reset();
|
|
927
|
+
seenText.clear();
|
|
834
928
|
savingEntry = undefined;
|
|
835
929
|
compressionLearner.reset();
|
|
836
930
|
secretsSeen.clear();
|
|
@@ -850,6 +944,9 @@ export default function wardenExtension(pi) {
|
|
|
850
944
|
notifier = undefined;
|
|
851
945
|
lastNotifiedAt = 0;
|
|
852
946
|
prefsScan = undefined;
|
|
947
|
+
assistantTurns = 0;
|
|
948
|
+
lessonSignalTurn = undefined;
|
|
949
|
+
loopsReminded = undefined;
|
|
853
950
|
// Load capability indexes (once per session, overwritten on every /warden index run).
|
|
854
951
|
globalIndexFile = readIndex(indexPath("global")) ?? undefined;
|
|
855
952
|
projectIndexFile = readIndex(indexPath("project", ctx.cwd)) ?? undefined;
|
|
@@ -869,13 +966,12 @@ export default function wardenExtension(pi) {
|
|
|
869
966
|
pi.sendMessage({ customType: `${PACKAGE_NAME}-status`, content: msg, display: true });
|
|
870
967
|
}
|
|
871
968
|
const opening = configFor(ctx);
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
return;
|
|
969
|
+
// A resumed or reloaded session already carries the message; sending it again would repeat it in the context.
|
|
970
|
+
const openingBranch = typeof ctx.sessionManager?.getBranch === "function" ? ctx.sessionManager.getBranch() : [];
|
|
971
|
+
const prefsSent = openingBranch.some(entry => entry.type === "custom_message" && entry.customType === PREFS_TYPE);
|
|
972
|
+
if (opening.enabled && opening.prefs.enabled && opening.prefs.inject && !prefsSent) {
|
|
877
973
|
try {
|
|
878
|
-
const message = prefsMessage((await
|
|
974
|
+
const message = prefsMessage((await evaluatedPrefs(ctx)).items);
|
|
879
975
|
// Not a steer; one message per session; do not spend a steer unit.
|
|
880
976
|
if (message)
|
|
881
977
|
pi.sendMessage({ customType: PREFS_TYPE, content: message, display: opening.steerVisible });
|
|
@@ -884,6 +980,8 @@ export default function wardenExtension(pi) {
|
|
|
884
980
|
console.warn("pi-warden: standing preferences scan failed:", error instanceof Error ? error.message : String(error));
|
|
885
981
|
}
|
|
886
982
|
}
|
|
983
|
+
if (opening.enabled && _event.reason === "resume")
|
|
984
|
+
await announceLoops(ctx, opening, openingBranch);
|
|
887
985
|
});
|
|
888
986
|
// A new user prompt starts a new attempt history, a new steer budget, and a new restatement window; answering the
|
|
889
987
|
// user is never a restatement.
|
|
@@ -897,6 +995,7 @@ export default function wardenExtension(pi) {
|
|
|
897
995
|
doneNudged = false;
|
|
898
996
|
wardenContinuation = false;
|
|
899
997
|
steersThisRun = 0;
|
|
998
|
+
lessonSignalTurn = isCorrection(event.prompt ?? "") ? assistantTurns : undefined;
|
|
900
999
|
finals.reset();
|
|
901
1000
|
runaway.reset();
|
|
902
1001
|
runawayStops = 0;
|
|
@@ -1197,6 +1296,7 @@ export default function wardenExtension(pi) {
|
|
|
1197
1296
|
});
|
|
1198
1297
|
// Every turn that runs after a compression is a turn that did not carry the removed text.
|
|
1199
1298
|
pi.on("turn_end", async (_event, ctx) => {
|
|
1299
|
+
assistantTurns++;
|
|
1200
1300
|
ledger.turnEnd();
|
|
1201
1301
|
if (savingEntry)
|
|
1202
1302
|
trace.amend(savingEntry, `at turn end: ${formatLedger(ledger.snapshot())}`);
|
|
@@ -1445,8 +1545,12 @@ export default function wardenExtension(pi) {
|
|
|
1445
1545
|
// The warn notice below names the mismatch to the user; the agent gets the steer with the other notes.
|
|
1446
1546
|
if (verdict.intentMismatch) {
|
|
1447
1547
|
stats.offPlan++;
|
|
1448
|
-
|
|
1449
|
-
|
|
1548
|
+
if (verdict.intentTraceOnly)
|
|
1549
|
+
stats.offPlanTraceOnly++;
|
|
1550
|
+
else {
|
|
1551
|
+
noteGuards.add("action");
|
|
1552
|
+
notes.push(intentSteer(verdict));
|
|
1553
|
+
}
|
|
1450
1554
|
}
|
|
1451
1555
|
if (verdict.offTaskSteer) {
|
|
1452
1556
|
stats.offTask++;
|
|
@@ -1857,6 +1961,13 @@ export default function wardenExtension(pi) {
|
|
|
1857
1961
|
if (!compressed && !ctx.signal?.aborted && output.confidence !== undefined)
|
|
1858
1962
|
recordKeptWhole(config, event.toolName, output);
|
|
1859
1963
|
}
|
|
1964
|
+
// A recall brings the stored text back on purpose, and a duplicate note has nothing left to cut.
|
|
1965
|
+
if (config.context.enabled && config.context.dedupeRuns && !earlier && !recallRead && !ctx.signal?.aborted) {
|
|
1966
|
+
syncSeen(ctx);
|
|
1967
|
+
const deduped = await dedupeContent(ctx, config, content, event.toolName);
|
|
1968
|
+
if (deduped)
|
|
1969
|
+
content = deduped;
|
|
1970
|
+
}
|
|
1860
1971
|
if (key && !earlier)
|
|
1861
1972
|
ledger.remember(key, event.toolName, storedPath);
|
|
1862
1973
|
// The banner in the result already tells the agent, so the notice rides the result content alone and is not also
|
|
@@ -1960,6 +2071,23 @@ export default function wardenExtension(pi) {
|
|
|
1960
2071
|
steer(config, "stuck", nudge);
|
|
1961
2072
|
return patch;
|
|
1962
2073
|
});
|
|
2074
|
+
// Opt-in: a relayed report that pastes earlier turns again keeps only its new part. Pi persists the replaced message.
|
|
2075
|
+
pi.on("message_end", async (event, ctx) => {
|
|
2076
|
+
const config = configFor(ctx);
|
|
2077
|
+
const message = event.message;
|
|
2078
|
+
if (!config.enabled || !config.context.enabled || !config.context.dedupeRuns || !config.context.dedupeMessages)
|
|
2079
|
+
return;
|
|
2080
|
+
if (message.role !== "user" && message.role !== "custom")
|
|
2081
|
+
return;
|
|
2082
|
+
syncSeen(ctx);
|
|
2083
|
+
const source = message.role === "custom" ? `${message.customType} message` : "user message";
|
|
2084
|
+
if (typeof message.content === "string") {
|
|
2085
|
+
const text = await dedupeText(ctx, config, message.content, source);
|
|
2086
|
+
return text === undefined ? undefined : { message: { ...message, content: text } };
|
|
2087
|
+
}
|
|
2088
|
+
const content = await dedupeContent(ctx, config, message.content, source);
|
|
2089
|
+
return content ? { message: { ...message, content } } : undefined;
|
|
2090
|
+
});
|
|
1963
2091
|
// The agent has caught up and Pi will not continue on its own: the one moment a wake costs the user nothing.
|
|
1964
2092
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
1965
2093
|
await checkSubagentReports(ctx, configFor(ctx));
|
|
@@ -1973,32 +2101,29 @@ export default function wardenExtension(pi) {
|
|
|
1973
2101
|
// Compaction evidence appendix: after compaction succeeds, send the evidence warden holds as one
|
|
1974
2102
|
// custom message so the agent can prefer saved paths over re-running commands. This is not a steer
|
|
1975
2103
|
// and does not spend a steer unit; it is one message per compaction, the same path steers use.
|
|
2104
|
+
/** The session's evidence as the compaction appendix and `warden_recall` both read it. */
|
|
2105
|
+
const evidenceSnapshot = (ctx, openLoopsText) => buildCompactSnapshot({
|
|
2106
|
+
savedOutputs: ledger.storedOutputs(),
|
|
2107
|
+
checks: evidence.checks.map((check, index) => ({ command: check.call, passed: check.passed, runIndex: 1, indexInRun: index })),
|
|
2108
|
+
holds: holds.records().filter(r => r.held).map(r => ({ tool: r.tool, preview: r.callExcerpt ?? "", outcome: r.outcome })),
|
|
2109
|
+
attempts: attempts.attempts,
|
|
2110
|
+
evidence,
|
|
2111
|
+
activeTask: latestUserPrompt(ctx),
|
|
2112
|
+
runs: 1,
|
|
2113
|
+
...(openLoopsText ? { openLoops: openLoopsText } : {}),
|
|
2114
|
+
});
|
|
1976
2115
|
pi.on("session_compact", async (_event, ctx) => {
|
|
1977
2116
|
const config = configFor(ctx);
|
|
1978
2117
|
if (!config.enabled || !config.context.compactAppendix)
|
|
1979
2118
|
return;
|
|
1980
2119
|
try {
|
|
1981
|
-
const
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
})
|
|
1987
|
-
const
|
|
1988
|
-
tool: r.tool,
|
|
1989
|
-
preview: r.callExcerpt ?? "",
|
|
1990
|
-
outcome: r.outcome,
|
|
1991
|
-
}));
|
|
1992
|
-
const task = latestUserPrompt(ctx);
|
|
1993
|
-
const snapshot = buildCompactSnapshot({
|
|
1994
|
-
savedOutputs: ledger.storedOutputs(),
|
|
1995
|
-
checks: checkRecords,
|
|
1996
|
-
holds: holdRecords,
|
|
1997
|
-
attempts: attempts.attempts,
|
|
1998
|
-
evidence,
|
|
1999
|
-
activeTask: task,
|
|
2000
|
-
runs: 1,
|
|
2001
|
-
});
|
|
2120
|
+
const file = loopsFile(ctx);
|
|
2121
|
+
// An unreadable loops file costs the appendix its loops section, not the evidence.
|
|
2122
|
+
const loopsText = file ? await readLoops(file).then(store => formatOpenLoops(openLoops(store)), (error) => {
|
|
2123
|
+
record(ctx, config, "action", "loops read error", [`reading the loops file failed: ${error instanceof Error ? error.message : String(error)}`]);
|
|
2124
|
+
return undefined;
|
|
2125
|
+
}) : undefined;
|
|
2126
|
+
const snapshot = evidenceSnapshot(ctx, loopsText);
|
|
2002
2127
|
const appendix = compactAppendix(snapshot);
|
|
2003
2128
|
if (!appendix)
|
|
2004
2129
|
return;
|
|
@@ -2027,6 +2152,27 @@ export default function wardenExtension(pi) {
|
|
|
2027
2152
|
// Operator input invalidates in-flight conscience assessments
|
|
2028
2153
|
conscienceGeneration++;
|
|
2029
2154
|
});
|
|
2155
|
+
/**
|
|
2156
|
+
* One short reminder of the open loops when a run ends, for the next turn: it starts no turn of its own, counts against
|
|
2157
|
+
* the steer budget as `loops`, and names the same unchanged list only once.
|
|
2158
|
+
*/
|
|
2159
|
+
const remindLoops = async (ctx, config) => {
|
|
2160
|
+
const file = loopsFile(ctx);
|
|
2161
|
+
if (!file)
|
|
2162
|
+
return;
|
|
2163
|
+
try {
|
|
2164
|
+
const open = openLoops(await readLoops(file));
|
|
2165
|
+
const fingerprint = loopsFingerprint(open);
|
|
2166
|
+
if (!open.length || fingerprint === loopsReminded)
|
|
2167
|
+
return;
|
|
2168
|
+
const notice = `pi-warden: ${open.length} open loop${open.length === 1 ? "" : "s"} you promised in this session:\n${formatOpenLoops(open)}\nClose each with warden_loops done or drop once it is finished or no longer needed.`;
|
|
2169
|
+
if (steer(config, "loops", notice, { deliverAs: "nextTurn" }))
|
|
2170
|
+
loopsReminded = fingerprint;
|
|
2171
|
+
}
|
|
2172
|
+
catch (error) {
|
|
2173
|
+
record(ctx, config, "action", "loops reminder error", [`reading the loops file failed: ${error instanceof Error ? error.message : String(error)}`]);
|
|
2174
|
+
}
|
|
2175
|
+
};
|
|
2030
2176
|
pi.on("agent_end", async (event, ctx) => {
|
|
2031
2177
|
const config = configFor(ctx);
|
|
2032
2178
|
if (!config.enabled)
|
|
@@ -2046,6 +2192,7 @@ export default function wardenExtension(pi) {
|
|
|
2046
2192
|
steer(config, "runaway", nudge, { triggerTurn: false, display: true });
|
|
2047
2193
|
return;
|
|
2048
2194
|
}
|
|
2195
|
+
await remindLoops(ctx, config);
|
|
2049
2196
|
const finalMessage = finalAssistantText(event.messages);
|
|
2050
2197
|
// Restatement is measured in code, before any judging: the run that collects five accounting replies restating the
|
|
2051
2198
|
// same completion status is the noise the user sees, and it is invisible to every per-reply and per-call guard.
|
|
@@ -2169,9 +2316,70 @@ export default function wardenExtension(pi) {
|
|
|
2169
2316
|
togglePanel(ctx.ui, configFor(ctx)); },
|
|
2170
2317
|
});
|
|
2171
2318
|
}
|
|
2172
|
-
|
|
2319
|
+
// A plain JSON Schema: Pi compiles tool schemas with TypeBox, which accepts one, and pi-warden keeps no TypeBox dependency.
|
|
2320
|
+
const toolParameters = (properties, required) => ({ type: "object", properties, required, additionalProperties: false });
|
|
2321
|
+
const toolReply = (text) => ({ content: [{ type: "text", text }], details: undefined });
|
|
2322
|
+
const rememberParameters = toolParameters({ lesson: { type: "string", maxLength: LESSON_CHARS, description: `One standing instruction for this project, at most ${LESSON_CHARS} characters, e.g. "Never X" or "Always Y".` } }, ["lesson"]);
|
|
2323
|
+
pi.registerTool({
|
|
2324
|
+
name: "warden_remember",
|
|
2325
|
+
label: "warden remember",
|
|
2326
|
+
description: `Record a standing lesson for this project, so later sessions keep it. Use it only right after the user corrected you, or after a mistake you had to undo; never for task notes, plans, or progress. The lesson is one instruction in a standing form (don't, never, always, stop, from now on, next time), names no ticket, branch, PR, or hash, and never skips a test, check, review, or confirmation. It reaches later sessions only once it is confirmed.`,
|
|
2327
|
+
parameters: rememberParameters,
|
|
2328
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2329
|
+
const config = configFor(ctx);
|
|
2330
|
+
if (!config.enabled || !config.prefs.enabled)
|
|
2331
|
+
return toolReply(`not recorded: standing preferences are off (${config.enabled ? "prefs.enabled" : "enabled"} is false)`);
|
|
2332
|
+
const signal = lessonSignalTurn !== undefined && assistantTurns - lessonSignalTurn <= LESSON_TURNS;
|
|
2333
|
+
const lesson = typeof params.lesson === "string" ? params.lesson : "";
|
|
2334
|
+
if (!signal)
|
|
2335
|
+
return toolReply(NO_LESSON_SIGNAL);
|
|
2336
|
+
const manager = ctx.sessionManager;
|
|
2337
|
+
const session = typeof manager.getSessionId === "function" ? manager.getSessionId() : "unknown";
|
|
2338
|
+
const scan = await standingPrefs(ctx);
|
|
2339
|
+
const path = prefsStorePath(scan.project);
|
|
2340
|
+
const result = recordLesson({ lesson, session, now: Date.now(), signal, scan, store: await readPrefsStore(path) });
|
|
2341
|
+
if (result.store)
|
|
2342
|
+
await writePrefsStore(path, result.store);
|
|
2343
|
+
return toolReply(result.reply);
|
|
2344
|
+
},
|
|
2345
|
+
});
|
|
2346
|
+
pi.registerTool({
|
|
2347
|
+
name: "warden_loops",
|
|
2348
|
+
label: "warden loops",
|
|
2349
|
+
description: `Keep open loops for this session, so a long session never loses a promise. Whenever you promise to do something later ("after CI passes I will bump the version", "I will come back to the flaky test"), add a loop; mark it done when you did it, or drop it with a reason when it is no longer needed. Actions: add (text, at most ${LOOP_CHARS} characters, and an optional when condition such as "after CI passes"), done (id), drop (id and reason), list. Open loops are shown again after compaction, at the end of a run, and on resume. They belong to this session only.`,
|
|
2350
|
+
parameters: toolParameters({
|
|
2351
|
+
action: { type: "string", enum: [...LOOP_ACTIONS], description: "add, done, drop, or list." },
|
|
2352
|
+
text: { type: "string", maxLength: LOOP_CHARS, description: "For add: what you promised to do." },
|
|
2353
|
+
when: { type: "string", maxLength: LOOP_CHARS, description: "For add, optional: the condition that makes it due." },
|
|
2354
|
+
id: { type: "integer", minimum: 1, description: "For done and drop: the loop number." },
|
|
2355
|
+
reason: { type: "string", maxLength: LOOP_CHARS, description: "For drop: why it is no longer needed." },
|
|
2356
|
+
}, ["action"]),
|
|
2357
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2358
|
+
if (!configFor(ctx).enabled)
|
|
2359
|
+
return toolReply("not available: pi-warden is off (enabled is false)");
|
|
2360
|
+
const file = loopsFile(ctx);
|
|
2361
|
+
if (!file)
|
|
2362
|
+
return toolReply("not available: this session has no id to keep loops under");
|
|
2363
|
+
const result = applyLoopAction(await readLoops(file), params, Date.now());
|
|
2364
|
+
if (result.store)
|
|
2365
|
+
await writeLoops(file, result.store);
|
|
2366
|
+
return toolReply(result.reply);
|
|
2367
|
+
},
|
|
2368
|
+
});
|
|
2369
|
+
pi.registerTool({
|
|
2370
|
+
name: "warden_recall",
|
|
2371
|
+
label: "warden recall",
|
|
2372
|
+
description: "Recall what was already tried in this session before you try it again: the failed attempts with their error lines, the last passing check and whether the code changed since, and the paths of saved full outputs (read those instead of re-running the command). Read-only; takes no arguments.",
|
|
2373
|
+
parameters: toolParameters({}, []),
|
|
2374
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
2375
|
+
if (!configFor(ctx).enabled)
|
|
2376
|
+
return toolReply("not available: pi-warden is off (enabled is false)");
|
|
2377
|
+
return toolReply(recallText(evidenceSnapshot(ctx)));
|
|
2378
|
+
},
|
|
2379
|
+
});
|
|
2380
|
+
const actions = ["status", "enable", "disable", "mode", "config", "test", "trace", "init", "audit", "index", "prefs", "loops", "recommend"];
|
|
2173
2381
|
pi.registerCommand("warden", {
|
|
2174
|
-
description: "pi-warden status, config (set/get/editor), TypeSafe consent, mode, trace panel, recommend, standing preferences, and a synthetic guard test",
|
|
2382
|
+
description: "pi-warden status, config (set/get/editor), TypeSafe consent, mode, trace panel, recommend, standing preferences, open loops, and a synthetic guard test",
|
|
2175
2383
|
getArgumentCompletions(prefix) {
|
|
2176
2384
|
const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
|
|
2177
2385
|
return matches.length ? matches : null;
|
|
@@ -2202,9 +2410,9 @@ export default function wardenExtension(pi) {
|
|
|
2202
2410
|
: `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).`;
|
|
2203
2411
|
report([
|
|
2204
2412
|
`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}`,
|
|
2205
|
-
`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`}.`,
|
|
2413
|
+
`Session: ${stats.inspected} inspected, ${stats.judged} judged, ${stats.warned} warned, ${stats.held} held, ${stats.approved} approved on retry, ${stats.offPlan} off plan (${stats.offPlanTraceOnly} trace-only), ${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`}.`,
|
|
2206
2414
|
formatSteers(stats),
|
|
2207
|
-
`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}.`,
|
|
2415
|
+
`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, trace-only: ${config.action.intentTraceOnly}); 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}.`,
|
|
2208
2416
|
formatLedger(ledger.snapshot()),
|
|
2209
2417
|
...(config.learning.patternAnalysis ? [`Learning: ${(await generateRecommendations(ctx.cwd)).length} recommendations, steer effectiveness ${Math.round((await analyzeSteerEffectivenessReport(ctx.cwd)).overall * 100)}% (use /warden recommend for details)`] : []),
|
|
2210
2418
|
`${formatHolds(holds.snapshot(), config.action.feedbackLog ? holdLog?.path : undefined)}${holdLog?.lastFailure ? ` Log write failed: ${holdLog.lastFailure}.` : ""}`,
|
|
@@ -2233,12 +2441,29 @@ export default function wardenExtension(pi) {
|
|
|
2233
2441
|
void opened?.closed.then(fallback, fallback);
|
|
2234
2442
|
return;
|
|
2235
2443
|
}
|
|
2444
|
+
if (action === "loops") {
|
|
2445
|
+
const file = loopsFile(ctx);
|
|
2446
|
+
report(file ? formatLoopsForUser(await readLoops(file)) : "No loops: this session has no id to keep them under.");
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
2236
2449
|
if (action === "prefs") {
|
|
2237
2450
|
if (!config.enabled || !config.prefs.enabled) {
|
|
2238
2451
|
report(`Standing preferences are off (${config.enabled ? "prefs.enabled" : "enabled"} is false); no session files were read.`);
|
|
2239
2452
|
return;
|
|
2240
2453
|
}
|
|
2241
|
-
|
|
2454
|
+
const { scan, path, items } = await evaluatedPrefs(ctx);
|
|
2455
|
+
if (argument === "forget") {
|
|
2456
|
+
const index = Number(tokens[2]);
|
|
2457
|
+
const item = Number.isInteger(index) ? items[index - 1] : undefined;
|
|
2458
|
+
if (!item) {
|
|
2459
|
+
report(`Usage: /warden prefs forget <n>, where n is an item number from /warden prefs (1 to ${items.length}).`, "warning");
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
await writePrefsStore(path, forgetPref(await readPrefsStore(path), item));
|
|
2463
|
+
report(`Forgotten for this project: "${item.text}". It is not listed or injected again.`);
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
report(formatPrefs(scan, items));
|
|
2242
2467
|
return;
|
|
2243
2468
|
}
|
|
2244
2469
|
if (action === "recommend") {
|
package/dist/guard.d.ts
CHANGED
|
@@ -156,6 +156,10 @@ export interface Verdict {
|
|
|
156
156
|
plan?: string;
|
|
157
157
|
/** True when Jev finds the call at odds with the agent's stated plan and the call can change something; the agent is told. */
|
|
158
158
|
intentMismatch?: boolean;
|
|
159
|
+
/** The intent mismatch is recorded in the trace but the agent is not told (`action.intentTraceOnly`). */
|
|
160
|
+
intentTraceOnly?: boolean;
|
|
161
|
+
/** Index of the trace-only intent-mismatch reason; any prepend must adjust this index. */
|
|
162
|
+
intentTraceOnlyReasonIndex?: number;
|
|
159
163
|
/** True when Jev finds the call unrelated to the request on a call that can change something. Still steered in the reason log, but the steer message is suppressed until AUC improves above 0.51. */
|
|
160
164
|
offTaskSteer?: boolean;
|
|
161
165
|
/** True when should_proceed is below the hold threshold; the agent is told to pause and ask. */
|