pi-condense 2.9.1 → 2.9.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.
- package/CHANGELOG.md +4 -0
- package/PRUNING.md +1 -1
- package/README.md +8 -3
- package/index.ts +8 -23
- package/package.json +1 -1
- package/src/commands.test.ts +1 -28
- package/src/commands.ts +8 -14
- package/src/reload-rearm.integration.test.ts +13 -80
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,10 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
|
|
|
7
7
|
Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
|
|
8
8
|
publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
|
|
9
9
|
|
|
10
|
+
## [2.9.2] - 2026-08-31
|
|
11
|
+
|
|
12
|
+
- **Context metrics removed from the footer status line.** The ` · think Nk · gap Nk · chain P%` suffix rendered on nearly every non-idle session and crowded the footer for no actionable signal. The metrics stay on `/pruner status` (`--- context ---`) and in the `context-prune-flush-metrics` session entries; the footer is back to prune state, reclaim, and `diag`. The snapshot cache that existed only to feed the widget is gone - both remaining consumers compute on demand.
|
|
13
|
+
|
|
10
14
|
## [2.9.1] - 2026-08-18
|
|
11
15
|
|
|
12
16
|
- **Orphan sweep: any foreign message is now a barrier ([#11](https://github.com/jjuraszek/pi-condense/issues/11)).** `sweepOrphanToolResults` only reset its open-call set on `assistant` messages, while pi-ai flushes synthetic tool results at both `assistant` and `user` boundaries (`convertToLlm` maps `custom`/`branchSummary`/`compactionSummary`/`bashExecution` to `user`). A foreign message spliced between a toolCall and its toolResult (observed: a pi-cohort control notice) let the real result through alongside pi-ai's synthetic - a duplicate `tool_use_id` the provider rejects permanently (Anthropic 400, branch bricked). Now any message that is neither `assistant` nor `toolResult` clears the open set, so the interleaved result is swept and pi-ai's repairable synthetic stands alone; already-broken branches un-brick on the next render. Deliberate conservative over-sweep (unknown roles, `excludeFromContext` bashExecution) - no role allowlist. Test helper `expectNoOrphanToolResults` carries the identical rule. Spec: `doc/specs/2026-08-18-gh-11-orphan-sweep-barrier.md` (partially supersedes the 2026-08-12 spec's section C).
|
package/PRUNING.md
CHANGED
|
@@ -995,7 +995,7 @@ Phase 1 (per-batch summarization) is unaffected by chain closure and remains the
|
|
|
995
995
|
|
|
996
996
|
Neither knob makes a chain close; they just keep Phase 1 flushing on schedule so raw toolResults do not pile up unsummarized for the whole run.
|
|
997
997
|
|
|
998
|
-
**Observability metrics** (`src/context-metrics.ts`, `computeContextMetrics`) exist precisely to make this shape of session visible instead of silently reporting `calls: 1` the way the triggering incident did. All three are chars/4 token estimates (`Math.round`, same convention as the reclaim footer) and surface on `/pruner status` (a `--- context ---` block)
|
|
998
|
+
**Observability metrics** (`src/context-metrics.ts`, `computeContextMetrics`) exist precisely to make this shape of session visible instead of silently reporting `calls: 1` the way the triggering incident did. All three are chars/4 token estimates (`Math.round`, same convention as the reclaim footer) and surface on `/pruner status` (a `--- context ---` block) and a `context-prune-flush-metrics` session entry written once per flush attempt regardless of outcome:
|
|
999
999
|
|
|
1000
1000
|
| Metric | Definition |
|
|
1001
1001
|
|---|---|
|
package/README.md
CHANGED
|
@@ -69,6 +69,8 @@ pi install npm:pi-condense
|
|
|
69
69
|
| `agent-message` (default) | When the agent sends a final text-only reply | ~1 cache rewrite per task batch |
|
|
70
70
|
| `on-demand` | Only when you run `/pruner now` | None until you ask |
|
|
71
71
|
|
|
72
|
+
With the default `agent-message` trigger (and `autoBudgetThreshold`/`budgetTurnDelta` unset), a non-interactive (`pi -p`) session sees its first flush only at the final reply - set `autoBudgetThreshold` (e.g. `0.8`) so flushes also fire mid-run. This is a property of single-prompt sessions, not a defect in the default.
|
|
73
|
+
|
|
72
74
|
Before any summarizer call, a pre-flush pipeline can drop or redirect a batch at zero LLM cost: protected tools/paths are never touched, content-hash duplicates are aliased to the original, batches too small to be worth summarizing are skipped outright, and oversized single results are spilled straight to a sidecar file. Closed tool-call chains older than a rolling window are additionally range-compressed. Full pipeline and each safeguard: [PRUNING.md § Pre-flush Pipeline & Safeguards](PRUNING.md#pre-flush-pipeline--safeguards), [§ Chain Compression](PRUNING.md#chain-compression).
|
|
73
75
|
|
|
74
76
|
### External cost channel
|
|
@@ -84,7 +86,7 @@ Every summarizer cost update is emitted on the shared `pi.events` channel `cost:
|
|
|
84
86
|
| Batch vs chain | A batch is one flush's worth of tool calls; a chain is a longer closed sequence eligible for range compression |
|
|
85
87
|
| Prune frontier | The last attempted prune boundary - advances even on a skip, so nothing is reconsidered twice |
|
|
86
88
|
| Diagnostics (`diag u/m/o/b`) | A self-hiding status-line segment surfacing prune-time degradations: `u` = unresolved chain range, `m` = detection/render id mismatch (informational, does not change what's dropped), `o` = orphan tool-result sweep, `b` = a zero-coverage chain with nothing left to backfill (genuine span mismatch, see below). Each letter's count is omitted when zero; the whole segment disappears when all four are zero. Backing session entries are `context-prune-diagnostic` - see below |
|
|
87
|
-
| Context metrics (`
|
|
89
|
+
| Context metrics (`thinking`/`chain share`/`frontier gap`) | Open-cycle thinking tokens, largest-chain share, frontier gap - what the pruner cannot (yet) reclaim, notably in single-chain sessions. Shown on `/pruner status`, never on the footer. See below and [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) |
|
|
88
90
|
| Prompt-cache interaction | Why batching (not per-turn pruning) is the default - see [PRUNING.md](PRUNING.md#how-prefix-caching-works) |
|
|
89
91
|
| `cost:external` | The shared cost-reporting channel pi-condense emits on (see above) |
|
|
90
92
|
|
|
@@ -100,10 +102,9 @@ The status-line `diag u<N>/m<N>/o<N>/b<N>` segment above is backed by `context-p
|
|
|
100
102
|
|
|
101
103
|
### Context metrics (`context-prune-flush-metrics`)
|
|
102
104
|
|
|
103
|
-
Three metrics the pruner cannot yet reclaim - open-cycle thinking tokens, largest-chain share (%), frontier gap tokens - surface in
|
|
105
|
+
Three metrics the pruner cannot yet reclaim - open-cycle thinking tokens, largest-chain share (%), frontier gap tokens - surface in two places, both backed by `computeContextMetrics` (`src/context-metrics.ts`). They are deliberately kept off the footer status line, which stays limited to prune state, reclaim, and diagnostics:
|
|
104
106
|
|
|
105
107
|
- `/pruner status` prints a `--- context ---` block: `thinking:`, `chain share:`, `frontier gap:`, plus a `rearmed: yes` line while a reload-rearm probe (below) has recoverable work armed.
|
|
106
|
-
- The footer status line appends `· think Nk · gap Nk · chain P%` - only when the frontier gap is non-zero, so an idle session's footer is unchanged.
|
|
107
108
|
- Each flush attempt (every outcome, including empty/error) writes one `context-prune-flush-metrics` session entry with the pre-flush snapshot - session-log-only, never added to what the model sees, and not reconstructed on reload.
|
|
108
109
|
|
|
109
110
|
These are most informative for long single-chain sessions where Phase 3 (chain compression) never gets a closed chain to act on - see [PRUNING.md § Single-chain sessions](PRUNING.md#single-chain-sessions) for the limitation and config guidance, and [PRUNING.md § Reload rearm](PRUNING.md#reload-rearm) for how a reload with recoverable pending work re-arms the automatic flush trigger.
|
|
@@ -180,6 +181,10 @@ pi-condense is the context-economy layer: it has no code dependency on the other
|
|
|
180
181
|
|
|
181
182
|
No committed roadmap beyond what's already tracked in [CHANGELOG.md](CHANGELOG.md); proposals and in-progress work show up there and in repo issues first.
|
|
182
183
|
|
|
184
|
+
## Contributing
|
|
185
|
+
|
|
186
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) - issues follow a Context / Problem / Idea / Acceptance Criteria template; PRs run the [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) workflow (one-liners exempt from ceremony, never from keeping docs truthful).
|
|
187
|
+
|
|
183
188
|
## Support
|
|
184
189
|
|
|
185
190
|
If this saves you tokens, [buy me a coffee](https://buymeacoffee.com/jjurasszek).
|
package/index.ts
CHANGED
|
@@ -93,10 +93,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
93
93
|
// Cleared on every non-concurrent flushPending invocation.
|
|
94
94
|
let rearmedPending = false;
|
|
95
95
|
|
|
96
|
-
// Latest ContextMetricsSnapshot, recomputed at reload probes, batch capture,
|
|
97
|
-
// and flush entry. Cached (rather than recomputed on every widget refresh)
|
|
98
|
-
// because computeContextMetrics walks the full branch.
|
|
99
|
-
let metricsCache: ContextMetricsSnapshot | undefined;
|
|
100
96
|
const computeMetricsSnapshot = (ctx: any): ContextMetricsSnapshot | undefined => {
|
|
101
97
|
try {
|
|
102
98
|
// Includes persisted custom_message entries (e.g. this extension's own
|
|
@@ -114,7 +110,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
114
110
|
? { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() }
|
|
115
111
|
: e.message,
|
|
116
112
|
);
|
|
117
|
-
|
|
113
|
+
return computeContextMetrics(
|
|
118
114
|
branch,
|
|
119
115
|
frontier.get(),
|
|
120
116
|
(k: string) => indexer.isSummarized(k),
|
|
@@ -122,8 +118,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
122
118
|
);
|
|
123
119
|
} catch (err) {
|
|
124
120
|
console.error("pi-condense: context metrics computation failed", err);
|
|
121
|
+
return undefined;
|
|
125
122
|
}
|
|
126
|
-
return metricsCache;
|
|
127
123
|
};
|
|
128
124
|
|
|
129
125
|
type FlushResult =
|
|
@@ -566,7 +562,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
566
562
|
|
|
567
563
|
if (processedBatches.length === 0) {
|
|
568
564
|
// Nothing was persisted (all calls failed or first call failed)
|
|
569
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
565
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
570
566
|
outcome = "error";
|
|
571
567
|
return { ok: false, reason: "summarizer-failed" };
|
|
572
568
|
}
|
|
@@ -644,7 +640,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
644
640
|
return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
|
|
645
641
|
}
|
|
646
642
|
|
|
647
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
643
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
648
644
|
emitExternalCost(pi, statsAccum);
|
|
649
645
|
|
|
650
646
|
// Chain compression — compress closed chains beyond the rolling window.
|
|
@@ -771,7 +767,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
771
767
|
// When the abort signal fired, summarizeBatch rethrows rather than
|
|
772
768
|
// swallowing the error. Don't show a UI error — the user intended this.
|
|
773
769
|
if (options.signal?.aborted) {
|
|
774
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
770
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
775
771
|
return { ok: false, reason: "aborted" };
|
|
776
772
|
}
|
|
777
773
|
if (isStaleContextError(err)) {
|
|
@@ -816,10 +812,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
816
812
|
}
|
|
817
813
|
}
|
|
818
814
|
|
|
819
|
-
computeMetricsSnapshot(ctx);
|
|
820
|
-
|
|
821
815
|
// Update footer status
|
|
822
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
816
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
823
817
|
|
|
824
818
|
ctx.ui.setWidget(
|
|
825
819
|
"pruner-boot",
|
|
@@ -856,8 +850,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
856
850
|
}
|
|
857
851
|
}
|
|
858
852
|
|
|
859
|
-
|
|
860
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
|
|
853
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
861
854
|
});
|
|
862
855
|
|
|
863
856
|
// ── turn_end: capture batch, flush immediately or queue ──────────────────
|
|
@@ -934,13 +927,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
934
927
|
}
|
|
935
928
|
}
|
|
936
929
|
|
|
937
|
-
// Recompute regardless of whether trim produced a batch: a turn whose
|
|
938
|
-
// toolResults are all protected/spilled/summarized/trimmed-empty still
|
|
939
|
-
// changes the branch (thinking, open-segment size), so the cache must not
|
|
940
|
-
// go stale on it. Placed before the pushedBatch/rearmedPending early
|
|
941
|
-
// return below — a cache write is not gate evaluation.
|
|
942
|
-
if (hasToolResults) computeMetricsSnapshot(ctx);
|
|
943
|
-
|
|
944
930
|
// Mirrors main's `if (!batch) return;`: no freshly pushed batch this turn
|
|
945
931
|
// means no gate evaluation, regardless of leftover pendingBatches from an
|
|
946
932
|
// earlier turn — UNLESS a reload probe armed rearmedPending, in which case
|
|
@@ -1026,7 +1012,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1026
1012
|
changed = true;
|
|
1027
1013
|
statsAccum.setLiveReclaim(result.beforeChars, result.afterChars);
|
|
1028
1014
|
}
|
|
1029
|
-
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts()
|
|
1015
|
+
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
1030
1016
|
|
|
1031
1017
|
if (!changed) return undefined;
|
|
1032
1018
|
return { messages };
|
|
@@ -1082,7 +1068,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1082
1068
|
compactChains,
|
|
1083
1069
|
() => diagnostics.counts(),
|
|
1084
1070
|
(ctx: any) => computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT,
|
|
1085
|
-
() => metricsCache,
|
|
1086
1071
|
() => rearmedPending,
|
|
1087
1072
|
);
|
|
1088
1073
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.2",
|
|
4
4
|
"description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|
package/src/commands.test.ts
CHANGED
|
@@ -11,10 +11,9 @@ function captureStatus(
|
|
|
11
11
|
config: ContextPruneConfig,
|
|
12
12
|
value?: Parameters<typeof setPruneStatusWidget>[2],
|
|
13
13
|
diagnostics?: Parameters<typeof setPruneStatusWidget>[3],
|
|
14
|
-
metrics?: Parameters<typeof setPruneStatusWidget>[4],
|
|
15
14
|
): string | undefined {
|
|
16
15
|
let captured: string | undefined;
|
|
17
|
-
setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics
|
|
16
|
+
setPruneStatusWidget({ ui: { setStatus: (_id, text) => { captured = text; } } }, config, value, diagnostics);
|
|
18
17
|
return captured;
|
|
19
18
|
}
|
|
20
19
|
|
|
@@ -59,7 +58,6 @@ function setupPrunerCommand(overrides: {
|
|
|
59
58
|
async () => ({ compressedEntries: [], skipped: 0 }),
|
|
60
59
|
undefined,
|
|
61
60
|
overrides.getContextMetrics,
|
|
62
|
-
undefined,
|
|
63
61
|
overrides.getRearmed,
|
|
64
62
|
);
|
|
65
63
|
|
|
@@ -213,28 +211,3 @@ describe("diagnostic counters on the status line", () => {
|
|
|
213
211
|
});
|
|
214
212
|
});
|
|
215
213
|
|
|
216
|
-
describe("context metrics suffix on the status line", () => {
|
|
217
|
-
const metrics: ContextMetricsSnapshot = {
|
|
218
|
-
openCycleThinkingTokens: 12000,
|
|
219
|
-
largestChainSharePct: 62,
|
|
220
|
-
frontierGapTokens: 195000,
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
it("appends a compact think/gap/chain segment when frontierGapTokens > 0", () => {
|
|
224
|
-
const text = pruneStatusText(cfg(true), undefined, undefined, metrics);
|
|
225
|
-
expect(text).toContain("\u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("omits the suffix when frontierGapTokens is 0", () => {
|
|
229
|
-
const withZeroGap = { ...metrics, frontierGapTokens: 0 };
|
|
230
|
-
expect(pruneStatusText(cfg(true), undefined, undefined, withZeroGap)).toBe(
|
|
231
|
-
pruneStatusText(cfg(true)),
|
|
232
|
-
);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it("composes after the diag suffix when both are present", () => {
|
|
236
|
-
const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const;
|
|
237
|
-
const text = pruneStatusText(cfg(true), undefined, mixedDiag, metrics);
|
|
238
|
-
expect(text).toBe("prune: ON \u00b7 diag u2/o1 \u00b7 think 12.0k \u00b7 gap 195.0k \u00b7 chain 62%");
|
|
239
|
-
});
|
|
240
|
-
});
|
package/src/commands.ts
CHANGED
|
@@ -64,7 +64,6 @@ export function pruneStatusText(
|
|
|
64
64
|
config: ContextPruneConfig,
|
|
65
65
|
reclaim?: LiveReclaim,
|
|
66
66
|
diagnostics?: Record<DiagnosticKind, number>,
|
|
67
|
-
metrics?: ContextMetricsSnapshot,
|
|
68
67
|
): string {
|
|
69
68
|
if (!config.enabled) return "prune: OFF";
|
|
70
69
|
const diag = diagnostics
|
|
@@ -76,14 +75,11 @@ export function pruneStatusText(
|
|
|
76
75
|
].filter(Boolean)
|
|
77
76
|
: [];
|
|
78
77
|
const suffix = diag.length > 0 ? ` \u00b7 diag ${diag.join("/")}` : "";
|
|
79
|
-
|
|
80
|
-
? ` \u00b7 think ${formatCompactCount(metrics.openCycleThinkingTokens)} \u00b7 gap ${formatCompactCount(metrics.frontierGapTokens)} \u00b7 chain ${metrics.largestChainSharePct}%`
|
|
81
|
-
: "";
|
|
82
|
-
if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}${metricsSuffix}`;
|
|
78
|
+
if (!reclaim || reclaim.beforeChars <= 0) return `prune: ON${suffix}`;
|
|
83
79
|
const beforeTok = Math.round(reclaim.beforeChars / 4);
|
|
84
80
|
const afterTok = Math.round(reclaim.afterChars / 4);
|
|
85
81
|
const reduction = Math.max(0, Math.round((1 - afterTok / beforeTok) * 100));
|
|
86
|
-
return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}
|
|
82
|
+
return `prune: ON \u00b7 ${formatCompactCount(beforeTok)}->${formatCompactCount(afterTok)} (-${reduction}%)${suffix}`;
|
|
87
83
|
}
|
|
88
84
|
|
|
89
85
|
export function setPruneStatusWidget(
|
|
@@ -91,13 +87,12 @@ export function setPruneStatusWidget(
|
|
|
91
87
|
config: ContextPruneConfig,
|
|
92
88
|
value?: LiveReclaim | string,
|
|
93
89
|
diagnostics?: Record<DiagnosticKind, number>,
|
|
94
|
-
metrics?: ContextMetricsSnapshot,
|
|
95
90
|
): void {
|
|
96
91
|
if (!config.showPruneStatusLine) {
|
|
97
92
|
ctx.ui.setStatus(STATUS_WIDGET_ID, undefined);
|
|
98
93
|
return;
|
|
99
94
|
}
|
|
100
|
-
const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics
|
|
95
|
+
const text = typeof value === "string" ? value : pruneStatusText(config, value, diagnostics);
|
|
101
96
|
// Leading-only separator: the footer joins extension status segments with a
|
|
102
97
|
// single space, so a trailing divider collides with the next segment's leading
|
|
103
98
|
// one and renders doubled. One leading bar yields single dividers between
|
|
@@ -481,7 +476,6 @@ export function registerCommands(
|
|
|
481
476
|
compactChains: (ctx: ExtensionCommandContext) => Promise<{ compressedEntries: ChainCompressionEntry[]; skipped: number }>,
|
|
482
477
|
getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
|
|
483
478
|
getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot,
|
|
484
|
-
getCachedMetrics?: () => ContextMetricsSnapshot | undefined,
|
|
485
479
|
getRearmed?: () => boolean,
|
|
486
480
|
): void {
|
|
487
481
|
// Register the /pruner command
|
|
@@ -829,7 +823,7 @@ export function registerCommands(
|
|
|
829
823
|
}
|
|
830
824
|
currentConfig.value = newConfig;
|
|
831
825
|
saveConfig(newConfig);
|
|
832
|
-
setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.()
|
|
826
|
+
setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.());
|
|
833
827
|
settingsList?.invalidate();
|
|
834
828
|
};
|
|
835
829
|
|
|
@@ -864,7 +858,7 @@ export function registerCommands(
|
|
|
864
858
|
currentConfig.value = { ...currentConfig.value, enabled: true };
|
|
865
859
|
saveConfig(currentConfig.value);
|
|
866
860
|
ctx.ui.notify("Context pruning enabled.");
|
|
867
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
861
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
868
862
|
break;
|
|
869
863
|
}
|
|
870
864
|
|
|
@@ -873,7 +867,7 @@ export function registerCommands(
|
|
|
873
867
|
currentConfig.value = { ...currentConfig.value, enabled: false };
|
|
874
868
|
saveConfig(currentConfig.value);
|
|
875
869
|
ctx.ui.notify("Context pruning disabled.");
|
|
876
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
870
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
877
871
|
break;
|
|
878
872
|
}
|
|
879
873
|
|
|
@@ -996,7 +990,7 @@ export function registerCommands(
|
|
|
996
990
|
currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
|
|
997
991
|
}
|
|
998
992
|
saveConfig(currentConfig.value);
|
|
999
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
993
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
1000
994
|
break;
|
|
1001
995
|
}
|
|
1002
996
|
|
|
@@ -1098,7 +1092,7 @@ export function registerCommands(
|
|
|
1098
1092
|
|
|
1099
1093
|
// Remove the widget and restore the normal footer status.
|
|
1100
1094
|
clearWidget();
|
|
1101
|
-
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.()
|
|
1095
|
+
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
1102
1096
|
|
|
1103
1097
|
if (!result.ok) {
|
|
1104
1098
|
const suffix = "error" in result && result.error ? ` (${result.error})` : "";
|
|
@@ -173,6 +173,8 @@ function bootExtension(
|
|
|
173
173
|
const piAppended: AppendedEntry[] = options.separatePiAppended ? [] : appended;
|
|
174
174
|
const sessionAppended: AppendedEntry[] = appended;
|
|
175
175
|
const handlers = new Map<string, (event: any, ctx: any) => any>();
|
|
176
|
+
const commands = new Map<string, (args: string, ctx: any) => Promise<void>>();
|
|
177
|
+
const notifications: string[] = [];
|
|
176
178
|
|
|
177
179
|
const pushPi = (type: string, data?: unknown) => {
|
|
178
180
|
piAppended.push({ type, data });
|
|
@@ -187,7 +189,9 @@ function bootExtension(
|
|
|
187
189
|
},
|
|
188
190
|
appendEntry: options.piAppendEntry ? options.piAppendEntry(pushPi) : pushPi,
|
|
189
191
|
sendMessage() {},
|
|
190
|
-
registerCommand() {
|
|
192
|
+
registerCommand(name: string, spec: { handler: (args: string, ctx: any) => Promise<void> }) {
|
|
193
|
+
commands.set(name, spec.handler);
|
|
194
|
+
},
|
|
191
195
|
registerTool() {},
|
|
192
196
|
registerMessageRenderer() {},
|
|
193
197
|
events: { emit() {} },
|
|
@@ -221,12 +225,14 @@ function bootExtension(
|
|
|
221
225
|
ui: {
|
|
222
226
|
setStatus() {},
|
|
223
227
|
setWidget() {},
|
|
224
|
-
notify() {
|
|
228
|
+
notify(message: string) {
|
|
229
|
+
notifications.push(message);
|
|
230
|
+
},
|
|
225
231
|
select: async () => undefined,
|
|
226
232
|
},
|
|
227
233
|
};
|
|
228
234
|
|
|
229
|
-
return { handlers, ctx, pi, piAppended, sessionAppended, appended, branch };
|
|
235
|
+
return { handlers, commands, notifications, ctx, pi, piAppended, sessionAppended, appended, branch };
|
|
230
236
|
}
|
|
231
237
|
|
|
232
238
|
async function boot(options?: Parameters<typeof bootExtension>[0]) {
|
|
@@ -526,75 +532,6 @@ describe("reload rearm (issue #6)", () => {
|
|
|
526
532
|
expect(piAppended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
|
|
527
533
|
});
|
|
528
534
|
|
|
529
|
-
it("recomputes the cached metrics snapshot on a turn_end whose toolResults produce no pushed batch (G3)", async () => {
|
|
530
|
-
// Component 4 (spec): the snapshot cache recomputes at every enabled
|
|
531
|
-
// turn_end carrying toolResults, unconditional on whether trim yields a
|
|
532
|
-
// batch to push. Observed via the footer widget suffix (commands.ts's
|
|
533
|
-
// pruneStatusText), which is rendered from the cache, not recomputed
|
|
534
|
-
// itself — the honest seam here since the harness's pi.registerCommand
|
|
535
|
-
// is a no-op stub and the registerCommands getCachedMetrics callback is
|
|
536
|
-
// therefore unreachable from a test.
|
|
537
|
-
const { handlers, ctx, branch } = await boot({ protectedTools: ["secret_tool"] });
|
|
538
|
-
|
|
539
|
-
// Neutralize the budget/delta gate so this test only observes the
|
|
540
|
-
// recompute, not a side-effect flush (harness default usage is 0.6,
|
|
541
|
-
// above the fixture's 0.5 autoBudgetThreshold).
|
|
542
|
-
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
543
|
-
|
|
544
|
-
await handlers.get("session_start")!({}, ctx);
|
|
545
|
-
|
|
546
|
-
const statusCalls: unknown[] = [];
|
|
547
|
-
ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
|
|
548
|
-
|
|
549
|
-
// Force a render of the current (session_start-computed) cache.
|
|
550
|
-
const rawBefore = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
|
|
551
|
-
await handlers.get("context")!({ messages: rawBefore }, ctx);
|
|
552
|
-
const textBefore = statusCalls[statusCalls.length - 1];
|
|
553
|
-
|
|
554
|
-
// Grow the branch as Pi would before firing turn_end: a new assistant
|
|
555
|
-
// turn with a large thinking block and a protected tool call, plus its
|
|
556
|
-
// toolResult. Protected content is excluded from frontierGapTokens by
|
|
557
|
-
// design, but NOT from openCycleThinkingTokens or largestChainSharePct —
|
|
558
|
-
// so this turn still moves the cache if recomputed.
|
|
559
|
-
const newAssistant = {
|
|
560
|
-
type: "message",
|
|
561
|
-
message: {
|
|
562
|
-
role: "assistant",
|
|
563
|
-
content: [
|
|
564
|
-
{ type: "thinking", text: "t".repeat(4000) },
|
|
565
|
-
{ type: "toolCall", id: "tc2", name: "secret_tool", arguments: {} },
|
|
566
|
-
],
|
|
567
|
-
},
|
|
568
|
-
};
|
|
569
|
-
const newToolResult = {
|
|
570
|
-
type: "message",
|
|
571
|
-
message: {
|
|
572
|
-
role: "toolResult",
|
|
573
|
-
toolCallId: "tc2",
|
|
574
|
-
toolName: "secret_tool",
|
|
575
|
-
content: [{ type: "text", text: "s".repeat(400) }],
|
|
576
|
-
timestamp: Date.now(),
|
|
577
|
-
},
|
|
578
|
-
};
|
|
579
|
-
branch.push(newAssistant, newToolResult);
|
|
580
|
-
|
|
581
|
-
// This turn's toolResults are entirely protected, so trimBatchToPendingRange
|
|
582
|
-
// returns null and no batch is pushed — the case this fix targets.
|
|
583
|
-
await handlers.get("turn_end")!(
|
|
584
|
-
{ message: newAssistant.message, toolResults: [newToolResult.message], turnIndex: 3 },
|
|
585
|
-
ctx,
|
|
586
|
-
);
|
|
587
|
-
|
|
588
|
-
const rawAfter = ctx.sessionManager.getBranch().filter((e: any) => e.type === "message").map((e: any) => e.message);
|
|
589
|
-
await handlers.get("context")!({ messages: rawAfter }, ctx);
|
|
590
|
-
const textAfter = statusCalls[statusCalls.length - 1];
|
|
591
|
-
|
|
592
|
-
// Pre-fix, the cache is stale (computed once at session_start, on the
|
|
593
|
-
// pre-growth branch) — the widget text does not move. Post-fix, the
|
|
594
|
-
// turn_end recompute picks up the larger open segment/thinking.
|
|
595
|
-
expect(textAfter).not.toBe(textBefore);
|
|
596
|
-
});
|
|
597
|
-
|
|
598
535
|
it("includes a persisted summary custom_message entry in the largest-chain-share denominator (G1)", async () => {
|
|
599
536
|
// Component 1 (spec): denominator = per-message chars over the entire
|
|
600
537
|
// branch projection, INCLUDING retained custom_message summary entries.
|
|
@@ -620,17 +557,13 @@ describe("reload rearm (issue #6)", () => {
|
|
|
620
557
|
};
|
|
621
558
|
const branch = [...closedChain, summaryEntry, closer];
|
|
622
559
|
|
|
623
|
-
const { handlers, ctx } = await boot({ branch });
|
|
560
|
+
const { handlers, commands, notifications, ctx } = await boot({ branch });
|
|
624
561
|
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
625
562
|
|
|
626
|
-
const statusCalls: unknown[] = [];
|
|
627
|
-
ctx.ui.setStatus = (_id: string, text?: string) => statusCalls.push(text);
|
|
628
|
-
|
|
629
563
|
await handlers.get("session_start")!({}, ctx);
|
|
630
564
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const text = statusCalls[statusCalls.length - 1] as string;
|
|
565
|
+
await commands.get("pruner")!("status", ctx);
|
|
566
|
+
const text = notifications[notifications.length - 1] as string;
|
|
634
567
|
|
|
635
568
|
const chainChars = closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0);
|
|
636
569
|
const totalWithSummary = [...closedChain.map((e: any) => e.message), summaryEntry, closer.message]
|
|
@@ -641,7 +574,7 @@ describe("reload rearm (issue #6)", () => {
|
|
|
641
574
|
(100 * chainChars) / closedChain.map((e: any) => JSON.stringify(e.message).length).reduce((a, b) => a + b, 0),
|
|
642
575
|
);
|
|
643
576
|
|
|
644
|
-
expect(text).toContain(`chain ${expectedPct}%`);
|
|
577
|
+
expect(text).toContain(`chain share: ${expectedPct}%`);
|
|
645
578
|
expect(expectedPct).toBeLessThan(inflatedPct);
|
|
646
579
|
});
|
|
647
580
|
});
|