pi-goal-list-loop-audit 0.27.0 → 0.27.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.
@@ -37,6 +37,36 @@ export function truncate(s: string, max: number): string {
37
37
  return s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…";
38
38
  }
39
39
 
40
+ /**
41
+ * Word-wrap to `width`, capped at `maxLines` (v0.27.1). A pause is the one
42
+ * state where the FULL text matters — the reason often carries a decision
43
+ * the user must make (dedup choices, impossible-verdict narrowing), and a
44
+ * 60-char truncate hid it. Over-long words are hard-split; when the cap
45
+ * cuts content the last line ends with "…" (the pause-time notification
46
+ * and /goal status always carry the full text).
47
+ */
48
+ export function wrap(s: string, width: number, maxLines: number): string[] {
49
+ const norm = s.replace(/\s+/g, " ").trim();
50
+ const words = norm.split(" ").filter(Boolean);
51
+ const all: string[] = [];
52
+ let cur = "";
53
+ for (let w of words) {
54
+ const next = cur ? `${cur} ${w}` : w;
55
+ if (next.length <= width) { cur = next; continue; }
56
+ if (cur) all.push(cur);
57
+ while (w.length > width) { all.push(w.slice(0, width)); w = w.slice(width); }
58
+ cur = w;
59
+ }
60
+ if (cur) all.push(cur);
61
+ if (all.length === 0) all.push("");
62
+ if (all.length <= maxLines) return all;
63
+ const out = all.slice(0, maxLines);
64
+ // The last kept line already fits within width — truncate() would leave it
65
+ // unmarked, so force the ellipsis to signal "more in /goal status".
66
+ out[maxLines - 1] = out[maxLines - 1]!.slice(0, Math.max(0, width - 1)) + "…";
67
+ return out;
68
+ }
69
+
40
70
  /**
41
71
  * Width-aware truncation budget (v0.22.2). The hardcoded caps are FLOORS for
42
72
  * narrow terminals; when the terminal is wider, lines may use the available
@@ -191,8 +221,29 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
191
221
  return lines;
192
222
  }
193
223
  if (g.status === "paused" && g.pauseReason) {
194
- lines.push(`├─ ${paint(theme, pauseIsError(g) ? "error" : "warning", truncate(g.pauseReason, budgetFor(width, 3, 60)))}`);
195
- if (g.pauseSuggestedAction) lines.push(`└─ ${paint(theme, "dim", truncate(g.pauseSuggestedAction, budgetFor(width, 3, 60)))}`);
224
+ const isErr = pauseIsError(g);
225
+ const budget = budgetFor(width, 3, 60);
226
+ // v0.27.1: wrap reason + suggested action over up to 3 lines each
227
+ // (see wrap()); before, both were truncated at ~60 chars and the actual
228
+ // question in a decision-pause never reached the user.
229
+ wrap(g.pauseReason, budget, 3).forEach((w, i) => {
230
+ lines.push(`${i === 0 ? "├─" : "│ "} ${paint(theme, isErr ? "error" : "warning", w)}`);
231
+ });
232
+ // v0.27.1: what survives the pause — the first question at a pause is
233
+ // "did I lose the work?". Answer it on the card.
234
+ const spent: string[] = [];
235
+ const tokUsed = g.usage?.tokensUsed ?? 0;
236
+ if (tokUsed > 0) spent.push(`${fmtTokens(tokUsed)} tok spent`);
237
+ const audits = g.auditHistory?.length ?? 0;
238
+ if (audits > 0) spent.push(`${audits} audit${audits === 1 ? "" : "s"}`);
239
+ const savedLine = `saved${spent.length > 0 ? ` — ${spent.join(" · ")}` : ""} · resumes exactly here`;
240
+ if (g.pauseSuggestedAction) {
241
+ lines.push(`├─ ${paint(theme, "dim", truncate(savedLine, budget))}`);
242
+ const wrapped = wrap(g.pauseSuggestedAction, budget, 3);
243
+ wrapped.forEach((w, i) => lines.push(`${i === wrapped.length - 1 ? "└─" : "│ "} ${paint(theme, "dim", w)}`));
244
+ } else {
245
+ lines.push(`└─ ${paint(theme, "dim", truncate(savedLine, budget))}`);
246
+ }
196
247
  return lines;
197
248
  }
198
249
  const next = nextPending(g);
@@ -0,0 +1,72 @@
1
+ // pi-goal-list-loop-audit — v0.27.2
2
+ // extensions/length-continue.ts
3
+ //
4
+ // Folded-in auto-continue for output-token truncation (was the standalone
5
+ // pi-length-continue 0.1.0, now deprecated). When ONE assistant response
6
+ // exceeds the model's provider-side per-response output cap, pi ends the
7
+ // turn with stopReason "length" and idles — a dead stop on unattended rigs.
8
+ // The tracker decides when to re-trigger; goal.ts's agent_end handler wires
9
+ // it BEFORE all turn bookkeeping: a truncated turn is not a completed turn
10
+ // (no telemetry), not a stall (no no-tool nudge), and must not run the
11
+ // loop measure or the normal goal continuation on half a response.
12
+ //
13
+ // Guards (same as the standalone):
14
+ // - consecutive cap: after MAX back-to-back truncations, give up (once)
15
+ // instead of burning quota in a truncation ping-pong. Any normally
16
+ // finished turn resets the counter.
17
+ // - the caller skips when messages are already pending (a queued message
18
+ // triggers a turn anyway) and routes stale-handle errors to
19
+ // goStaleTerminal (pi#7154).
20
+
21
+ export const LENGTH_CONTINUE_MAX = 3;
22
+
23
+ export const LENGTH_CONTINUE_TEXT = [
24
+ "Your previous response was cut off at the model's per-response output token limit.",
25
+ "Continue EXACTLY where you stopped — finish the current artifact, then keep going.",
26
+ "Keep each individual response shorter from here: split large file writes into multiple smaller write/edit calls across turns instead of one giant response.",
27
+ ].join(" ");
28
+
29
+ export interface LengthContinueTick {
30
+ /** Send the continue message this round. */
31
+ fire: boolean;
32
+ /** The cap was just exceeded — notify the give-up exactly once. */
33
+ giveUpNow: boolean;
34
+ /** Current consecutive truncation streak (after this tick). */
35
+ consecutive: number;
36
+ }
37
+
38
+ export function makeLengthContinueTracker(max: number = LENGTH_CONTINUE_MAX) {
39
+ let consecutive = 0;
40
+ let gaveUp = false;
41
+ return {
42
+ tick(stopped: boolean): LengthContinueTick {
43
+ if (!stopped) {
44
+ consecutive = 0;
45
+ gaveUp = false;
46
+ return { fire: false, giveUpNow: false, consecutive: 0 };
47
+ }
48
+ consecutive++;
49
+ if (consecutive > max) {
50
+ const giveUpNow = !gaveUp;
51
+ gaveUp = true;
52
+ return { fire: false, giveUpNow, consecutive };
53
+ }
54
+ return { fire: true, giveUpNow: false, consecutive };
55
+ },
56
+ get consecutive(): number {
57
+ return consecutive;
58
+ },
59
+ };
60
+ }
61
+
62
+ // Session-level singleton — one tracker per extension runtime. The factory
63
+ // calls resetLengthContinue() so an extension reload starts clean.
64
+ let tracker = makeLengthContinueTracker();
65
+
66
+ export function tickLengthContinue(stopped: boolean): LengthContinueTick {
67
+ return tracker.tick(stopped);
68
+ }
69
+
70
+ export function resetLengthContinue(): void {
71
+ tracker = makeLengthContinueTracker();
72
+ }
@@ -86,6 +86,12 @@ import {
86
86
  writeGoalMd,
87
87
  missingGllaTools,
88
88
  } from "../goal-loop-core.js";
89
+ import {
90
+ LENGTH_CONTINUE_MAX,
91
+ LENGTH_CONTINUE_TEXT,
92
+ resetLengthContinue,
93
+ tickLengthContinue,
94
+ } from "../length-continue.js";
89
95
  import {
90
96
  isQuotaError,
91
97
  isSubagentQuotaResult,
@@ -527,6 +533,25 @@ function sendContinuation(goalId: string): void {
527
533
  }
528
534
  }
529
535
 
536
+ // v0.27.2: send the truncation-continue nudge. Same guards as
537
+ // sendContinuation (stale api = terminal), independent of goal state —
538
+ // plain sessions truncate too.
539
+ function sendLengthContinue(ctx: ExtensionContext, consecutive: number): void {
540
+ if (!extensionApi || extensionApiStale) return;
541
+ try {
542
+ extensionApi.sendMessage({
543
+ customType: GOAL_EVENT_ENTRY,
544
+ content: LENGTH_CONTINUE_TEXT,
545
+ display: true,
546
+ }, { triggerTurn: true, deliverAs: "followUp" });
547
+ appendLedger(ctx.cwd, "length_continue_sent", { consecutive });
548
+ ctx.ui.notify(`Response hit the output-token cap — auto-continuing (${consecutive}/${LENGTH_CONTINUE_MAX})`, "warning");
549
+ } catch (err) {
550
+ appendLedger(ctx.cwd, "length_continue_send_failed", { consecutive, error: err instanceof Error ? err.message : String(err) });
551
+ if (isStaleApiError(err)) goStaleTerminal(ctx, "sendLengthContinue");
552
+ }
553
+ }
554
+
530
555
  function continuationPrompt(goal: Goal): string {
531
556
  // Read the .md file as the template, then substitute {{tokens}}.
532
557
  // For v0.1.0 we inline-substitute so we don't need fs at runtime.
@@ -2254,8 +2279,12 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
2254
2279
  pauseReason: p.reason,
2255
2280
  pauseSuggestedAction: p.suggestedAction,
2256
2281
  }, ctx);
2257
- ctx.ui.notify(`Goal paused: ${p.reason}`, "info");
2258
- notifyExternal(ctx, `Goal paused: ${p.reason.slice(0, 120)}`);
2282
+ // v0.27.1: surface the FULL pause contract — reason AND suggested
2283
+ // action. Before, the action only appeared in /goal status and the
2284
+ // widget truncated both at ~60 chars, so decision-pauses ("choose a
2285
+ // or b") reached the user as an unreadable fragment.
2286
+ ctx.ui.notify(`Goal paused: ${p.reason}${p.suggestedAction ? `\n\n→ ${p.suggestedAction}` : ""}`, "info");
2287
+ notifyExternal(ctx, `Goal paused: ${(p.suggestedAction ? `${p.reason} → ${p.suggestedAction}` : p.reason).slice(0, 200)}`);
2259
2288
  return { content: [{ type: "text", text: "Goal paused. /goal resume to continue." }], details: {} };
2260
2289
  },
2261
2290
  }));
@@ -3459,6 +3488,7 @@ function warnOnCommandCollision(ctx: ExtensionContext): void {
3459
3488
  export default function (pi: ExtensionAPI): void {
3460
3489
  extensionApi = pi;
3461
3490
  extensionApiStale = false; // a fresh factory run means a fresh runtime (reload path)
3491
+ resetLengthContinue(); // v0.27.2: fresh runtime, fresh truncation streak
3462
3492
  startHeartbeat();
3463
3493
  startUITicker();
3464
3494
  // Four top-level commands, that's all (v0.8.0 consolidation):
@@ -3774,6 +3804,23 @@ export default function (pi: ExtensionAPI): void {
3774
3804
  // continuation loop.
3775
3805
  if (isForeignCtx(ctx)) return;
3776
3806
  noteActivity(true);
3807
+ // v0.27.2: folded-in length-continue (standalone pi-length-continue is
3808
+ // deprecated). A response cut by the per-response output cap is NOT a
3809
+ // completed turn (no telemetry), NOT a stall (no no-tool nudge), and
3810
+ // must not run the loop measure or the normal goal continuation on half
3811
+ // a response — re-trigger immediately with split-smaller guidance and
3812
+ // skip ALL turn bookkeeping; the NEXT agent_end processes the run.
3813
+ // Works with no goal active (plain sessions truncate too).
3814
+ const lastA = [...(event.messages as any[])].reverse().find((m) => m.role === "assistant");
3815
+ const lc = tickLengthContinue(lastA?.stopReason === "length");
3816
+ if (lc.giveUpNow) {
3817
+ ctx.ui.notify(`glla: response hit the output-token cap ${LENGTH_CONTINUE_MAX}× in a row — stepping aside. Ask the model to split the work into smaller pieces.`, "warning");
3818
+ notifyExternal(ctx, "Response truncated 3× in a row — giving up auto-continue.");
3819
+ }
3820
+ if (lastA?.stopReason === "length") {
3821
+ if (lc.fire && !ctx.hasPendingMessages()) sendLengthContinue(ctx, lc.consecutive);
3822
+ return;
3823
+ }
3777
3824
  // v0.25.2: per-goal turn telemetry (/glla stats).
3778
3825
  if (state.goal && state.goal.status === "active") {
3779
3826
  const t = state.goal.telemetry ?? { turns: 0, fileWrites: 0, bashCalls: 0 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.27.0",
3
+ "version": "0.27.2",
4
4
  "description": "Goal. Loop. Audit. Done. — a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor — only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",