pi-condense 2.6.0 → 2.8.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/src/budget.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  import type { ContextUsage } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ // Ceiling on what the budget triggers treat as the context window. Advertised
4
+ // windows reach 1M, which makes any (0,1] fraction unreachable in a real session.
5
+ // The two triggers apply it in different shapes on purpose: the threshold is a
6
+ // LEVEL, so the cap bounds the level itself (min(CAP, threshold * window)); the
7
+ // delta is a GROWTH RATE, where a 300k ceiling could never bind, so the cap
8
+ // enters through the denominator instead (delta * min(window, CAP)).
9
+ export const MAX_BUDGET_WINDOW = 300_000;
10
+
3
11
  /**
4
- * True iff a budget-triggered flush should fire. Computes the ratio ourselves
5
- * (tokens / contextWindow, a 0–1 fraction) rather than using ContextUsage.percent
6
- * (a 0–100 value, null when tokens is null). tokens is also null right after a
7
- * compaction — guarded here.
12
+ * True iff a budget-triggered flush should fire: at `threshold` of the model's
13
+ * window, or at MAX_BUDGET_WINDOW tokens, whichever comes first. Computes the
14
+ * level ourselves rather than using ContextUsage.percent (a 0–100 value, null
15
+ * when tokens is null). tokens is also null right after a compaction — guarded here.
8
16
  */
9
17
  export function shouldBudgetFlush(
10
18
  usage: ContextUsage | undefined,
@@ -12,17 +20,23 @@ export function shouldBudgetFlush(
12
20
  ): boolean {
13
21
  if (threshold == null || threshold <= 0 || threshold > 1) return false;
14
22
  if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return false;
15
- return usage.tokens / usage.contextWindow >= threshold;
23
+ return usage.tokens >= Math.min(MAX_BUDGET_WINDOW, threshold * usage.contextWindow);
16
24
  }
17
25
 
18
- /** 0–1 usage fraction, or null when usage is missing / tokens null / window non-positive. */
26
+ /**
27
+ * Usage fraction against the effective window (min(contextWindow, MAX_BUDGET_WINDOW)),
28
+ * or null when usage is missing / tokens null / window non-positive. NOT bounded by 1:
29
+ * 600k tokens on a 1M window returns 2.0. Deliberately unclamped — clamping would make
30
+ * shouldDeltaFlush saturate above the ceiling and stop re-arming.
31
+ */
19
32
  export function usageFraction(usage: ContextUsage | undefined): number | null {
20
33
  if (!usage || usage.tokens == null || !(usage.contextWindow > 0)) return null;
21
- return usage.tokens / usage.contextWindow;
34
+ return usage.tokens / Math.min(usage.contextWindow, MAX_BUDGET_WINDOW);
22
35
  }
23
36
 
24
37
  /**
25
- * True iff this turn's usage fraction rose by at least `delta` versus the previous turn.
38
+ * True iff this turn's usage fraction rose by at least `delta` versus the previous turn,
39
+ * i.e. growth of at least `delta * min(contextWindow, MAX_BUDGET_WINDOW)` tokens.
26
40
  * Mirrors shouldBudgetFlush's guards. previousFraction === null (first turn or post-restart)
27
41
  * never fires; the absolute autoBudgetThreshold covers that gap.
28
42
  */
@@ -1,6 +1,7 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { pruneStatusText, setPruneStatusWidget } from "./commands.js";
3
- import type { ContextPruneConfig } from "./types.js";
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { pruneStatusText, setPruneStatusWidget, registerCommands } from "./commands.js";
3
+ import type { ContextPruneConfig, ContextMetricsSnapshot, SummarizerStats } from "./types.js";
4
+ import { DEFAULT_CONFIG } from "./types.js";
4
5
 
5
6
  const cfg = (enabled: boolean): ContextPruneConfig => ({ enabled } as ContextPruneConfig);
6
7
  const cfgVisible = (enabled: boolean): ContextPruneConfig =>
@@ -10,12 +11,119 @@ function captureStatus(
10
11
  config: ContextPruneConfig,
11
12
  value?: Parameters<typeof setPruneStatusWidget>[2],
12
13
  diagnostics?: Parameters<typeof setPruneStatusWidget>[3],
14
+ metrics?: Parameters<typeof setPruneStatusWidget>[4],
13
15
  ): string | undefined {
14
16
  let captured: string | undefined;
15
- setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics);
17
+ setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics, metrics);
16
18
  return captured;
17
19
  }
18
20
 
21
+ // ── /pruner command handler harness (registerCommands) ──────────────────────
22
+ // Drives the real switch-statement handler registered by registerCommands,
23
+ // with all injected collaborators stubbed. This is the seam for exercising
24
+ // /pruner subcommands without booting the full index.ts extension.
25
+ function setupPrunerCommand(overrides: {
26
+ capturePendingBatches?: () => any[];
27
+ flushPending?: (ctx: any, options?: any) => Promise<any>;
28
+ getRearmed?: () => boolean;
29
+ getContextMetrics?: (ctx: any) => ContextMetricsSnapshot;
30
+ } = {}) {
31
+ let handler: (args: string, ctx: any) => Promise<void>;
32
+ const notifications: { message: string; type?: string }[] = [];
33
+ const flushCalls: any[] = [];
34
+
35
+ const flushPending =
36
+ overrides.flushPending ??
37
+ (async (_ctx: any, options?: any) => {
38
+ flushCalls.push(options);
39
+ return { ok: false, reason: "empty" };
40
+ });
41
+
42
+ const pi: any = {
43
+ registerCommand(_name: string, spec: { handler: (args: string, ctx: any) => Promise<void> }) {
44
+ handler = spec.handler;
45
+ },
46
+ registerMessageRenderer() {},
47
+ };
48
+
49
+ const currentConfig = { value: { ...DEFAULT_CONFIG, enabled: true } };
50
+
51
+ registerCommands(
52
+ pi,
53
+ currentConfig,
54
+ flushPending,
55
+ overrides.capturePendingBatches ?? (() => []),
56
+ () => ({ callCount: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 } as SummarizerStats),
57
+ () => undefined,
58
+ {} as any,
59
+ async () => ({ compressedEntries: [], skipped: 0 }),
60
+ undefined,
61
+ overrides.getContextMetrics,
62
+ undefined,
63
+ overrides.getRearmed,
64
+ );
65
+
66
+ const ctx: any = {
67
+ ui: {
68
+ notify(message: string, type?: string) {
69
+ notifications.push({ message, type });
70
+ },
71
+ },
72
+ };
73
+
74
+ return {
75
+ run: (args: string) => handler(args, ctx),
76
+ notifications,
77
+ flushCalls,
78
+ };
79
+ }
80
+
81
+ describe("/pruner now (empty capture)", () => {
82
+ it("invokes flushPending even when nothing is pending, so a flush-metrics entry is recorded", async () => {
83
+ const harness = setupPrunerCommand({ capturePendingBatches: () => [] });
84
+ await harness.run("now");
85
+
86
+ expect(harness.flushCalls.length).toBe(1);
87
+ expect(harness.flushCalls[0]).toMatchObject({ previewedBatches: [], trigger: "manual" });
88
+ expect(harness.notifications[0]?.message).toBe("pruner: nothing pending — no batches to summarize");
89
+ });
90
+ });
91
+
92
+ describe("/pruner status context block", () => {
93
+ const metrics: ContextMetricsSnapshot = {
94
+ openCycleThinkingTokens: 12000,
95
+ largestChainSharePct: 62,
96
+ frontierGapTokens: 195000,
97
+ };
98
+
99
+ it("renders the --- context --- block with all three metrics", async () => {
100
+ const harness = setupPrunerCommand({ getContextMetrics: () => metrics, getRearmed: () => false });
101
+ await harness.run("status");
102
+
103
+ const text = harness.notifications[0]?.message ?? "";
104
+ expect(text).toContain("--- context ---");
105
+ expect(text).toContain("thinking: 12.0k tokens (open segment)");
106
+ expect(text).toContain("chain share: 62%");
107
+ expect(text).toContain("frontier gap: 195.0k tokens");
108
+ });
109
+
110
+ it("appends the rearmed: line only when getRearmed() is true", async () => {
111
+ const armed = setupPrunerCommand({ getContextMetrics: () => metrics, getRearmed: () => true });
112
+ await armed.run("status");
113
+ expect(armed.notifications[0]?.message).toContain("rearmed: yes");
114
+
115
+ const notArmed = setupPrunerCommand({ getContextMetrics: () => metrics, getRearmed: () => false });
116
+ await notArmed.run("status");
117
+ expect(notArmed.notifications[0]?.message).not.toContain("rearmed:");
118
+ });
119
+
120
+ it("omits the context block entirely when getContextMetrics is unwired", async () => {
121
+ const harness = setupPrunerCommand();
122
+ await harness.run("status");
123
+ expect(harness.notifications[0]?.message).not.toContain("--- context ---");
124
+ });
125
+ });
126
+
19
127
  describe("pruneStatusText", () => {
20
128
  it("disabled config -> 'prune: OFF'", () => {
21
129
  expect(pruneStatusText(cfg(false))).toBe("prune: OFF");
@@ -94,3 +202,29 @@ describe("diagnostic counters on the status line", () => {
94
202
  );
95
203
  });
96
204
  });
205
+
206
+ describe("context metrics suffix on the status line", () => {
207
+ const metrics: ContextMetricsSnapshot = {
208
+ openCycleThinkingTokens: 12000,
209
+ largestChainSharePct: 62,
210
+ frontierGapTokens: 195000,
211
+ };
212
+
213
+ it("appends a compact think/gap/chain segment when frontierGapTokens > 0", () => {
214
+ const text = pruneStatusText(cfg(true), undefined, undefined, metrics);
215
+ expect(text).toContain("\u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
216
+ });
217
+
218
+ it("omits the suffix when frontierGapTokens is 0", () => {
219
+ const withZeroGap = { ...metrics, frontierGapTokens: 0 };
220
+ expect(pruneStatusText(cfg(true), undefined, undefined, withZeroGap)).toBe(
221
+ pruneStatusText(cfg(true)),
222
+ );
223
+ });
224
+
225
+ it("composes after the diag suffix when both are present", () => {
226
+ const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const;
227
+ const text = pruneStatusText(cfg(true), undefined, mixedDiag, metrics);
228
+ expect(text).toBe("prune: ON \u00b7 diag u2/o1 \u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
229
+ });
230
+ });
package/src/commands.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  type ChainCompressionEntry,
7
7
  type FlushOptions,
8
8
  type DiagnosticKind,
9
+ type ContextMetricsSnapshot,
9
10
  PRUNE_ON_MODES,
10
11
  BATCHING_MODES,
11
12
  STATUS_WIDGET_ID,
@@ -23,6 +24,7 @@ import {
23
24
  } from "./types.js";
24
25
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
25
26
  import { saveConfig } from "./config.js";
27
+ import { MAX_BUDGET_WINDOW } from "./budget.js";
26
28
  import { formatTokens, formatCost, formatCharProgress, formatCompactCount } from "./stats.js";
27
29
  import { Container, Text, SettingsList, type SettingItem } from "@earendil-works/pi-tui";
28
30
  import { DynamicBorder, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
@@ -62,6 +64,7 @@ export function pruneStatusText(
62
64
  config: ContextPruneConfig,
63
65
  reclaim?: LiveReclaim,
64
66
  diagnostics?: Record<DiagnosticKind, number>,
67
+ metrics?: ContextMetricsSnapshot,
65
68
  ): string {
66
69
  if (!config.enabled) return "prune: OFF";
67
70
  const diag = diagnostics
@@ -72,11 +75,14 @@ export function pruneStatusText(
72
75
  ].filter(Boolean)
73
76
  : [];
74
77
  const suffix = diag.length > 0 ? ` \u00b7 diag ${diag.join("/")}` : "";
75
- if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}`;
78
+ const metricsSuffix = metrics && metrics.frontierGapTokens > 0
79
+ ? ` \u00b7 think ${formatCompactCount(metrics.openCycleThinkingTokens)} \u00b7 gap ${formatCompactCount(metrics.frontierGapTokens)} \u00b7 chain ${metrics.largestChainSharePct}%`
80
+ : "";
81
+ if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}${metricsSuffix}`;
76
82
  const beforeTok = Math.round(reclaim.beforeChars / 4);
77
83
  const afterTok = Math.round(reclaim.afterChars / 4);
78
84
  const reduction = Math.max(0, Math.round((1 - afterTok / beforeTok) * 100));
79
- return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}`;
85
+ return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}${metricsSuffix}`;
80
86
  }
81
87
 
82
88
  export function setPruneStatusWidget(
@@ -84,12 +90,13 @@ export function setPruneStatusWidget(
84
90
  config: ContextPruneConfig,
85
91
  value?: LiveReclaim | string,
86
92
  diagnostics?: Record<DiagnosticKind, number>,
93
+ metrics?: ContextMetricsSnapshot,
87
94
  ): void {
88
95
  if (!config.showPruneStatusLine) {
89
96
  ctx.ui.setStatus(STATUS_WIDGET_ID, undefined);
90
97
  return;
91
98
  }
92
- const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics);
99
+ const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics, metrics);
93
100
  // Leading-only separator: the footer joins extension status segments with a
94
101
  // single space, so a trailing divider collides with the next segment's leading
95
102
  // one and renders doubled. One leading bar yields single dividers between
@@ -228,10 +235,12 @@ function maxTimeoutDescription(config: ContextPruneConfig): string {
228
235
  }
229
236
 
230
237
  function autoBudgetThresholdDescription(config: ContextPruneConfig): string {
238
+ const cap = `${MAX_BUDGET_WINDOW / 1000}k`;
231
239
  if (config.autoBudgetThreshold == null) {
232
- return `Token-budget auto-flush: force a prune when context usage reaches this share of the window, regardless of prune-on mode. Currently off. Pick a percentage to enable.`;
240
+ return `Token-budget auto-flush: force a prune when context usage reaches this share of the window (or ${cap} tokens, whichever comes first), regardless of prune-on mode. Currently off. Pick a percentage to enable.`;
233
241
  }
234
- return `Token-budget auto-flush: force a prune when context usage reaches ${Math.round(config.autoBudgetThreshold * 100)}% of the window, regardless of prune-on mode. Set to Off to disable.`;
242
+ const pct = Math.round(config.autoBudgetThreshold * 100);
243
+ return `Token-budget auto-flush: force a prune when context usage reaches ${pct}% of the window or ${cap} tokens, whichever comes first, regardless of prune-on mode. The ${cap} ceiling keeps this reachable on huge-window models. Set to Off to disable.`;
235
244
  }
236
245
 
237
246
  function protectedToolsDisplay(list: string[]): string {
@@ -470,6 +479,9 @@ export function registerCommands(
470
479
  indexer: ToolCallIndexer,
471
480
  compactChains: (ctx: ExtensionCommandContext) => Promise<{ compressedEntries: ChainCompressionEntry[]; skipped: number }>,
472
481
  getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
482
+ getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot,
483
+ getCachedMetrics?: () => ContextMetricsSnapshot | undefined,
484
+ getRearmed?: () => boolean,
473
485
  ): void {
474
486
  // Register the /pruner command
475
487
  pi.registerCommand("pruner", {
@@ -816,7 +828,7 @@ export function registerCommands(
816
828
  }
817
829
  currentConfig.value = newConfig;
818
830
  saveConfig(newConfig);
819
- setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.());
831
+ setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
820
832
  settingsList?.invalidate();
821
833
  };
822
834
 
@@ -851,7 +863,7 @@ export function registerCommands(
851
863
  currentConfig.value = { ...currentConfig.value, enabled: true };
852
864
  saveConfig(currentConfig.value);
853
865
  ctx.ui.notify("Context pruning enabled.");
854
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
866
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
855
867
  break;
856
868
  }
857
869
 
@@ -860,7 +872,7 @@ export function registerCommands(
860
872
  currentConfig.value = { ...currentConfig.value, enabled: false };
861
873
  saveConfig(currentConfig.value);
862
874
  ctx.ui.notify("Context pruning disabled.");
863
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
875
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
864
876
  break;
865
877
  }
866
878
 
@@ -873,8 +885,12 @@ export function registerCommands(
873
885
  ? `\n --- summarizer ---\n calls: ${s.callCount}\n input: ${formatTokens(s.totalInputTokens)} tokens\n output: ${formatTokens(s.totalOutputTokens)} tokens\n cost: ${formatCost(s.totalCost)}`
874
886
  : "\n (no summarizer calls yet)";
875
887
  const fmtTimeout = (ms: number) => (ms === 0 ? "disabled" : `${Math.round(ms / 1000)}s`);
888
+ const m = getContextMetrics?.(ctx);
889
+ const contextLine = m
890
+ ? `\n --- context ---\n thinking: ${formatTokens(m.openCycleThinkingTokens)} tokens (open segment)\n chain share: ${m.largestChainSharePct}%\n frontier gap: ${formatTokens(m.frontierGapTokens)} tokens${getRearmed?.() ? "\n rearmed: yes" : ""}`
891
+ : "";
876
892
  ctx.ui.notify(
877
- `pruner status:\n enabled: ${cfg.enabled}\n model: ${cfg.summarizerModel}\n thinking: ${summarizerThinkingLabel(cfg.summarizerThinking)} (${cfg.summarizerThinking})\n idle to: ${fmtTimeout(cfg.summarizerIdleTimeoutMs)}\n max to: ${fmtTimeout(cfg.summarizerMaxTimeoutMs)}\n trigger: ${mode}\n batching: ${batchingModeLabel(cfg.batchingMode)} (${cfg.batchingMode})\n dedup: ${cfg.dedupByContentHash ? "on" : "off"}\n status: ${cfg.showPruneStatusLine ? "on" : "off"}${statsLine}`,
893
+ `pruner status:\n enabled: ${cfg.enabled}\n model: ${cfg.summarizerModel}\n thinking: ${summarizerThinkingLabel(cfg.summarizerThinking)} (${cfg.summarizerThinking})\n idle to: ${fmtTimeout(cfg.summarizerIdleTimeoutMs)}\n max to: ${fmtTimeout(cfg.summarizerMaxTimeoutMs)}\n trigger: ${mode}\n batching: ${batchingModeLabel(cfg.batchingMode)} (${cfg.batchingMode})\n dedup: ${cfg.dedupByContentHash ? "on" : "off"}\n status: ${cfg.showPruneStatusLine ? "on" : "off"}${statsLine}${contextLine}`,
878
894
  );
879
895
  break;
880
896
  }
@@ -979,7 +995,7 @@ export function registerCommands(
979
995
  currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
980
996
  }
981
997
  saveConfig(currentConfig.value);
982
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
998
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
983
999
  break;
984
1000
  }
985
1001
 
@@ -1052,6 +1068,11 @@ export function registerCommands(
1052
1068
  const batches = capturePendingBatches(ctx);
1053
1069
  if (batches.length === 0) {
1054
1070
  ctx.ui.notify("pruner: nothing pending — no batches to summarize", "info");
1071
+ // Still invoke flushPending so its finally-emitted flush-metrics entry
1072
+ // records this attempt (outcome "empty") — the incident's exact
1073
+ // undiagnosable "nothing pending" report is precisely what this log
1074
+ // exists to make diagnosable on recurrence.
1075
+ await flushPending(ctx, { previewedBatches: batches, trigger: "manual" });
1055
1076
  break;
1056
1077
  }
1057
1078
 
@@ -1076,7 +1097,7 @@ export function registerCommands(
1076
1097
 
1077
1098
  // Remove the widget and restore the normal footer status.
1078
1099
  clearWidget();
1079
- setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
1100
+ setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.(), getCachedMetrics?.());
1080
1101
 
1081
1102
  if (!result.ok) {
1082
1103
  const suffix = "error" in result && result.error ? ` (${result.error})` : "";