Files
my-pi/pi-condense/doc/specs/2026-06-21-cost-event-reclaim-status.md
T

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:

  1. Pruner spend is invisible to the unified total. The summarizer makes its own stream() calls (src/summarizer.ts runSummarization) that bypass the agent loop, so they never reach message_end and never enter pi-subagents' grand total (Σ$...). The pruner instead shows its own isolated cost (↑3.0k ↓1.3k $0.021).
  2. The status line shows price, not value. prune: ON (On agent message) │ ↑3.0k ↓1.3k $0.021 reports 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.events channel 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.events string channel "cost:external". EventBus.emit(channel: string, data: unknown) per node_modules/@earendil-works/pi-coding-agent/dist/core/event-bus.d.ts. ExtensionAPI exposes this bus as events: EventBus (types.d.ts ~line 962), so any site that already holds pi: ExtensionAPI can 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 constants EXTERNAL_COST_CHANNEL = "cost:external" and EXTERNAL_COST_SOURCE = "pi-context-prune", are declared and exported from src/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 sums totalCost.

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:

  • StatsAccumulator captures a session baseline - a snapshot of { totalCost, totalInputTokens, totalOutputTokens } taken at construction (zero) and re-captured at the end of reconstructFromSession (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:external payload maps from getSessionDelta(), with the constant source.

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) (in src/stats.ts or a small util) that emits { source: EXTERNAL_COST_SOURCE, ...accumulator.getSessionDelta() } on EXTERNAL_COST_CHANNEL.
  • Call it immediately after every site that writes stats: both branches of the stats-write in flushPending (index.ts runtime statsAccum.persist(pi) ~493 and session appendEntry ~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 persisted SummarizerStats for the context-prune-stats entry 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 on session_start and stays so until the next prune.
  • The context event handler in index.ts:787 currently destructures the context as _ctx (unused). Rename it to ctx and use it: stash the pruneMessages before/after into the accumulator and refresh the status widget via setPruneStatusWidget(ctx, config, statsAccum.getStats()). ExtensionContext.ui.setStatus is 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 existing formatCompactCount (k/M) helper used elsewhere in commands.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: pruneMessages returns early; no measurement, status line keeps the last value (or prune: ON if never pruned).
  • beforeChars == 0: show prune: 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.emit is a no-op with no subscribers; pruner behavior is unchanged.

Testing

  • Reclaim calc (pure): unit test in the pruner test that before/after char totals and the formatted ratio string are correct, including the clamp and zero-before cases.
  • Emit (unit): assert persist emits one "cost:external" event whose payload carries the cumulative { source, totalCost, inputTokens, outputTokens } from current stats.
  • Status text (unit): pruneStatusText renders each of the three states correctly.
  • Smoke: pi -e ./index.ts -p "..." against an isolated $PI_CODING_AGENT_DIR. --no-extensions is dropped: it would suppress the very extension under test. Verify the ratio renders (inspect the status widget / session output) and that context-prune-stats JSONL entries still validate against the unchanged SummarizerStats schema (jq the customType counts per AGENTS.md).

Documentation impact

  • README.md: update the status-line description; document the cost:external emit and the ExternalCostUpdate contract.
  • PRUNING.md: note the live-reclaim metric (single-point measurement in pruneMessages, estimated tokens).
  • AGENTS.md: add cost:external to the event/contract surface (it is an outbound pi.events emit, not a session custom entry, so note it as such).

Open questions

None.