billion-context-pi 0.1.53 → 0.1.54-pr.237.54

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/README.md CHANGED
@@ -22,6 +22,8 @@ The model decides <em>when</em> and <em>what</em> to compress — not a hard lim
22
22
 
23
23
  ---
24
24
 
25
+ > **Host support:** this plugin is for **Pi**. It does **not** support **OMP (oh-my-pi)** — on an OMP host it refuses to run. OMP users: use [billion-context](https://github.com/ranxianglei/billion-context) instead (`bili omp`). Details: [docs/omp.md](./docs/omp.md).
26
+
25
27
  ## Why?
26
28
 
27
29
  When conversations get long, the model runs out of context. Most tools hard-truncate — silently dropping earlier messages. **billion-context** gives the model a `compress` tool: the LLM decides **when** and **what** to compress into high-fidelity summaries, preserving critical details (file paths, decisions, error strings) while reclaiming context space.
@@ -72,6 +74,22 @@ This has two practical implications:
72
74
 
73
75
  2. **Even with a single compression plugin, interference is still possible in rare cases.** Load order under Pi is determined by filesystem discovery order (`fs.readdirSync` over `.pi/extensions/` → global → packages), which is not fully deterministic. If another (non-compression) extension also hooks the `context` event and happens to load *after* billion-context-pi, it could modify the compressed output. billion-context-pi rebuilds its working set from the session log rather than the chained input, which makes it robust to handlers that run *before* it — but it cannot defend against a handler that runs *after* it. This is a limitation of Pi's extension model; if you observe unexpected context behavior, check whether other installed extensions intercept the `context` event.
74
76
 
77
+ ## Host support
78
+
79
+ billion-context-pi is built for the **Pi** coding agent (`@earendil-works/pi-coding-agent`) and detects the host at session start:
80
+
81
+ - **Pi** — fully supported.
82
+ - **OMP (`can1357/oh-my-pi`)** — **not supported.** OMP's in-process session API diverges from Pi's, so the compression refs the extension injects can drift out of sync with the session's real refs and `compress` calls fail with `does not exist in this session` (issue [#234](https://github.com/ranxianglei/billion-context-pi/issues/234)). On OMP the extension now **refuses service**: it prints a warning, disables the ACP tools, and leaves the host's own context handling untouched.
83
+
84
+ **Use [billion-context](https://github.com/ranxianglei/billion-context) instead** — it runs the same compression pipeline server-side in a proxy, so the refs never diverge:
85
+
86
+ ```bash
87
+ npm install -g billion-context
88
+ bili omp # run OMP through the proxy
89
+ ```
90
+
91
+ Full details: [docs/omp.md](./docs/omp.md).
92
+
75
93
  ## Model-facing tools
76
94
 
77
95
  | Tool | What it does |
package/README.zh-CN.md CHANGED
@@ -20,6 +20,8 @@
20
20
 
21
21
  ---
22
22
 
23
+ > **宿主支持:** 本插件面向 **Pi**。它**不支持 OMP(oh-my-pi)** —— 在 OMP 宿主上会拒绝运行。OMP 用户请改用 [billion-context](https://github.com/ranxianglei/billion-context)(`bili omp`)。详细说明:[docs/omp.zh-CN.md](./docs/omp.zh-CN.md)。
24
+
23
25
  ## 为什么选择 billion-context
24
26
 
25
27
  当对话变长,模型的上下文会耗尽。多数工具采用硬截断 —— 静默丢弃早期消息。**billion-context** 把 `compress` 工具交给模型:由 LLM 决定**何时**压缩、压缩**什么**,将内容压缩成高保真摘要,在回收上下文空间的同时保留关键细节(文件路径、决策、错误字符串)。
@@ -71,6 +73,22 @@ billion-context 通过拦截 Pi 的 `context` 事件接管上下文管理。**Pi
71
73
 
72
74
  2. **即使只有一个压缩插件,在少数情况下仍可能出现干扰。** Pi 下的加载顺序由文件系统发现顺序(`fs.readdirSync` 遍历 `.pi/extensions/` → 全局 → 包)决定,并不完全确定。如果另一个(非压缩类)扩展也 hook 了 `context` 事件、且恰好加载在 billion-context-pi *之后*,它可能修改压缩后的输出。billion-context-pi 从会话日志重建工作集(而非链式输入),这让它对*排在它之前*的 handler 鲁棒 —— 但无法防御*排在它之后*的 handler。这是 Pi 扩展模型的固有限制;若你观察到上下文行为异常,请检查是否有其他已安装扩展拦截了 `context` 事件。
73
75
 
76
+ ## 宿主支持
77
+
78
+ billion-context-pi 面向 **Pi** 编码代理(`@earendil-works/pi-coding-agent`)构建,并在会话开始时检测宿主:
79
+
80
+ - **Pi** — 完全支持。
81
+ - **OMP(`can1357/oh-my-pi`)** — **不支持。** OMP 的进程内会话 API 与 Pi 不同,扩展注入的压缩引用可能与会话的真实引用漂移失步,导致 `compress` 调用失败,报错 `does not exist in this session`(issue [#234](https://github.com/ranxianglei/billion-context-pi/issues/234))。在 OMP 上,扩展现在会**拒绝服务**:打印警告、禁用 ACP 工具,并保持宿主自身的上下文处理不受影响。
82
+
83
+ **请改用 [billion-context](https://github.com/ranxianglei/billion-context)** — 它把同样的压缩流水线运行在服务端代理里,因此引用不会漂移:
84
+
85
+ ```bash
86
+ npm install -g billion-context
87
+ bili omp # 让 OMP 通过代理运行
88
+ ```
89
+
90
+ 完整说明:[docs/omp.zh-CN.md](./docs/omp.zh-CN.md)。
91
+
74
92
  ## 模型工具
75
93
 
76
94
  | 工具 | 作用 |
@@ -8,7 +8,7 @@ export declare function delegateSpawnOptions(cwd: string, env: NodeJS.ProcessEnv
8
8
  * run the SDK inside another node process, so probe instead. Non-pi hosts
9
9
  * (omp) keep argv[1] untouched. */
10
10
  export declare function resolvePiCliEntry(argv1: string, env?: NodeJS.ProcessEnv, piHost?: boolean): string;
11
- type RunStatus = "running" | "completed" | "failed" | "cancelled";
11
+ export type RunStatus = "running" | "completed" | "failed" | "cancelled";
12
12
  interface DelegateRun {
13
13
  runId: string;
14
14
  agent: string;
@@ -104,6 +104,7 @@ declare const WaitParams: Type.TObject<{
104
104
  }>;
105
105
  export declare function accumulateUsage(a: Usage | undefined, b: Usage): Usage;
106
106
  export declare function makeDelegateTool(pi: ExtensionAPI): ToolDefinition<typeof DelegateParams>;
107
+ export declare function formatRunResult(run: DelegateRun): string;
107
108
  /** Runs that reached a terminal state but whose result never reached the
108
109
  * model: no parked waiter, not consumed by a tool result, and never injected
109
110
  * as a notification. The model was promised a notification ("do NOT keep
@@ -163,5 +164,12 @@ export declare function buildChildArgs(args: DelegateArgs, rolePrompt: string, c
163
164
  isAsync: boolean;
164
165
  useJsonStream: boolean;
165
166
  }>;
166
- export declare function injectResult(pi: ExtensionAPI, agent: string, runId: string, task: string, code: number | null, file: string, timedOut?: string, usage?: Usage, mode?: "merged" | "separate", usageAlreadyReported?: boolean, body?: string): boolean;
167
+ /** Watchdog/EOF finalize arrives with code === null (the child was killed or
168
+ * never exited). If a result was delivered (non-empty reply or stderr), the
169
+ * run counts as completed (0); otherwise it stays null = genuine failure. */
170
+ export declare function effectiveExitCode(code: number | null, output: string, stderr: string): number | null;
171
+ /** status (set by finalize from the effective exit code) is the authority for
172
+ * the FAILED/completed decision; the raw code is diagnostic display only
173
+ * ("exit ?"), so the notification can never disagree with run.status. */
174
+ export declare function injectResult(pi: ExtensionAPI, agent: string, runId: string, task: string, status: RunStatus, code: number | null, file: string, timedOut?: string, usage?: Usage, mode?: "merged" | "separate", usageAlreadyReported?: boolean, body?: string): boolean;
167
175
  export {};
package/dist/index.js CHANGED
@@ -424,18 +424,42 @@ function assignRefs(messages, options) {
424
424
  return { map, nextIndex: cursor, newlyAssigned };
425
425
  }
426
426
  function allocateFreeRef(map, start) {
427
- let candidate = Math.max(start, MIN_INDEX);
428
- while (candidate <= MAX_INDEX) {
427
+ const from = Math.max(start, MIN_INDEX);
428
+ for (let candidate = from; candidate <= MAX_INDEX; candidate++) {
429
+ const text = indexToRef(candidate);
430
+ if (!map.byRef[text]) {
431
+ return { text, index: candidate };
432
+ }
433
+ }
434
+ for (let candidate = MIN_INDEX; candidate < from; candidate++) {
429
435
  const text = indexToRef(candidate);
430
436
  if (!map.byRef[text]) {
431
437
  return { text, index: candidate };
432
438
  }
433
- candidate++;
434
439
  }
435
440
  throw new Error(
436
- `ref capacity exhausted: cannot allocate beyond ${indexToRef(MAX_INDEX)}`
441
+ `ref capacity exhausted: all ${MAX_INDEX} ref slots are occupied by live messages \u2014 nothing left to reclaim`
437
442
  );
438
443
  }
444
+ function pruneDeadRefs(map, liveIds) {
445
+ let pruned = 0;
446
+ const byRaw = {};
447
+ for (const [rawId, ref] of Object.entries(map.byRaw)) {
448
+ if (liveIds.has(rawId)) {
449
+ byRaw[rawId] = ref;
450
+ continue;
451
+ }
452
+ pruned++;
453
+ }
454
+ return { map: rebuildRefIndex({ byRaw, byRef: {} }), pruned };
455
+ }
456
+ function rebuildRefIndex(map) {
457
+ const byRef = {};
458
+ for (const [rawId, ref] of Object.entries(map.byRaw)) {
459
+ if (ref !== BLOCKED_REF) byRef[ref] = rawId;
460
+ }
461
+ return { byRaw: { ...map.byRaw }, byRef };
462
+ }
439
463
  function highestUsedIndex(map) {
440
464
  let highest = 0;
441
465
  for (const ref of Object.values(map.byRaw)) {
@@ -634,6 +658,13 @@ function syncBlocks(messages, state) {
634
658
  deactivated.push(block.blockId);
635
659
  }
636
660
  }
661
+ const liveIds = new Set(presentIds);
662
+ for (const block of result.blocks) {
663
+ if (!block.active) continue;
664
+ for (const id of block.effectiveMessageIds) liveIds.add(id);
665
+ }
666
+ const prunedRefs = pruneDeadRefs(result.messageRefs, liveIds);
667
+ result.messageRefs = prunedRefs.map;
637
668
  return { state: result, deactivated };
638
669
  }
639
670
  function defaultConfig(modelContextLimit, overrides = {}) {
@@ -744,12 +775,14 @@ var BoundaryNotFoundError = class extends Error {
744
775
  code = "BOUNDARY_NOT_FOUND";
745
776
  kind;
746
777
  endpoint;
747
- constructor(kind, endpoint, message) {
778
+ reason;
779
+ constructor(kind, endpoint, message, reason = null) {
748
780
  super(message);
749
781
  this.name = "BoundaryNotFoundError";
750
782
  this.code = "BOUNDARY_NOT_FOUND";
751
783
  this.kind = kind;
752
784
  this.endpoint = endpoint;
785
+ this.reason = reason;
753
786
  }
754
787
  };
755
788
  function resolveBoundaries(input) {
@@ -820,7 +853,7 @@ function resolveAnchorIndex(boundary, state, indexByMessageId, endpoint) {
820
853
  throw new BoundaryNotFoundError(
821
854
  "unknown",
822
855
  endpoint,
823
- `${label}="${boundary.raw}" does not exist in this session (typo or wrong session) \u2014 run acp_status for current refs.`
856
+ `${label}="${boundary.raw}" does not exist in this session (typo, wrong session, or the message was edited/removed and its ref released) \u2014 run acp_status for current refs.`
824
857
  );
825
858
  }
826
859
  const index = indexByMessageId.get(rawId);
@@ -834,10 +867,33 @@ function resolveAnchorIndex(boundary, state, indexByMessageId, endpoint) {
834
867
  snapped: `${label}="${boundary.raw}" refers to a message already compressed into an active block \u2014 anchored to the active block covering it instead.`
835
868
  };
836
869
  }
870
+ const coveringActive = state.blocks.filter(
871
+ (block2) => block2.active && block2.effectiveMessageIds.includes(rawId)
872
+ );
873
+ if (coveringActive.length > 0) {
874
+ throw new BoundaryNotFoundError(
875
+ "consumed",
876
+ endpoint,
877
+ `${label}="${boundary.raw}" is already compressed into active block(s) ${coveringActive.map((block2) => block2.blockId).join(", ")} \u2014 retry with the block ref(s) instead of the message ref.`,
878
+ "consumed"
879
+ );
880
+ }
881
+ const generationReplaced = state.blocks.some(
882
+ (block2) => !block2.active && block2.effectiveMessageIds.includes(rawId)
883
+ );
884
+ if (generationReplaced) {
885
+ throw new BoundaryNotFoundError(
886
+ "consumed",
887
+ endpoint,
888
+ `${label}="${boundary.raw}" was compressed into a block that has since been distilled or consumed \u2014 its content is no longer available in this session; run acp_status for current refs.`,
889
+ "generation-replaced"
890
+ );
891
+ }
837
892
  throw new BoundaryNotFoundError(
838
893
  "consumed",
839
894
  endpoint,
840
- `${label}="${boundary.raw}" not found in visible context (likely consumed by an existing block).`
895
+ `${label}="${boundary.raw}" no longer exists \u2014 the message was edited or removed (its content id changed); run acp_status for current refs.`,
896
+ "edit-drift"
841
897
  );
842
898
  }
843
899
  const block = blockById(state, `b${boundary.numericId}`);
@@ -866,16 +922,21 @@ function resolveAnchorIndex(boundary, state, indexByMessageId, endpoint) {
866
922
  };
867
923
  }
868
924
  if (!block.active) {
925
+ const consumer = state.blocks.find(
926
+ (candidate) => candidate.active && candidate.directBlockIds.includes(block.blockId) && visibleBlockAnchor(candidate, indexByMessageId) !== null
927
+ );
869
928
  throw new BoundaryNotFoundError(
870
929
  "consumed",
871
930
  endpoint,
872
- `${label}="b${boundary.numericId}" not found in visible context (block distilled/consumed by a higher-tier block).`
931
+ consumer ? `${label}="b${boundary.numericId}" was consumed by ${consumer.blockId} (higher-tier block) \u2014 retry with ${consumer.blockId} instead.` : `${label}="b${boundary.numericId}" is inactive (distilled or garbage-collected) and none of its content is visible in the current context \u2014 run acp_status for current refs.`,
932
+ "generation-replaced"
873
933
  );
874
934
  }
875
935
  throw new BoundaryNotFoundError(
876
936
  "consumed",
877
937
  endpoint,
878
- `${label}="b${boundary.numericId}" is an active block but none of its content (raw messages or rendered summary) is visible in the current context \u2014 run acp_status to verify.`
938
+ `${label}="b${boundary.numericId}" is an active block but none of its content (raw messages or rendered summary) is present in the current view \u2014 its messages were likely edited or removed (id drift); it will be deactivated on the next state sync. Run acp_status to verify.`,
939
+ "edit-drift"
879
940
  );
880
941
  }
881
942
  function activeOwnerAnchor(state, ownedIds, indexByMessageId) {
@@ -1800,10 +1861,33 @@ function numericBlockId(id) {
1800
1861
  const parsed = /^b(\d+)$/.exec(id);
1801
1862
  return parsed ? Number(parsed[1]) : 0;
1802
1863
  }
1864
+ function refGateDiagnostics(state, requestedRanges, unknownCount) {
1865
+ const highest = highestUsedIndex(state.messageRefs);
1866
+ const highestRef = highest > 0 ? indexToRef(highest) : "none";
1867
+ return `[diagnostics: session highest ref=${highestRef}, unknown ranges in request=${unknownCount}/${requestedRanges}, session history=${state.stats.compressionCount} compression(s), ${state.blocks.length} block(s)]`;
1868
+ }
1869
+ function danglingMessageRefs(state, messages, spec) {
1870
+ const visible = new Set(messages.map((m) => m.id));
1871
+ const dangling = [];
1872
+ for (const ref of [spec.startRef, spec.endRef]) {
1873
+ const parsed = parseBoundary(ref);
1874
+ if (!parsed || parsed.kind !== "message") continue;
1875
+ const rawId = state.messageRefs.byRef[parsed.raw] ?? state.messageRefs.byRef[indexToRef(parsed.numericId)];
1876
+ if (!rawId || visible.has(rawId)) continue;
1877
+ const covered = state.blocks.some(
1878
+ (block) => block.active && block.effectiveMessageIds.includes(rawId)
1879
+ );
1880
+ if (!covered) dangling.push(parsed.raw);
1881
+ }
1882
+ return dangling;
1883
+ }
1803
1884
  function createCore(ports = {}) {
1804
1885
  const countTokens = ports.countTokens ?? defaultCountTokens;
1805
1886
  function applyCompression(input) {
1806
- const state = cloneState(input.state);
1887
+ const state = syncBlocks(
1888
+ input.messages,
1889
+ input.state
1890
+ ).state;
1807
1891
  const runId = allocateRunId(state);
1808
1892
  let blocksCreated = 0;
1809
1893
  let tokensCompressed = 0;
@@ -1900,7 +1984,15 @@ function createCore(ports = {}) {
1900
1984
  if (!hasBlockBoundaryRange && totalRangeChars < input.config.compress.minCompressRange) {
1901
1985
  const live = activeBlocks(state).map((b) => b.blockId).sort((x, y) => numericBlockId(x) - numericBlockId(y));
1902
1986
  const liveHint = live.length > 0 ? ` Current active blocks span ${live[0]}..${live[live.length - 1]} \u2014 retry with startId/endId set to active block IDs in that span.` : "";
1903
- const gateMessage = resolvableCount === 0 && consumedRanges.length === 0 && unknownCount > 0 ? `None of the ${input.ranges.length} requested range(s) resolved \u2014 every ref failed with "does not exist in this session". Refs recorded before an earlier compress are stale: each successful compress renumbers the remaining refs. Run acp_status, then call the compress tool again using only the refs it reports.` : consumedRanges.length > 0 ? `Requested range(s) already compressed (e.g. ${consumedRanges[0].startRef}..${consumedRanges[0].endRef}) \u2014 your refs are stale: a prior compress renumbered the remaining refs, so this range now falls inside an active block. Run acp_status, then call the compress tool again using only the CURRENT compressible ranges it reports.${liveHint}` : `Total compressible content too small (${totalRangeChars} chars across ${countedRanges} range(s), min ${input.config.compress.minCompressRange}). Combine more messages into your range(s) to meet the threshold.`;
1987
+ const diagnostics = refGateDiagnostics(
1988
+ state,
1989
+ input.ranges.length,
1990
+ unknownCount
1991
+ );
1992
+ const danglingRefs = consumedRanges.flatMap(
1993
+ (spec) => danglingMessageRefs(state, input.messages, spec)
1994
+ );
1995
+ const gateMessage = resolvableCount === 0 && consumedRanges.length === 0 && unknownCount > 0 ? `None of the ${input.ranges.length} requested range(s) resolved \u2014 every ref is unknown to this session. Refs are per-session snapshots, assigned once when a message is first rendered; no compress reassigns them, so unknown refs cannot come from an earlier compress in this session. They come from a different generation: a previous session instance (switching model or upstream mid-conversation starts a fresh session whose refs restart at m00001), the generation before a native-compaction rebase (which also resets refs to m00001), a typo, or a message that was edited or removed (its old ref was released when the content id changed). ${diagnostics} Run acp_status, then call the compress tool again using only the refs it reports.` : consumedRanges.length > 0 ? danglingRefs.length > 0 ? `Requested range(s) cannot be anchored (e.g. ${consumedRanges[0].startRef}..${consumedRanges[0].endRef}) \u2014 the refs exist in this session's ref map, but the messages they point to are no longer in the visible context and no active block covers them: the message content changed (or the message was filtered out of the view) and now carries a new ref, leaving your old refs dangling. ${diagnostics} Run acp_status, then call the compress tool again using only the refs it reports.` : `Requested range(s) already compressed (e.g. ${consumedRanges[0].startRef}..${consumedRanges[0].endRef}) \u2014 those refs no longer point to directly compressible content: the range is covered by active block(s) or the block ref(s) are stale (distilled or consumed). ${diagnostics} Run acp_status, then call the compress tool again using only the CURRENT compressible ranges it reports.${liveHint}` : `Total compressible content too small (${totalRangeChars} chars across ${countedRanges} range(s), min ${input.config.compress.minCompressRange}). Combine more messages into your range(s) to meet the threshold.`;
1904
1996
  return {
1905
1997
  state: input.state,
1906
1998
  result: {
@@ -2043,9 +2135,15 @@ var assignRefsNode = {
2043
2135
  run(io, ctx) {
2044
2136
  const hasProtection = ctx.config.protectedTools.length > 0 || !!ctx.config.isToolProtected;
2045
2137
  const protectedFn = hasProtection ? (m) => isMessageProtected(m, ctx.config) : void 0;
2138
+ const liveIds = new Set(io.messages.map((m) => m.id));
2139
+ for (const block of io.state.blocks) {
2140
+ if (!block.active) continue;
2141
+ for (const id of block.effectiveMessageIds) liveIds.add(id);
2142
+ }
2143
+ const pruned = pruneDeadRefs(io.state.messageRefs, liveIds);
2046
2144
  const refResult = assignRefs(io.messages, {
2047
- existing: io.state.messageRefs,
2048
- nextIndex: highestUsedIndex(io.state.messageRefs) + 1,
2145
+ existing: pruned.map,
2146
+ nextIndex: highestUsedIndex(pruned.map) + 1,
2049
2147
  isProtected: protectedFn
2050
2148
  });
2051
2149
  return { ...io, state: { ...io.state, messageRefs: refResult.map } };
@@ -2617,26 +2715,6 @@ function computeContextBreakdown(messages, total, growth, countTokens) {
2617
2715
  }
2618
2716
  return { system, tool, summaries, code, text, total, growth };
2619
2717
  }
2620
- function cloneState(state) {
2621
- return {
2622
- blocks: state.blocks.map((block) => ({
2623
- ...block,
2624
- directMessageIds: [...block.directMessageIds],
2625
- effectiveMessageIds: [...block.effectiveMessageIds],
2626
- directBlockIds: [...block.directBlockIds]
2627
- })),
2628
- messageRefs: {
2629
- byRaw: { ...state.messageRefs.byRaw },
2630
- byRef: { ...state.messageRefs.byRef }
2631
- },
2632
- tokenSnapshot: { ...state.tokenSnapshot ?? {} },
2633
- nudge: { ...state.nudge, anchors: { ...state.nudge.anchors } },
2634
- stats: { ...state.stats },
2635
- absorbed: (state.absorbed ?? []).map((record) => ({ ...record })),
2636
- nextBlockId: state.nextBlockId,
2637
- nextRunId: state.nextRunId
2638
- };
2639
- }
2640
2718
  function scoreRelevance(block, terms) {
2641
2719
  const topic = (block.topic ?? "").toLowerCase();
2642
2720
  const summary = block.summary.toLowerCase();
@@ -2967,9 +3045,9 @@ function parseObjectValue(value, callId, diag) {
2967
3045
  const content = value["content"];
2968
3046
  if (content === void 0) {
2969
3047
  const single = validateEntry(value, callId);
2970
- if (single) {
3048
+ if ("range" in single) {
2971
3049
  diag.kind = "ok";
2972
- return finish([single], diag);
3050
+ return finish([single.range], diag);
2973
3051
  }
2974
3052
  diag.kind = "missing-content";
2975
3053
  return finish([], diag);
@@ -2990,7 +3068,7 @@ function parseObjectValue(value, callId, diag) {
2990
3068
  diag.kind = "content-not-array";
2991
3069
  return finish([], diag);
2992
3070
  }
2993
- const { ranges, invalid } = validateEntries(entries, callId);
3071
+ const { ranges, invalid, reasons } = validateEntries(entries, callId);
2994
3072
  const topTopic = stringOr(value["topic"]);
2995
3073
  const topMaxChars = value["summaryMaxChars"];
2996
3074
  const hasTopMaxChars = typeof topMaxChars === "number" && Number.isFinite(topMaxChars);
@@ -3001,6 +3079,7 @@ function parseObjectValue(value, callId, diag) {
3001
3079
  }
3002
3080
  }
3003
3081
  diag.invalidItems = invalid;
3082
+ if (reasons.length > 0) diag.invalidReasons = reasons;
3004
3083
  diag.kind = salvaged ? "truncated" : ranges.length > 0 ? "ok" : "no-valid-ranges";
3005
3084
  return finish(ranges, diag);
3006
3085
  }
@@ -3024,36 +3103,43 @@ function finish(ranges, diag) {
3024
3103
  return { ranges, diagnostics: diag };
3025
3104
  }
3026
3105
  function finishSalvage(entries, callId, diag, truncatedShape) {
3027
- const { ranges, invalid } = validateEntries(entries, callId);
3106
+ const { ranges, invalid, reasons } = validateEntries(entries, callId);
3028
3107
  diag.invalidItems = invalid;
3108
+ if (reasons.length > 0) diag.invalidReasons = reasons;
3029
3109
  diag.kind = entries.length > 0 || truncatedShape ? "truncated" : "malformed-json";
3030
3110
  return finish(ranges, diag);
3031
3111
  }
3032
3112
  function validateEntries(entries, callId) {
3033
3113
  const ranges = [];
3114
+ const reasons = [];
3034
3115
  let invalid = 0;
3035
- for (const entry of entries) {
3036
- const range = validateEntry(entry, callId);
3037
- if (range !== null) ranges.push(range);
3038
- else invalid++;
3116
+ for (let i = 0; i < entries.length; i++) {
3117
+ const outcome = validateEntry(entries[i], callId);
3118
+ if ("range" in outcome) ranges.push(outcome.range);
3119
+ else {
3120
+ invalid++;
3121
+ reasons.push(`entry ${i}: ${outcome.reason}`);
3122
+ }
3039
3123
  }
3040
- return { ranges, invalid };
3124
+ return { ranges, invalid, reasons };
3041
3125
  }
3042
3126
  function validateEntry(entry, callId) {
3043
- if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return null;
3127
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return { reason: "not an object" };
3044
3128
  const e = entry;
3045
3129
  const start = stringOr(e["startRef"]) ?? stringOr(e["startId"]) ?? stringOr(e["messageId"]);
3046
3130
  const end = stringOr(e["endRef"]) ?? stringOr(e["endId"]) ?? stringOr(e["messageId"]);
3047
- if (start === void 0 || end === void 0) return null;
3131
+ if (start === void 0 || end === void 0) {
3132
+ return { reason: "missing range bounds (need startRef/startId and endRef/endId)" };
3133
+ }
3048
3134
  const summary = stringOr(e["summary"]);
3049
- if (summary === void 0) return null;
3135
+ if (summary === void 0) return { reason: "missing summary" };
3050
3136
  const range = { startRef: start, endRef: end, summary };
3051
3137
  const topic = stringOr(e["topic"]);
3052
3138
  if (topic !== void 0) range.topic = topic;
3053
3139
  const maxChars = e["summaryMaxChars"];
3054
3140
  if (typeof maxChars === "number" && Number.isFinite(maxChars)) range.summaryMaxChars = maxChars;
3055
3141
  if (callId !== void 0) range.compressCallId = callId;
3056
- return range;
3142
+ return { range };
3057
3143
  }
3058
3144
  function stringOr(value) {
3059
3145
  return typeof value === "string" && value.length > 0 ? value : void 0;
@@ -4670,7 +4756,12 @@ function createRuntime(adapter) {
4670
4756
  const sm = ctx.sessionManager;
4671
4757
  await store.save(state, sm.getSessionFile() ?? void 0, sm.getSessionId());
4672
4758
  }
4673
- return { core, store, get adapter() {
4759
+ let refused = false;
4760
+ return { core, store, get refused() {
4761
+ return refused;
4762
+ }, set refused(v) {
4763
+ refused = v;
4764
+ }, get adapter() {
4674
4765
  return adapterRef;
4675
4766
  }, setAdapter: (a) => {
4676
4767
  adapterRef = a;
@@ -9139,6 +9230,19 @@ function getSystemPromptText(ctx) {
9139
9230
  return normalizeSystemPrompt(result);
9140
9231
  }
9141
9232
 
9233
+ // src/omp.ts
9234
+ function isOmpHost(sm) {
9235
+ return !isPiHost(sm);
9236
+ }
9237
+ var OMP_UNSUPPORTED_MESSAGE = [
9238
+ "[billion-context-pi] This host is OMP (oh-my-pi), which is NOT supported \u2014 ACP has been disabled for this session.",
9239
+ `The in-process compression path is unreliable on OMP: the nudge's example refs diverge from the session's real refs, so compress calls fail with "does not exist in this session".`,
9240
+ "Use the billion-context proxy instead \u2014 it runs compression server-side and works on OMP:",
9241
+ " npm install -g billion-context",
9242
+ " bili omp",
9243
+ "Docs: https://github.com/ranxianglei/billion-context"
9244
+ ].join("\n");
9245
+
9142
9246
  // src/compress-tool.ts
9143
9247
  function formatK2(n) {
9144
9248
  return n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
@@ -9177,6 +9281,7 @@ function makeCompressTool(runtime) {
9177
9281
  ],
9178
9282
  parameters: CompressParams,
9179
9283
  async execute(toolCallId, params, _signal, _onUpdate, ctx) {
9284
+ if (runtime.refused) return { details: void 0, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] };
9180
9285
  let result;
9181
9286
  try {
9182
9287
  result = await handleCompress(params, runtime, ctx, toolCallId);
@@ -9358,6 +9463,7 @@ function makeDecompressTool(runtime) {
9358
9463
  ],
9359
9464
  parameters: DecompressParams,
9360
9465
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
9466
+ if (runtime.refused) return { details: void 0, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] };
9361
9467
  let result;
9362
9468
  try {
9363
9469
  result = await handleDecompress(params, runtime, ctx);
@@ -9597,6 +9703,7 @@ function makeSearchTool(runtime) {
9597
9703
  ],
9598
9704
  parameters: SearchParams,
9599
9705
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
9706
+ if (runtime.refused) return { details: void 0, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] };
9600
9707
  let result;
9601
9708
  try {
9602
9709
  result = await handleSearch(params, runtime, ctx);
@@ -10691,7 +10798,7 @@ async function runDelegate(pi, args, ctx, signal) {
10691
10798
  await appendFile(file2, fallback ? `${fallback}
10692
10799
  ` : "(no output)\n");
10693
10800
  }
10694
- const effectiveCode = code ?? (output || stderrText ? 0 : null);
10801
+ const effectiveCode = effectiveExitCode(code, output, stderrText);
10695
10802
  run.result = { code, file: file2, body: body2 };
10696
10803
  run.status = effectiveCode === 0 ? "completed" : "failed";
10697
10804
  run.finishedAt = Date.now();
@@ -10709,7 +10816,7 @@ async function runDelegate(pi, args, ctx, signal) {
10709
10816
  return;
10710
10817
  }
10711
10818
  const mode = delegateDisplayUsage;
10712
- const injected = injectResult(pi, args.agent, runId, args.task, code, file2, run.timedOut, run.usage, mode, run.usageReported, run.status === "failed" ? body2 : void 0);
10819
+ const injected = injectResult(pi, args.agent, runId, args.task, run.status, code, file2, run.timedOut, run.usage, mode, run.usageReported, run.status === "failed" ? body2 : void 0);
10713
10820
  if (run.usage && !run.usageReported && (mode === "separate" || injected)) {
10714
10821
  run.usageReported = true;
10715
10822
  }
@@ -10834,15 +10941,18 @@ function formatSyncResult(agent, runId, task, r, file) {
10834
10941
  const body = r.timedOut ? "(timed out)" : r.stderr.trim() || "(no stderr)";
10835
10942
  return formatPayload(header, file, task, body);
10836
10943
  }
10837
- function injectResult(pi, agent, runId, task, code, file, timedOut, usage, mode = "separate", usageAlreadyReported, body) {
10944
+ function effectiveExitCode(code, output, stderr) {
10945
+ return code ?? (output || stderr ? 0 : null);
10946
+ }
10947
+ function injectResult(pi, agent, runId, task, status, code, file, timedOut, usage, mode = "separate", usageAlreadyReported, body) {
10838
10948
  const send = pi.sendUserMessage;
10839
10949
  if (typeof send !== "function") {
10840
10950
  debug.event("delegate-inject-skipped", { runId, reason: "sendUserMessage unavailable" });
10841
10951
  logWarn("delegate", { event: "inject-skipped", runId, reason: "sendUserMessage unavailable" });
10842
10952
  return false;
10843
10953
  }
10844
- const failed = code !== 0;
10845
- const status = failed ? "FAILED \u26A0\uFE0F" : "completed";
10954
+ const failed = status === "failed";
10955
+ const statusLabel = failed ? "FAILED \u26A0\uFE0F" : "completed";
10846
10956
  const remaining = Array.from(runs.values()).filter((r) => r.status === "running").length;
10847
10957
  const remainingLine = remaining > 0 ? ` ${remaining} delegate${remaining === 1 ? " is" : "s are"} still running; keep doing other work and their notifications will arrive as they finish.` : " No delegates are currently running.";
10848
10958
  const timeoutNote = timedOut ? ` (timed out: ${timedOut})` : "";
@@ -10877,7 +10987,7 @@ Tokens: ${totalUsage.input.toLocaleString()} in, ${totalUsage.output.toLocaleStr
10877
10987
  if (lines.length) usageNote = ` Usage: ${lines.join(", ")}.`;
10878
10988
  }
10879
10989
  const closing = failed ? "This delegate did NOT complete its task \u2014 its result is missing from your work. Read the error excerpt (and the result file if present), then decide whether to re-dispatch the task before wrapping up. This is an automated system notification, NOT a user message." : "This is an automated system notification, NOT a user message. Read the result file if you need the details, then continue your original task; do not treat this as a new user request.";
10880
- const header = `[acp_delegate ${status}] **${agent}** (runId \`${runId}\`, exit ${code ?? "?"})${timeoutNote}${remainingLine}${usageNote} ${closing}`;
10990
+ const header = `[acp_delegate ${statusLabel}] **${agent}** (runId \`${runId}\`, exit ${code ?? "?"})${timeoutNote}${remainingLine}${usageNote} ${closing}`;
10881
10991
  const { text: recoveryText, covered } = buildRecoveryNotice(Array.from(runs.values()), runId);
10882
10992
  const text = formatPayload(header, file, task, failed ? body : void 0) + (recoveryText ? `
10883
10993
 
@@ -10902,6 +11012,7 @@ function notifyTerminalFailure(pi, run, body) {
10902
11012
  run.agent,
10903
11013
  run.runId,
10904
11014
  run.task,
11015
+ run.status,
10905
11016
  run.result?.code ?? null,
10906
11017
  run.result?.file ?? "",
10907
11018
  run.timedOut,
@@ -10976,6 +11087,7 @@ function makeStatusTool(runtime) {
10976
11087
  ],
10977
11088
  parameters: StatusParams,
10978
11089
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
11090
+ if (runtime.refused) return { details: void 0, content: [{ type: "text", text: OMP_UNSUPPORTED_MESSAGE }] };
10979
11091
  let result;
10980
11092
  try {
10981
11093
  result = await handleStatus(params, runtime, ctx);
@@ -11503,7 +11615,7 @@ async function statusReport(runtime, ctx) {
11503
11615
  const coveredIds = collectCoveredMessageIds(state);
11504
11616
  const sentTokens = estimateTokens(coreMessages, coveredIds, imageTokens) + systemPromptTokens;
11505
11617
  const turn = runtime.core.processTurn({ messages: coreMessages, state, config, tokenCount: anchorStale ? sentTokens : Math.max(sentTokens, realUsage?.tokens ?? 0) });
11506
- const versionStr = "0.1.53" ? `billion-context-pi@${"0.1.53"}` : void 0;
11618
+ const versionStr = "0.1.54" ? `billion-context-pi@${"0.1.54"}` : void 0;
11507
11619
  let text = buildStatusPanel({
11508
11620
  version: versionStr,
11509
11621
  tokenCount: sessionTokens,
@@ -11866,7 +11978,7 @@ async function autoInstallLatest(latest, extDirOverride) {
11866
11978
  "--no-fund",
11867
11979
  "--no-save"
11868
11980
  ];
11869
- const prevVersion = (await readPackageJson(join8(extDir, "package.json")))?.version ?? "0.1.53";
11981
+ const prevVersion = (await readPackageJson(join8(extDir, "package.json")))?.version ?? "0.1.54";
11870
11982
  const { code, stderr } = await runNpmImpl(installArgs(latest), { cwd: npmDir, timeout: 6e4 });
11871
11983
  if (code !== 0) {
11872
11984
  logWarn("update", {
@@ -11879,7 +11991,7 @@ async function autoInstallLatest(latest, extDirOverride) {
11879
11991
  }
11880
11992
  const verify = await verifyInstall(npmDir, latest);
11881
11993
  if (!verify.ok) {
11882
- const rollbackTo = SEMVER_RE.test(prevVersion) ? prevVersion : "0.1.53";
11994
+ const rollbackTo = SEMVER_RE.test(prevVersion) ? prevVersion : "0.1.54";
11883
11995
  logWarn("update", { event: "auto-install-verify-failed", latest, reason: verify.reason, rollbackTo });
11884
11996
  const rb = await runNpmImpl(installArgs(rollbackTo), { cwd: npmDir, timeout: 6e4 });
11885
11997
  logInfo("update", { event: "rollback", from: latest, to: rollbackTo, ok: rb.code === 0 });
@@ -11940,7 +12052,7 @@ async function checkForUpdate(autoUpdate, notify) {
11940
12052
  const runtimeVersion = await getRuntimeVersion();
11941
12053
  const latest = await fetchLatestVersion();
11942
12054
  if (!latest) return;
11943
- const current = runtimeVersion ?? "0.1.53";
12055
+ const current = runtimeVersion ?? "0.1.54";
11944
12056
  const hasUpdate = isNewer(latest, current);
11945
12057
  debug.event("update-check", {
11946
12058
  current,
@@ -11986,7 +12098,7 @@ function createAcpExtension(adapter = {}) {
11986
12098
  return;
11987
12099
  }
11988
12100
  const runtime = createRuntime(adapter);
11989
- wireCompactionDisable(pi);
12101
+ wireCompactionDisable(pi, runtime);
11990
12102
  wireSessionLifecycle(pi, runtime);
11991
12103
  wireContextTransform(pi, runtime);
11992
12104
  wireSystemPrompt(pi, runtime);
@@ -12003,11 +12115,26 @@ function createAcpExtension(adapter = {}) {
12003
12115
  };
12004
12116
  }
12005
12117
  var index_default = createAcpExtension();
12006
- function wireCompactionDisable(pi) {
12007
- pi.on("session_before_compact", () => ({ cancel: true }));
12118
+ function wireCompactionDisable(pi, runtime) {
12119
+ pi.on("session_before_compact", () => {
12120
+ if (runtime.refused) return;
12121
+ return { cancel: true };
12122
+ });
12008
12123
  }
12009
12124
  function wireSessionLifecycle(pi, runtime) {
12125
+ let ompWarned = false;
12010
12126
  pi.on("session_start", async (_event, ctx) => {
12127
+ if (isOmpHost(ctx.sessionManager)) {
12128
+ runtime.refused = true;
12129
+ if (!ompWarned) {
12130
+ ompWarned = true;
12131
+ const sid2 = ctx.sessionManager.getSessionId();
12132
+ logWarn("host", { event: "omp-unsupported", sid: sid2, action: "refused" });
12133
+ if (ctx.hasUI) ctx.ui.notify(OMP_UNSUPPORTED_MESSAGE, "warning");
12134
+ else console.error(OMP_UNSUPPORTED_MESSAGE);
12135
+ }
12136
+ return;
12137
+ }
12011
12138
  runtime.store.invalidate();
12012
12139
  runtime.clearNudgeTracking();
12013
12140
  runtime.throttleFor(ctx.sessionManager.getSessionId()).reset();
@@ -12016,7 +12143,7 @@ function wireSessionLifecycle(pi, runtime) {
12016
12143
  setDelegateDisplayUsage("separate");
12017
12144
  const sid = ctx.sessionManager.getSessionId();
12018
12145
  const modelInfo = ctx.model;
12019
- logInfo("session", { event: "start", sid, cwd: ctx.cwd, debug: runtime.adapter.debug ?? null, version: true ? "0.1.53" : null, model: modelInfo?.id ?? null, modelApi: modelInfo?.api ?? null, contextWindow: modelInfo?.contextWindow ?? null });
12146
+ logInfo("session", { event: "start", sid, cwd: ctx.cwd, debug: runtime.adapter.debug ?? null, version: true ? "0.1.54" : null, model: modelInfo?.id ?? null, modelApi: modelInfo?.api ?? null, contextWindow: modelInfo?.contextWindow ?? null });
12020
12147
  try {
12021
12148
  await runtime.reloadConfig(ctx.cwd);
12022
12149
  setDelegateDisplayUsage(resolveDelegate(runtime.adapter).displayUsage);
@@ -12047,6 +12174,7 @@ function wireSessionLifecycle(pi, runtime) {
12047
12174
  }
12048
12175
  function wireContextTransform(pi, runtime) {
12049
12176
  pi.on("context", async (event, ctx) => {
12177
+ if (runtime.refused) return;
12050
12178
  const sid = ctx.sessionManager.getSessionId();
12051
12179
  const release = await runtime.acquireLock(sid);
12052
12180
  try {
@@ -12185,6 +12313,7 @@ ${rendered.text}${example}`);
12185
12313
  }
12186
12314
  function wireSystemPrompt(pi, runtime) {
12187
12315
  pi.on("before_agent_start", (event) => {
12316
+ if (runtime.refused) return;
12188
12317
  const delegate = runtime.adapter.delegate !== false;
12189
12318
  const acp = buildAcpSystemPrompt(runtime.prompts);
12190
12319
  const prompt = delegate ? `${acp}