billion-context-pi 0.1.63 → 0.1.64-pr.356.147

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/config.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type Config, type Prompts } from "acp-kernel";
2
2
  import type { CompressReasoningConfig } from "./reasoning-drop.js";
3
+ import type { DegenerationGuardConfig } from "./degeneration.js";
3
4
  import type { ThrottleRetryConfig } from "./throttle-retry.js";
4
5
  /** Per-role delegate defaults. Lets long-lived automation pin a cheaper or
5
6
  * more capable model and a thinking level per delegate role, so the main
@@ -222,6 +223,15 @@ export interface AdapterConfig {
222
223
  * warn=3, abort=5. Stops greedy small models looping on byte-identical
223
224
  * tool calls (issue #308). */
224
225
  repetitionGuard?: boolean | RepetitionGuardConfig;
226
+ /** Character-level degenerate-repeat guard (see DegenerationGuardConfig).
227
+ * Collapses long single-codepoint runs (e.g. 4655×「【」) in assistant
228
+ * text/thinking of the outgoing view and injects a one-shot recovery notice
229
+ * after a degenerated turn — breaking the abort loop where pi replays the
230
+ * degenerated thinking back to the provider on every request (issue #351).
231
+ * Distinct from `repetitionGuard`, which is tool-call level. Accepts a
232
+ * boolean shorthand (`false` disables) or an object. Default: enabled,
233
+ * minRun=200. */
234
+ degenerationGuard?: boolean | DegenerationGuardConfig;
225
235
  /** Legacy flat alias for `delegate.displayUsage`. Kept for backward
226
236
  * compatibility with existing acp.json files. Prefer `delegate.displayUsage`. */
227
237
  displayUsage?: "merged" | "separate";
@@ -0,0 +1,98 @@
1
+ import type { SessionMessageEntry } from "@earendil-works/pi-coding-agent";
2
+ type AgentMessage = SessionMessageEntry["message"];
3
+ /** [#351] Character-level degenerate-repeat guard. Models occasionally
4
+ * degenerate into long single-codepoint runs (observed in the wild: a
5
+ * 5968-char thinking block ending in 4655 consecutive 「【」, escalating over
6
+ * turns until the turn aborts). This is distinct from `repetitionGuard`
7
+ * (issue #308), which breaks byte-identical TOOL-CALL loops; here the
8
+ * attractor is inside the generated text/thinking itself.
9
+ *
10
+ * Why the adapter must act: pi replays prior assistant thinking back to the
11
+ * provider on every subsequent request (openai-completions sends it as
12
+ * `reasoning_content`, or as plain text when `requiresThinkingAsText`), and
13
+ * an aborted turn's partial message persists in the session log. A
14
+ * degenerated tail therefore rides along every later prompt, where the model
15
+ * sees its own previous output ending in thousands of repeated characters —
16
+ * a continuation bias that re-triggers the same degeneration, aborting the
17
+ * next turn too. The session dies in an abort loop with no recovery path.
18
+ *
19
+ * The pass below collapses runs >= minRun in assistant text/thinking of the
20
+ * OUTGOING view (persisted history is never modified) and appends a one-shot
21
+ * recovery notice while the degenerated message is the most recent assistant
22
+ * turn. Position-based self-limiting: a fresh model turn makes the old
23
+ * message non-last, so the notice stops appearing without any persistent
24
+ * state (and cannot accumulate — #223 lesson). */
25
+ export interface DegenerationGuardConfig {
26
+ /** Master switch. Default: true. `false` disables the pass entirely
27
+ * (kill-switch). */
28
+ enabled?: boolean;
29
+ /** Minimum length of a single-codepoint run (counted in codepoints) before
30
+ * it is treated as degeneration. Legitimate single-char runs in coding
31
+ * sessions (markdown hrules, dotted leaders, comment banners) stay well
32
+ * under this; observed pre-degeneration drift maxed at ~60 before the
33
+ * catastrophic 4655×「【」 run. Default: 200. Values below 8 are raised to
34
+ * 8: the collapse marker must stay shorter than any detectable run, and a
35
+ * sub-8 threshold would start matching ordinary dotted leaders/hrules. */
36
+ minRun?: number;
37
+ }
38
+ export declare const DEFAULT_DEGENERATION_GUARD: Required<DegenerationGuardConfig>;
39
+ /** Resolve the guard config, handling the boolean shorthand (`false`
40
+ * disables). Invalid minRun values (< 2 or non-numeric) fall back to the
41
+ * default with a logged warning — they never fail the session. Valid values
42
+ * below MIN_VALID_MIN_RUN are raised to it. */
43
+ export declare function resolveDegenerationGuard(cfg?: boolean | DegenerationGuardConfig): Required<DegenerationGuardConfig>;
44
+ /** One maximal run of a repeated codepoint. */
45
+ export interface DegenerateRun {
46
+ /** The repeated codepoint (string of length 1 or 2 — surrogate pairs kept whole). */
47
+ char: string;
48
+ /** Run length in codepoints. */
49
+ count: number;
50
+ /** UTF-16 index of the run start in the original string. */
51
+ index: number;
52
+ }
53
+ /** Find maximal runs of a single repeated codepoint with length >= minRun.
54
+ * Codepoint-safe: surrogate pairs count as one unit, so a run of astral
55
+ * characters is detected like any other. Returns [] for empty input or
56
+ * minRun < 2 (a "run" shorter than 2 carries no signal). */
57
+ export declare function findDegenerateRuns(text: string, minRun: number): DegenerateRun[];
58
+ /** Collapse every degenerate run in `text` into a short marker that names the
59
+ * character and its original count. Pure, idempotent, fail-safe. Returns the
60
+ * input unchanged when there is nothing to collapse. */
61
+ export declare function collapseDegenerateRuns(text: string, minRun: number): string;
62
+ /** Evidence of one rewritten message: which blocks changed and what was collapsed. */
63
+ export interface CollapseEvidence {
64
+ /** Index of the rewritten message in the input array. */
65
+ msgIndex: number;
66
+ /** Block kinds rewritten ("content" for string content, else "text"/"thinking"). */
67
+ blocks: string[];
68
+ /** Runs collapsed across those blocks. */
69
+ runs: DegenerateRun[];
70
+ }
71
+ /** Request-time pass: collapse degenerate single-codepoint runs in ASSISTANT
72
+ * messages' text/thinking blocks (string content included). Every position is
73
+ * scanned — including the current turn's partial assistant message, which is
74
+ * exactly the one pi will replay back to the provider on the next request.
75
+ * Tool-call arguments are never touched (rewriting them would desync the
76
+ * model's view from the call that actually executed). Pure: returns the same
77
+ * array reference when nothing changed; idempotent; fail-safe (any error
78
+ * returns the input unchanged). Persisted history is never modified. */
79
+ export declare function collapseAssistantDegeneration(messages: AgentMessage[], cfg?: boolean | DegenerationGuardConfig): {
80
+ messages: AgentMessage[];
81
+ evidence: CollapseEvidence[];
82
+ };
83
+ /** Scan backward for the LAST assistant message and collect its degenerate
84
+ * runs. Call it with the PERSISTED originals in session order (not the
85
+ * outgoing view): thinking-only aborted turns never reach the outgoing view
86
+ * (projectMessage drops them), yet they are still "the previous turn".
87
+ * Returns null when absent or clean. Gates the recovery notice: because the
88
+ * notice fires only while the degenerated message is the most recent
89
+ * assistant turn, it self-limits — once the model produces a new turn the
90
+ * old message is no longer last and the notice disappears on the next
91
+ * rebuild (no persistent state needed). */
92
+ export declare function lastAssistantRuns(messages: AgentMessage[], minRun: number): DegenerateRun[] | null;
93
+ /** One-shot recovery notice appended while a degenerated assistant message is
94
+ * the most recent turn: tells the model the repeated segment carries no
95
+ * information and was truncated, and to resume from the last valid step
96
+ * instead of continuing the attractor. */
97
+ export declare function degenerationNotice(runs: DegenerateRun[]): AgentMessage;
98
+ export {};
package/dist/index.js CHANGED
@@ -4739,6 +4739,8 @@ var KNOWN = /* @__PURE__ */ new Set([
4739
4739
  "displayUsage",
4740
4740
  "throttleRetry",
4741
4741
  "outputHeadroomMaxPct",
4742
+ "repetitionGuard",
4743
+ "degenerationGuard",
4742
4744
  "prompts",
4743
4745
  "acknowledgePromptsRisk"
4744
4746
  ]);
@@ -16836,7 +16838,7 @@ async function statusReport(runtime, ctx) {
16836
16838
  const sentTokens = estimateTokens(coreMessages, coveredIds, imageTokens) + systemPromptTokens;
16837
16839
  const viewSentTokens = adjustedTokenCount(runtime.core, coreMessages, state, config, sentTokens, imageTokens, systemPromptTokens);
16838
16840
  const turn = runtime.core.processTurn({ messages: coreMessages, state, config, tokenCount: anchorStale ? viewSentTokens : Math.max(viewSentTokens, realUsage?.tokens ?? 0) });
16839
- const versionStr = "0.1.63" ? `billion-context-pi@${"0.1.63"}` : void 0;
16841
+ const versionStr = "0.1.64" ? `billion-context-pi@${"0.1.64"}` : void 0;
16840
16842
  let text = buildStatusPanel({
16841
16843
  version: versionStr,
16842
16844
  tokenCount: sessionTokens,
@@ -16857,6 +16859,170 @@ async function statusReport(runtime, ctx) {
16857
16859
  return text;
16858
16860
  }
16859
16861
 
16862
+ // src/degeneration.ts
16863
+ var DEFAULT_DEGENERATION_GUARD = { enabled: true, minRun: 200 };
16864
+ var MIN_VALID_MIN_RUN = 8;
16865
+ function resolveDegenerationGuard(cfg) {
16866
+ if (cfg === false) return { enabled: false, minRun: DEFAULT_DEGENERATION_GUARD.minRun };
16867
+ const c = typeof cfg === "object" && cfg !== null ? cfg : {};
16868
+ let minRun = DEFAULT_DEGENERATION_GUARD.minRun;
16869
+ if (c.minRun !== void 0) {
16870
+ const n = c.minRun;
16871
+ if (typeof n === "number" && Number.isFinite(n) && n >= 2) {
16872
+ minRun = Math.max(MIN_VALID_MIN_RUN, Math.floor(n));
16873
+ } else {
16874
+ logWarn("config", { event: "degeneration-guard-invalid", field: "minRun", value: n, fallback: DEFAULT_DEGENERATION_GUARD.minRun });
16875
+ }
16876
+ }
16877
+ return { enabled: c.enabled !== false, minRun };
16878
+ }
16879
+ var preScreenMinRun = Number.NaN;
16880
+ var preScreenRe = null;
16881
+ function hasLongRun(text, minRun) {
16882
+ const n = Math.floor(minRun);
16883
+ if (preScreenRe === null || preScreenMinRun !== n) {
16884
+ preScreenMinRun = n;
16885
+ preScreenRe = new RegExp(`(.)\\1{${Math.max(n - 1, 0)},}`, "su");
16886
+ }
16887
+ return preScreenRe.test(text);
16888
+ }
16889
+ function findDegenerateRuns(text, minRun) {
16890
+ const runs2 = [];
16891
+ if (!text || !Number.isFinite(minRun) || minRun < 2) return runs2;
16892
+ if (!hasLongRun(text, minRun)) return runs2;
16893
+ const chars = Array.from(text);
16894
+ let utf16 = 0;
16895
+ let k = 0;
16896
+ while (k < chars.length) {
16897
+ const ch = chars[k];
16898
+ let m2 = k + 1;
16899
+ while (m2 < chars.length && chars[m2] === ch) m2++;
16900
+ const count = m2 - k;
16901
+ if (count >= minRun) runs2.push({ char: ch, count, index: utf16 });
16902
+ utf16 += count * ch.length;
16903
+ k = m2;
16904
+ }
16905
+ return runs2;
16906
+ }
16907
+ function describeChar(ch) {
16908
+ if (ch === " ") return "space";
16909
+ if (ch === "\n") return "newline";
16910
+ if (ch === " ") return "tab";
16911
+ if (ch === "\r") return "carriage-return";
16912
+ const cp = ch.codePointAt(0) ?? 0;
16913
+ if (cp < 32 || cp === 127) return `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`;
16914
+ return JSON.stringify(ch);
16915
+ }
16916
+ function applyRuns(text, runs2, minRun) {
16917
+ const keep = Math.max(1, Math.min(3, minRun - 1));
16918
+ let out = "";
16919
+ let last = 0;
16920
+ for (const r of runs2) {
16921
+ out += text.slice(last, r.index);
16922
+ out += `${r.char.repeat(keep)}\u2026 [${r.count}\xD7 identical chars cut \u2014 degenerate repeat]`;
16923
+ last = r.index + r.count * r.char.length;
16924
+ }
16925
+ out += text.slice(last);
16926
+ return out;
16927
+ }
16928
+ function collapseAssistantDegeneration(messages, cfg) {
16929
+ const { enabled, minRun } = resolveDegenerationGuard(cfg);
16930
+ if (!enabled || messages.length === 0) return { messages, evidence: [] };
16931
+ try {
16932
+ const evidence = [];
16933
+ let changed = false;
16934
+ const out = messages.slice();
16935
+ for (let i = 0; i < messages.length; i++) {
16936
+ const msg = messages[i];
16937
+ if (msg.role !== "assistant") continue;
16938
+ const c = msg.content;
16939
+ if (typeof c === "string") {
16940
+ const runs3 = findDegenerateRuns(c, minRun);
16941
+ if (runs3.length > 0) {
16942
+ out[i] = { ...msg, content: applyRuns(c, runs3, minRun) };
16943
+ evidence.push({ msgIndex: i, blocks: ["content"], runs: runs3 });
16944
+ changed = true;
16945
+ }
16946
+ continue;
16947
+ }
16948
+ if (!Array.isArray(c)) continue;
16949
+ const blocks = [];
16950
+ const runs2 = [];
16951
+ let blockChanged = false;
16952
+ const nc = c.map((p) => {
16953
+ const b2 = p;
16954
+ if (b2?.type === "text" && typeof b2.text === "string") {
16955
+ const r = findDegenerateRuns(b2.text, minRun);
16956
+ if (r.length > 0) {
16957
+ blockChanged = true;
16958
+ blocks.push("text");
16959
+ runs2.push(...r);
16960
+ return { ...b2, text: applyRuns(b2.text, r, minRun) };
16961
+ }
16962
+ return p;
16963
+ }
16964
+ if (b2?.type === "thinking" && typeof b2.thinking === "string") {
16965
+ const r = findDegenerateRuns(b2.thinking, minRun);
16966
+ if (r.length > 0) {
16967
+ blockChanged = true;
16968
+ blocks.push("thinking");
16969
+ runs2.push(...r);
16970
+ return { ...b2, thinking: applyRuns(b2.thinking, r, minRun) };
16971
+ }
16972
+ return p;
16973
+ }
16974
+ return p;
16975
+ });
16976
+ if (blockChanged) {
16977
+ out[i] = { ...msg, content: nc };
16978
+ evidence.push({ msgIndex: i, blocks, runs: runs2 });
16979
+ changed = true;
16980
+ }
16981
+ }
16982
+ return { messages: changed ? out : messages, evidence };
16983
+ } catch {
16984
+ return { messages, evidence: [] };
16985
+ }
16986
+ }
16987
+ function lastAssistantRuns(messages, minRun) {
16988
+ try {
16989
+ for (let i = messages.length - 1; i >= 0; i--) {
16990
+ const msg = messages[i];
16991
+ if (msg.role !== "assistant") continue;
16992
+ const runs2 = [];
16993
+ const c = msg.content;
16994
+ if (typeof c === "string") {
16995
+ runs2.push(...findDegenerateRuns(c, minRun));
16996
+ } else if (Array.isArray(c)) {
16997
+ for (const p of c) {
16998
+ const b2 = p;
16999
+ if (b2?.type === "text" && typeof b2.text === "string") runs2.push(...findDegenerateRuns(b2.text, minRun));
17000
+ else if (b2?.type === "thinking" && typeof b2.thinking === "string") runs2.push(...findDegenerateRuns(b2.thinking, minRun));
17001
+ }
17002
+ }
17003
+ return runs2.length > 0 ? runs2 : null;
17004
+ }
17005
+ return null;
17006
+ } catch {
17007
+ return null;
17008
+ }
17009
+ }
17010
+ function degenerationNotice(runs2) {
17011
+ const sorted = [...runs2].sort((a, b2) => b2.count - a.count);
17012
+ const top = sorted[0];
17013
+ const extra = sorted.length > 1 ? ` and ${sorted.length - 1} other repeated segment(s)` : "";
17014
+ return {
17015
+ role: "user",
17016
+ content: [
17017
+ {
17018
+ type: "text",
17019
+ text: `[ACP recovery notice] Your previous turn ended in degenerate generation: its output contained ${top.count} consecutive repetitions of ${describeChar(top.char)}${extra}. That repeated segment carries no information and has been truncated in the context above. Do not reproduce it or continue the pattern. Resume your task from your last valid step.`
17020
+ }
17021
+ ],
17022
+ timestamp: Date.now()
17023
+ };
17024
+ }
17025
+
16860
17026
  // src/system-prompt.ts
16861
17027
  function buildAcpSystemPrompt(prompts) {
16862
17028
  return `
@@ -17370,7 +17536,7 @@ async function autoInstallLatest(latest, extDirOverride) {
17370
17536
  "--no-fund",
17371
17537
  "--no-save"
17372
17538
  ];
17373
- const prevVersion = (await readPackageJson(join11(extDir, "package.json")))?.version ?? "0.1.63";
17539
+ const prevVersion = (await readPackageJson(join11(extDir, "package.json")))?.version ?? "0.1.64";
17374
17540
  const { code, stderr } = await runNpmImpl(installArgs(latest), { cwd: npmDir, timeout: 6e4 });
17375
17541
  if (code !== 0) {
17376
17542
  logWarn("update", {
@@ -17388,7 +17554,7 @@ async function autoInstallLatest(latest, extDirOverride) {
17388
17554
  }
17389
17555
  const verify = await verifyInstall(npmDir, latest);
17390
17556
  if (!verify.ok) {
17391
- const rollbackTo = SEMVER_RE.test(prevVersion) ? prevVersion : "0.1.63";
17557
+ const rollbackTo = SEMVER_RE.test(prevVersion) ? prevVersion : "0.1.64";
17392
17558
  logWarn("update", { event: "auto-install-verify-failed", latest, reason: verify.reason, rollbackTo });
17393
17559
  const rb = await runNpmImpl(installArgs(rollbackTo), { cwd: npmDir, timeout: 6e4 });
17394
17560
  logInfo("update", { event: "rollback", from: latest, to: rollbackTo, ok: rb.code === 0 });
@@ -17463,7 +17629,7 @@ async function checkForUpdate(autoUpdate, notify) {
17463
17629
  }
17464
17630
  const latest = await fetchLatestVersion(tag);
17465
17631
  if (!latest) return;
17466
- const current = runtimeVersion ?? "0.1.63";
17632
+ const current = runtimeVersion ?? "0.1.64";
17467
17633
  const hasUpdate = isVersionNewer(latest, current);
17468
17634
  debug.event("update-check", {
17469
17635
  current,
@@ -17641,7 +17807,7 @@ function wireSessionLifecycle(pi, runtime, standDownIfProxied) {
17641
17807
  setDelegatePolicy(DEFAULT_DELEGATE_POLICY);
17642
17808
  const sid = ctx.sessionManager.getSessionId();
17643
17809
  const modelInfo = ctx.model;
17644
- logInfo("session", { event: "start", sid, cwd: ctx.cwd, debug: runtime.adapter.debug ?? null, version: true ? "0.1.63" : null, model: modelInfo?.id ?? null, modelApi: modelInfo?.api ?? null, contextWindow: modelInfo?.contextWindow ?? null });
17810
+ logInfo("session", { event: "start", sid, cwd: ctx.cwd, debug: runtime.adapter.debug ?? null, version: true ? "0.1.64" : null, model: modelInfo?.id ?? null, modelApi: modelInfo?.api ?? null, contextWindow: modelInfo?.contextWindow ?? null });
17645
17811
  try {
17646
17812
  await runtime.reloadConfig(ctx.cwd);
17647
17813
  const delegateCfg = resolveDelegate(runtime.adapter);
@@ -17684,6 +17850,7 @@ function wireSessionLifecycle(pi, runtime, standDownIfProxied) {
17684
17850
  closeLogStream();
17685
17851
  });
17686
17852
  }
17853
+ var lastDegNoticeKey = null;
17687
17854
  function wireContextTransform(pi, runtime, standDownIfProxied) {
17688
17855
  pi.on("context", async (event, ctx) => {
17689
17856
  if (runtime.refused) return;
@@ -17796,6 +17963,24 @@ function wireContextTransform(pi, runtime, standDownIfProxied) {
17796
17963
  debug.event("reasoning-drop", { sid, droppedChars, drop: reasoningDrop.drop, threshold: reasoningDrop.threshold });
17797
17964
  }
17798
17965
  rebuilt = droppedThinking;
17966
+ const degCfg = resolveDegenerationGuard(runtime.adapter.degenerationGuard);
17967
+ const tailRuns = degCfg.enabled ? lastAssistantRuns([...originalById.values()], degCfg.minRun) : null;
17968
+ const deg = collapseAssistantDegeneration(rebuilt, degCfg);
17969
+ if (deg.messages !== rebuilt) {
17970
+ rebuilt = deg.messages;
17971
+ const maxRun = deg.evidence.reduce((m2, e) => e.runs.reduce((x2, r) => Math.max(x2, r.count), m2), 0);
17972
+ logWarn("degeneration", { sid, event: "runs-collapsed", msgs: deg.evidence.length, maxRun, minRun: degCfg.minRun });
17973
+ debug.event("degeneration-collapsed", { sid, msgs: deg.evidence.length, maxRun });
17974
+ }
17975
+ if (tailRuns) {
17976
+ rebuilt.push(degenerationNotice(tailRuns));
17977
+ const top = [...tailRuns].sort((a, b2) => b2.count - a.count)[0];
17978
+ logInfo("degeneration", { sid, event: "recovery-notice", maxRun: top.count });
17979
+ if (ctx.hasUI && lastDegNoticeKey !== `${sid}:${top.count}:${top.char.codePointAt(0)}`) {
17980
+ lastDegNoticeKey = `${sid}:${top.count}:${top.char.codePointAt(0)}`;
17981
+ ctx.ui.notify(`[ACP] previous turn ended in degenerate generation (${top.count}\xD7 repeat) \u2014 the repeated segment was truncated above and a recovery notice injected.`);
17982
+ }
17983
+ }
17799
17984
  const debugOn2 = debug.enabled;
17800
17985
  const turnKey = lastUserMessageId(entries) ?? sid;
17801
17986
  const compressOutcomes = collectCompressOutcomes(entries, turnStartIndex(entries));