pi-warden 0.34.1 → 0.35.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 CHANGED
@@ -6,7 +6,10 @@ How to keep this current: add the entry in the same pull request as the change,
6
6
 
7
7
  ## Unreleased
8
8
 
9
- <!-- Empty. Next release starts here. -->
9
+ ## 0.35.0
10
+
11
+ ### Added
12
+ - Stuck-loop diff: when the stuck detector marks a repeated failed attempt, the agent sees a short unified line diff against the previous output instead of the full repeated output again. Byte-identical outputs get a one-line note. The diff note never grows the result. Config keys `stuck.diffLimit` (3000) and `stuck.tailLimit` (1000) control the diff and tail caps.
10
13
 
11
14
  ## 0.34.1
12
15
 
package/dist/config.d.ts CHANGED
@@ -127,6 +127,10 @@ export interface StuckGuardConfig {
127
127
  churnThreshold: number;
128
128
  /** Also steer the agent with a short message, not only the user. */
129
129
  nudge: boolean;
130
+ /** Maximum characters of the unified line diff in a stuck-loop diff note. */
131
+ diffLimit: number;
132
+ /** Characters of the current output tail shown after the diff in a stuck-loop diff note. */
133
+ tailLimit: number;
130
134
  }
131
135
  export interface DoneGuardConfig {
132
136
  enabled: boolean;
package/dist/config.js CHANGED
@@ -39,7 +39,7 @@ export function defaultConfig() {
39
39
  escalationThreshold: 0.85,
40
40
  floor: "evidence",
41
41
  },
42
- stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true },
42
+ stuck: { enabled: true, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 0.7, churnThreshold: 5, nudge: true, diffLimit: 3000, tailLimit: 1000 },
43
43
  done: { enabled: true, claimsDone: 0.7, nudge: true },
44
44
  slop: { enabled: true, threshold: 0.7, prose: { enabled: true, audience: "technical", threshold: 0.7, trend: 2, minChars: 200 } },
45
45
  security: { enabled: true, threshold: 0.7 },
@@ -284,6 +284,8 @@ function applyStuck(base, raw) {
284
284
  sameStrategy: probability(raw.sameStrategy, base.sameStrategy),
285
285
  churnThreshold: Math.min(window, positiveInteger(raw.churnThreshold, base.churnThreshold)),
286
286
  nudge: boolean(raw.nudge, base.nudge),
287
+ diffLimit: positiveInteger(raw.diffLimit, base.diffLimit),
288
+ tailLimit: positiveInteger(raw.tailLimit, base.tailLimit),
287
289
  };
288
290
  }
289
291
  function applyRunaway(base, raw) {
package/dist/extension.js CHANGED
@@ -25,7 +25,7 @@ import { writeStarterRules, buildInitPrompt } from "./init.js";
25
25
  import { buildAuditPrompt, findProjects, snapshotReport, reportOutcome } from "./audit.js";
26
26
  import { detectNotifier, sendNotification } from "./notify.js";
27
27
  import { formatRunaway, RunawayMonitor, runawayNudge } from "./runaway.js";
28
- import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckNudge } from "./stuck.js";
28
+ import { AttemptWindow, evaluateStuck, formatStuck, makeAttempt, resultFailed, stuckDiff, stuckNudge } from "./stuck.js";
29
29
  import { openConfigPanel, openTracePanel } from "./panel.js";
30
30
  import { completeConfig, shapeWarning } from "./shape.js";
31
31
  import { ContextLedger, formatLedger } from "./saver.js";
@@ -250,6 +250,8 @@ export default function wardenExtension(pi) {
250
250
  // Allowed calls of the turn the user just replied to; the regret question about them rides the next action request.
251
251
  let regretCandidates = [];
252
252
  let attempts = new AttemptWindow(defaultConfig().stuck.window);
253
+ /** Full output text and saved path keyed by attempt call key, for stuck-loop diffs. */
254
+ let fullOutputs = new Map();
253
255
  let evidence = emptyEvidence();
254
256
  let doneNudged = false;
255
257
  let warnedFallback = false;
@@ -595,6 +597,7 @@ export default function wardenExtension(pi) {
595
597
  lastUi = ctx.ui;
596
598
  const config = configFor(ctx);
597
599
  attempts = new AttemptWindow(config.stuck.window);
600
+ fullOutputs = new Map();
598
601
  doneNudged = false;
599
602
  steersThisRun = 0;
600
603
  finals.reset();
@@ -1129,11 +1132,53 @@ export default function wardenExtension(pi) {
1129
1132
  `output sample: ${redact(text).slice(0, 300)}`, delivered ? `agent told: ${notice}` : `steer recorded, not delivered (a repeat or the per-run budget): ${notice}`,
1130
1133
  ]);
1131
1134
  }
1135
+ // Stuck-loop diff: when the verdict is stuck on a failed result and a previous failed output
1136
+ // for the same call is available, replace the tool result with a short diff note. Compute
1137
+ // bytesSaved against the current content (after duplicate detection and compression), so the
1138
+ // diff never grows the result.
1139
+ const verdict = await stuckCheck;
1140
+ if (verdict && verdict.stuck && failed && !verdict.successRepeat && !verdict.churn && text.length > 0) {
1141
+ const currentKey = attempts.attempts.at(-1)?.key;
1142
+ const prevEntry = currentKey ? fullOutputs.get(currentKey) : undefined;
1143
+ if (currentKey && prevEntry && prevEntry.text !== text) {
1144
+ // Differing outputs: save the current output and diff against the previous one.
1145
+ try {
1146
+ const saved = await saveOutput(text);
1147
+ const diffNote = stuckDiff(prevEntry.text, text, { diffLimit: config.stuck.diffLimit, tailLimit: config.stuck.tailLimit, fullPath: saved });
1148
+ const currentLen = Buffer.byteLength(content.map(part => part.type === "text" ? part.text : "").join("\n"));
1149
+ if (Buffer.byteLength(diffNote) < currentLen) {
1150
+ content = [{ type: "text", text: diffNote }, ...content.filter(part => part.type !== "text")];
1151
+ }
1152
+ fullOutputs.set(currentKey, { text, path: saved });
1153
+ }
1154
+ catch {
1155
+ noteError(ctx, "Could not store full output for stuck diff; keeping it unchanged.", undefined);
1156
+ fullOutputs.set(currentKey, { text });
1157
+ }
1158
+ }
1159
+ else if (currentKey && prevEntry && prevEntry.text === text && prevEntry.path) {
1160
+ // Byte-identical outputs: one-line note reusing the existing saved path.
1161
+ const diffNote = stuckDiff(text, text, { diffLimit: config.stuck.diffLimit, tailLimit: config.stuck.tailLimit, fullPath: prevEntry.path });
1162
+ const currentLen = Buffer.byteLength(content.map(part => part.type === "text" ? part.text : "").join("\n"));
1163
+ if (Buffer.byteLength(diffNote) < currentLen) {
1164
+ content = [{ type: "text", text: diffNote }, ...content.filter(part => part.type !== "text")];
1165
+ }
1166
+ }
1167
+ else if (currentKey) {
1168
+ // First failed output for this call, or no previous entry yet: store for future diffs.
1169
+ fullOutputs.set(currentKey, { text });
1170
+ }
1171
+ }
1172
+ else if (failed && text.length > 0) {
1173
+ // Not stuck (or no verdict yet): store the output so a future repeat can diff against it.
1174
+ const currentKey = attempts.attempts.at(-1)?.key;
1175
+ if (currentKey)
1176
+ fullOutputs.set(currentKey, { text });
1177
+ }
1132
1178
  const patch = content === event.content ? undefined : { content };
1133
1179
  // Checks use the original result, not the excerpts or security banner.
1134
1180
  if (config.done.enabled)
1135
1181
  recordDoneOutcome(evidence, classifyToolResult(event.toolName, event.input, failed, text), event.input, event.toolName);
1136
- const verdict = await stuckCheck;
1137
1182
  if (!verdict)
1138
1183
  return patch;
1139
1184
  if (verdict.error)
@@ -1146,7 +1191,7 @@ export default function wardenExtension(pi) {
1146
1191
  return patch;
1147
1192
  stats.stuck++;
1148
1193
  if (ctx.hasUI && config.notices)
1149
- ctx.ui.notify(`warden · stuck: ${verdict.reasons.join("; ")}${nudge ? " (agent nudged)" : ""}`, "warning");
1194
+ ctx.ui.notify(`warden \u00b7 stuck: ${verdict.reasons.join("; ")}${nudge ? " (agent nudged)" : ""}`, "warning");
1150
1195
  if (nudge)
1151
1196
  steer(config, "stuck", nudge);
1152
1197
  return patch;
package/dist/shape.js CHANGED
@@ -31,7 +31,7 @@ export function completeConfig(loaded) {
31
31
  notices: source.notices ?? false,
32
32
  steerBudget: typeof source.steerBudget === "number" && source.steerBudget >= 0 ? source.steerBudget : 3,
33
33
  action: section("action", { ...off, tools: [], failOpen: true, timeoutMs: 5000, irreversible: { warn: 1, confirm: 1 }, offTask: { warn: 1, steer: 1 }, intentMismatch: 1, visibleMismatch: 1, shouldProceed: { hold: 0.6, steer: false }, feedbackLog: false, commandRules: [], commandDenyRules: [], exemptRules: [], pathRules: [], armingRules: [], escalationThreshold: 0.85, floor: "evidence" }),
34
- stuck: section("stuck", { ...off, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 1, churnThreshold: 5, nudge: false }),
34
+ stuck: section("stuck", { ...off, window: 12, minFailures: 3, cooldown: 3, sameStrategy: 1, churnThreshold: 5, nudge: false, diffLimit: 3000, tailLimit: 1000 }),
35
35
  done: section("done", { ...off, claimsDone: 1, nudge: false }),
36
36
  slop: section("slop", { ...off, threshold: 1, prose: proseOff() }),
37
37
  security: section("security", { ...off, threshold: 1 }),
package/dist/stuck.d.ts CHANGED
@@ -102,4 +102,13 @@ export declare function evaluateStuck(window: AttemptWindow, task: string | unde
102
102
  /** Steering text for the agent. Names the pattern and asks for a change of method, not another retry. A successful
103
103
  * repeat is a different disease than a failure loop: the model already has the answer, so it should use it. */
104
104
  export declare function stuckNudge(verdict: StuckVerdict): string;
105
+ /**
106
+ * Short note for a stuck-loop repeated failure: header, capped unified diff against the previous output,
107
+ * tail of the current output, and the path of the full copy. All inputs must be pre-redacted.
108
+ */
109
+ export declare function stuckDiff(previous: string, current: string, options: {
110
+ diffLimit: number;
111
+ tailLimit: number;
112
+ fullPath: string;
113
+ }): string;
105
114
  export declare function formatStuck(verdict: StuckVerdict, template?: string): string;
package/dist/stuck.js CHANGED
@@ -177,6 +177,107 @@ export function stuckNudge(verdict) {
177
177
  }
178
178
  return `pi-warden: ${verdict.reasons.join("; ")}. Stop retrying. Re-read the last error output carefully, state a new hypothesis about the cause, and either gather the missing information (read the relevant file, check versions or paths) or try a different method. If two different methods have failed, report the blocker to the user with the exact error instead of trying again.`;
179
179
  }
180
+ /** LCS-based line diff between two outputs, capped at diffLimit characters. Both inputs must be pre-redacted. */
181
+ function lineDiff(previous, current, diffLimit) {
182
+ const prevLines = previous.split("\n");
183
+ const currLines = current.split("\n");
184
+ const m = prevLines.length;
185
+ const n = currLines.length;
186
+ // Longest common subsequence.
187
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
188
+ for (let i = 1; i <= m; i++) {
189
+ for (let j = 1; j <= n; j++) {
190
+ dp[i][j] = prevLines[i - 1] === currLines[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
191
+ }
192
+ }
193
+ // Backtrack to build unified diff.
194
+ let i = m;
195
+ let j = n;
196
+ const ops = [];
197
+ while (i > 0 || j > 0) {
198
+ if (i > 0 && j > 0 && prevLines[i - 1] === currLines[j - 1]) {
199
+ ops.push([" ", prevLines[i - 1]]);
200
+ i--;
201
+ j--;
202
+ }
203
+ else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
204
+ ops.push(["+", currLines[j - 1]]);
205
+ j--;
206
+ }
207
+ else {
208
+ ops.push(["-", prevLines[i - 1]]);
209
+ i--;
210
+ }
211
+ }
212
+ ops.reverse();
213
+ // Identify indices of changed ops (+ or -).
214
+ const changedIndices = [];
215
+ for (let k = 0; k < ops.length; k++) {
216
+ if (ops[k][0] !== " ")
217
+ changedIndices.push(k);
218
+ }
219
+ if (changedIndices.length === 0)
220
+ return "";
221
+ // Merge nearby changes: expand each changed index by 3 lines of context on each side,
222
+ // then merge overlapping ranges.
223
+ const CONTEXT = 3;
224
+ const ranges = [];
225
+ for (const idx of changedIndices) {
226
+ const start = Math.max(0, idx - CONTEXT);
227
+ const end = Math.min(ops.length - 1, idx + CONTEXT);
228
+ if (ranges.length > 0 && start <= ranges[ranges.length - 1][1] + 1) {
229
+ ranges[ranges.length - 1][1] = end;
230
+ }
231
+ else {
232
+ ranges.push([start, end]);
233
+ }
234
+ }
235
+ // Emit hunks.
236
+ const hunks = [];
237
+ for (const [rangeStart, rangeEnd] of ranges) {
238
+ let prevPos = 0;
239
+ for (let k = 0; k < rangeStart; k++) {
240
+ const op = ops[k][0];
241
+ if (op === " " || op === "-")
242
+ prevPos++;
243
+ }
244
+ // Count lines in the range for each file.
245
+ let prevCount = 0;
246
+ let currCount = 0;
247
+ const lines = [];
248
+ for (let k = rangeStart; k <= rangeEnd; k++) {
249
+ const [op, line] = ops[k];
250
+ lines.push(`${op} ${line}`);
251
+ if (op === " ") {
252
+ prevCount++;
253
+ currCount++;
254
+ }
255
+ else if (op === "-") {
256
+ prevCount++;
257
+ }
258
+ else {
259
+ currCount++;
260
+ }
261
+ }
262
+ hunks.push(`@@ -${prevPos + 1},${prevCount} +${prevPos + 1},${currCount} @@`);
263
+ hunks.push(...lines);
264
+ }
265
+ const text = hunks.join("\n");
266
+ return text.length <= diffLimit ? text : `${text.slice(0, diffLimit)}\n… [diff truncated]`;
267
+ }
268
+ /**
269
+ * Short note for a stuck-loop repeated failure: header, capped unified diff against the previous output,
270
+ * tail of the current output, and the path of the full copy. All inputs must be pre-redacted.
271
+ */
272
+ export function stuckDiff(previous, current, options) {
273
+ const header = `pi-warden: stuck-loop diff; this output repeated a failure (previous: ${previous.length} chars, current: ${current.length} chars).`;
274
+ if (previous === current) {
275
+ return `${header}\nOutputs are byte-identical; see the full output at ${options.fullPath}.`;
276
+ }
277
+ const diff = lineDiff(previous, current, options.diffLimit);
278
+ const tail = current.length <= options.tailLimit ? current : `… [${current.length - options.tailLimit} earlier chars omitted]\n${current.slice(-options.tailLimit)}`;
279
+ return `${header}\n${diff}\n${tail}\n\nFull output: ${options.fullPath}`;
280
+ }
180
281
  export function formatStuck(verdict, template = DEFAULT_TEMPLATES.stuck) {
181
282
  return renderTemplate(template, stuckTokens(verdict));
182
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-warden",
3
- "version": "0.34.1",
3
+ "version": "0.35.0",
4
4
  "description": "Makes the Pi agent follow your project's rules. Jev judges every write against your pi-warden.md and quotes the broken rule back to the agent, names slop, breaks stuck loops, calls out unverified done claims, compresses large tool output, and holds the rare destructive command. Built on pi-typesafe.",
5
5
  "type": "module",
6
6
  "license": "MIT",