9.6 KiB
Spec: unified cost event + live-reclaim status line
Date: 2026-06-21
Branch: cost-event-reclaim-status
Problem
Two defects in how the pruner surfaces itself:
- Pruner spend is invisible to the unified total. The summarizer makes its own
stream()calls (src/summarizer.tsrunSummarization) that bypass the agent loop, so they never reachmessage_endand never enter pi-subagents' grand total (Σ$...). The pruner instead shows its own isolated cost (↑3.0k ↓1.3k $0.021). - The status line shows price, not value.
prune: ON (On agent message) │ ↑3.0k ↓1.3k $0.021reports what pruning costs, never what it reclaims. A user cannot tell whether pruning is doing a good job.
Goals
- Pruner publishes its cumulative LLM spend on a shared
pi.eventschannel so pi-subagents (or any aggregator) can fold it into oneΣ$as a third cost signal. Fire-and-forget; no hard dependency on pi-subagents. - Status line headline becomes a compact reclaim ratio:
prune: ON │ 92k->14k (-85%).
Non-goals
- pi-subagents-side aggregation (separate spec in that repo, against the same contract).
- Durability of the external-cost slice across session reload. Decided live-only: the external cost is ephemeral; no session reseed. Rationale: it is a minor fraction of the total and durability is not worth coupling an aggregator to the pruner's entry schema.
- Exact token accounting for reclaim. The headline uses an estimate (chars / 4).
Change 1 - emit cost on the shared bus
Contract (fixed interface, shared with the pi-subagents spec)
-
Channel:
pi.eventsstring channel"cost:external".EventBus.emit(channel: string, data: unknown)pernode_modules/@earendil-works/pi-coding-agent/dist/core/event-bus.d.ts.ExtensionAPIexposes this bus asevents: EventBus(types.d.ts ~line 962), so any site that already holdspi: ExtensionAPIcan emit with no signature change. -
Payload (cumulative-per-source, NOT deltas):
interface ExternalCostUpdate { source: string; // stable producer id: "pi-context-prune" totalCost: number; // cumulative USD this source spent THIS SESSION inputTokens?: number; // cumulative this session, optional (for breakdown display) outputTokens?: number; // cumulative this session, optional }ExternalCostUpdate, plus the constantsEXTERNAL_COST_CHANNEL = "cost:external"andEXTERNAL_COST_SOURCE = "pi-context-prune", are declared and exported fromsrc/types.ts(not spec-prose only) so the contract is importable in one place. -
Semantics: "cumulative" means cumulative for the current session, not all-time. The producer emits its running session total on every update. Cumulative-per-source is idempotent and replay-safe within an aggregator run: a re-emit overwrites the same key, never double-counts. The aggregator keeps
Map<source, payload>and sumstotalCost.
Producer wiring (this repo)
Session-scoped delta, not raw cumulative. StatsAccumulator.reconstructFromSession
(src/stats.ts:96) reloads prior-session context-prune-stats totals on session_start.
Emitting getStats() directly would republish historical spend to a freshly started
aggregator, breaking the live-only contract. Fix:
StatsAccumulatorcaptures a session baseline - a snapshot of{ totalCost, totalInputTokens, totalOutputTokens }taken at construction (zero) and re-captured at the end ofreconstructFromSession(equal to the carried-over totals).- New method
getSessionDelta()returns{ totalCost, inputTokens, outputTokens }= current totals minus the baseline - i.e. only this session's spend. - The
cost:externalpayload maps fromgetSessionDelta(), with the constantsource.
Emit at every stats-write site, not only persist. persist(pi) is NOT the sole
stats-write path: the default message_end -> delivery: "session" branch writes via
appendEntry(CUSTOM_TYPE_STATS, statsAccum.getStats()) (index.ts:498), bypassing
persist. Emitting only inside persist would miss the main agent-message summarization
path. Fix:
- Add a free helper
emitExternalCost(pi, accumulator)(insrc/stats.tsor a small util) that emits{ source: EXTERNAL_COST_SOURCE, ...accumulator.getSessionDelta() }onEXTERNAL_COST_CHANNEL. - Call it immediately after every site that writes stats: both branches of the
stats-write in
flushPending(index.tsruntimestatsAccum.persist(pi)~493 and sessionappendEntry~498) and the chain-compression stats writes (~534, ~839). Simplest form: fold the emit just after the runtime/session if-else so it runs unconditionally on each flush. - Idempotency makes the multi-site emit safe: re-emitting the same session delta is a no-op at the aggregator.
Change 2 - live reclaim headline
Measurement (single point, all mechanisms)
pruneMessages (src/pruner.ts) is the one place where every reclaim mechanism
(stub-replace, error-purge, chain-range-prune, thinking-strip) runs against the full message
array. Measure once there, only when a prune actually occurs (the function already
short-circuits and returns the original array reference when nothing changes):
beforeChars=sizeMessages(messages)(the input array, raw outputs still present).afterChars=sizeMessages(pruned)(the returned pruned array).
sizeMessages(messages: AgentMessage[]): number is a pure helper defined as
JSON.stringify(messages).length. Serializing the whole array (not just visible .text)
is deliberate: it counts tool-call argument bodies (error-purge reclaim), thinking blocks
(thinking-strip reclaim), and tool-result arrays (stub-replace / chain-range reclaim) -
so all four mechanisms register. It is an estimate of context weight, not an exact token
count.
This captures all four mechanisms with zero per-path plumbing. It is a live measurement (current pruned context vs the same context unpruned), self-correcting each turn, with no cumulative drift.
Carrying the measurement to the status line
StatsAccumulator(src/stats.ts) gains a transient live-reclaim field (liveBeforeChars,liveAfterChars), set via a new method (e.g.setLiveReclaim(before, after)).- These transient fields are separate from
SummarizerStats(which stays the persisted schema).getStats()returns the persistedSummarizerStatsfor thecontext-prune-statsentry unchanged; the status widget reads the live reclaim via a distinct accessor (e.g.getLiveReclaim()) or an extended widget-only view object. The persisted entry schema is untouched; the live value resets to unset onsession_startand stays so until the next prune. - The
contextevent handler inindex.ts:787currently destructures the context as_ctx(unused). Rename it toctxand use it: stash thepruneMessagesbefore/after into the accumulator and refresh the status widget viasetPruneStatusWidget(ctx, config, statsAccum.getStats()).ExtensionContext.ui.setStatusis already available - no signature change beyond using the parameter.
Status line format
pruneStatusText(config, stats) (src/commands.ts) renders:
| State | Line |
|---|---|
| Enabled, reclaim measured this session | prune: ON | 92k->14k (-85%) |
| Enabled, nothing pruned yet | prune: ON |
| Disabled | prune: OFF |
- Tokens estimated as
Math.round(chars / 4), formatted with the existingformatCompactCount(k/M) helper used elsewhere incommands.ts. - Reduction
= round((1 - afterTokens / beforeTokens) * 100), clamped to>= 0. A full strip (afterTokens == 0,beforeTokens > 0) correctly renders-100%. - Drops the
(On agent message)mode label and the↑↓$figures from the line. - Cost leaves the status line entirely. It now flows into pi-subagents'
Σ$. Full token/cost/call detail remains available in/pruner stats(unchanged).
Edge cases
- No prune this turn:
pruneMessagesreturns early; no measurement, status line keeps the last value (orprune: ONif never pruned). beforeChars == 0: showprune: ON(no ratio); never divide by zero.- Negative/zero reclaim (summary larger than raw, rare): clamp reduction to
0%. - Oversized/spilled outputs not in context: measured as-is; the headline is an estimate, not an exact byte count.
- No aggregator listening:
pi.events.emitis a no-op with no subscribers; pruner behavior is unchanged.
Testing
- Reclaim calc (pure): unit test in the
prunertest that before/after char totals and the formatted ratio string are correct, including the clamp and zero-before cases. - Emit (unit): assert
persistemits one"cost:external"event whose payload carries the cumulative{ source, totalCost, inputTokens, outputTokens }from current stats. - Status text (unit):
pruneStatusTextrenders each of the three states correctly. - Smoke:
pi -e ./index.ts -p "..."against an isolated$PI_CODING_AGENT_DIR.--no-extensionsis dropped: it would suppress the very extension under test. Verify the ratio renders (inspect the status widget / session output) and thatcontext-prune-statsJSONL entries still validate against the unchangedSummarizerStatsschema (jqthecustomTypecounts per AGENTS.md).
Documentation impact
- README.md: update the status-line description; document the
cost:externalemit and theExternalCostUpdatecontract. - PRUNING.md: note the live-reclaim metric (single-point measurement in
pruneMessages, estimated tokens). - AGENTS.md: add
cost:externalto the event/contract surface (it is an outboundpi.eventsemit, not a session custom entry, so note it as such).
Open questions
None.