@diegopetrucci/pi-extensions 0.1.76 → 0.1.79

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.
Files changed (63) hide show
  1. package/README.md +8 -8
  2. package/extensions/agent-workflow-audit/README.md +1 -1
  3. package/extensions/agent-workflow-audit/index.ts +275 -59
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/git.ts +0 -8
  6. package/extensions/annotate-git-diff/glimpseui.d.ts +5 -65
  7. package/extensions/annotate-git-diff/package.json +1 -1
  8. package/extensions/annotate-last-message/glimpseui.d.ts +5 -65
  9. package/extensions/annotate-last-message/package.json +1 -1
  10. package/extensions/annotate-last-message/web/index.html +3 -30
  11. package/extensions/code-reviewer/README.md +5 -5
  12. package/extensions/code-reviewer/index.ts +252 -82
  13. package/extensions/code-reviewer/package.json +1 -1
  14. package/extensions/confirm-destructive/index.ts +2 -8
  15. package/extensions/confirm-destructive/package.json +1 -1
  16. package/extensions/context-cap/README.md +9 -9
  17. package/extensions/context-cap/index.ts +3 -3
  18. package/extensions/context-cap/package.json +2 -2
  19. package/extensions/context-inspector/index.ts +2 -46
  20. package/extensions/context-inspector/package.json +1 -1
  21. package/extensions/contrarian/README.md +4 -3
  22. package/extensions/contrarian/index.ts +48 -67
  23. package/extensions/contrarian/package.json +1 -1
  24. package/extensions/dirty-repo-guard/index.ts +0 -4
  25. package/extensions/dirty-repo-guard/package.json +1 -1
  26. package/extensions/dynamic-context-pruning/README.md +20 -11
  27. package/extensions/dynamic-context-pruning/index.ts +118 -341
  28. package/extensions/dynamic-context-pruning/package.json +1 -1
  29. package/extensions/fast/README.md +18 -11
  30. package/extensions/fast/index.ts +9 -5
  31. package/extensions/fast/package.json +1 -1
  32. package/extensions/git-footer/index.ts +2 -16
  33. package/extensions/git-footer/package.json +1 -1
  34. package/extensions/inline-bash/index.ts +0 -16
  35. package/extensions/inline-bash/package.json +1 -1
  36. package/extensions/librarian/README.md +1 -1
  37. package/extensions/librarian/index.ts +246 -70
  38. package/extensions/librarian/package.json +1 -1
  39. package/extensions/minimal-footer/README.md +5 -5
  40. package/extensions/minimal-footer/index.ts +5 -26
  41. package/extensions/minimal-footer/minimal-footer.example.json +1 -1
  42. package/extensions/minimal-footer/openai-usage.ts +2 -11
  43. package/extensions/minimal-footer/package.json +1 -1
  44. package/extensions/oracle/README.md +4 -3
  45. package/extensions/oracle/index.ts +46 -65
  46. package/extensions/oracle/package.json +1 -1
  47. package/extensions/permission-gate/README.md +3 -0
  48. package/extensions/permission-gate/index.ts +127 -36
  49. package/extensions/permission-gate/package.json +1 -1
  50. package/extensions/project-mcp-json/README.md +2 -2
  51. package/extensions/project-mcp-json/package.json +1 -1
  52. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  53. package/extensions/quiet-tools/README.md +30 -19
  54. package/extensions/quiet-tools/index.ts +233 -166
  55. package/extensions/quiet-tools/package.json +2 -2
  56. package/extensions/review/README.md +2 -0
  57. package/extensions/review/package.json +1 -1
  58. package/extensions/todo/index.ts +0 -19
  59. package/extensions/todo/package.json +1 -1
  60. package/extensions/triage-comments/README.md +4 -2
  61. package/extensions/triage-comments/index.ts +344 -39
  62. package/extensions/triage-comments/package.json +1 -1
  63. package/package.json +4 -4
@@ -7,10 +7,7 @@ import {
7
7
  type ExtensionContext,
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
 
10
- // ============================================================================
11
- // Types (kept intentionally minimal/structural so pure helpers stay testable
12
- // without depending on the full @earendil-works/pi-ai type graph).
13
- // ============================================================================
10
+ // Structural stand-ins so pure helpers stay testable without the full pi-ai type graph.
14
11
 
15
12
  export interface MinimalTextContent {
16
13
  type: "text";
@@ -143,27 +140,17 @@ export function normalizeSessionProjection(value: unknown): MinimalSessionProjec
143
140
  return { entries, messages: entries.flatMap((entry) => entry.messages) };
144
141
  }
145
142
 
146
- export function sessionProjectionToMessages(
147
- projection: MinimalSessionProjection,
148
- ): MinimalMessage[] {
149
- return projection.entries.flatMap((entry) => entry.messages);
150
- }
151
-
152
- // ============================================================================
153
- // Constants
154
- // ============================================================================
155
-
156
143
  const EXTENSION_ID = "dynamic-context-pruning";
157
144
  const CONFIG_FILE_NAME = "dynamic-context-pruning.json";
158
145
  const DECISION_ENTRY_TYPE = "dynamic-context-pruning:decision";
159
146
  const STATS_ENTRY_TYPE = "dynamic-context-pruning:stats";
160
147
  /**
161
- * Restore/undo entries (pe-8re9): a lightweight tombstone that records "the
162
- * decision with this idempotencyKey should be treated as reverted". See
163
- * `resolvePruneTombstoneState` for the exact chronological resolution rule.
148
+ * Restore/undo entries: a lightweight tombstone that records "the decision
149
+ * with this idempotencyKey should be treated as reverted". See
150
+ * `resolvePruneTombstoneState` for the chronological last-event-wins rule.
164
151
  */
165
152
  const RESTORE_ENTRY_TYPE = "dynamic-context-pruning:restore";
166
- /** strategyId used for user-initiated prunes from the /prune picker (pe-8re9). Always source:"manual". */
153
+ /** strategyId used for user-initiated prunes from the /prune picker. Always source:"manual". */
167
154
  const MANUAL_STRATEGY_ID = "manual";
168
155
 
169
156
  const DEFAULT_RECENT_TURNS = 4;
@@ -171,37 +158,10 @@ const DEFAULT_RECENT_TURNS = 4;
171
158
  /** Cache cost model default: fraction of full (non-cached) price still paid for cached tokens. */
172
159
  const DEFAULT_CACHED_PRICE_RATIO = 0.1;
173
160
 
174
- /**
175
- * Default net-benefit gate threshold (max amortization calls). Recalibrated
176
- * (pe-c5n9) from the representative-corpus benchmark (~/.the-last-harness/
177
- * agent/sessions, 1,390+ session files, 556 candidates): this is the
178
- * hindsight-optimal break-even T at cachedPriceRatio r=0.1 (aggressive
179
- * prompt caching, the common Anthropic case). IMPORTANT caveats:
180
- * - Provider/ratio-dependent: this optimum shifts higher as caching gets
181
- * weaker (T=29 at r=0.25, T=54 at r=0.5, T=58 at r=0.9 on the same
182
- * corpus) — see docs/v2-design.md section 4 and the extension README.
183
- * - At r=0.1 the realized deterministic savings this threshold buys are
184
- * economically marginal (~20.6k token-units total across the whole
185
- * corpus, i.e. pennies); savings only become material at r>=0.25.
186
- * - Re-derive via `scripts/benchmark.mjs <sessions-dir> --ratio 0.1` if
187
- * the representative corpus changes meaningfully.
188
- */
161
+ /** Default idle net-benefit gate threshold; see README.md and docs/v2-design.md §4. */
189
162
  const DEFAULT_BREAK_EVEN_THRESHOLD = 22;
190
163
 
191
- /**
192
- * Default mid_loop break-even threshold (pe-zy4s follow-up). The package's own
193
- * representative-corpus benchmark (turn-START state definition — the same one
194
- * the runtime `context` handler uses) favored rejecting mid_loop candidates
195
- * at cachedPriceRatio r=0.1 (mid_loop-optimal T=1, total realized
196
- * net benefit ~0.0). This supports a conservative default for the measured
197
- * corpus, not a universal claim that mid_loop pruning cannot pay off.
198
- * The reported 2026-09-08 byte-level capture (a mid_loop prune at T=22
199
- * invalidating 81% of a warm request prefix) illustrates cache invalidation,
200
- * not an inability to amortize it: enough subsequent calls can recoup a
201
- * one-time bust. Idle keeps T=22; turn-start classification does not imply
202
- * the provider cache is cold. Users who want the old behavior can override
203
- * via gate.breakEvenThresholdByState.mid_loop.
204
- */
164
+ /** Default mid_loop threshold; see README.md for the runtime-aligned benchmark rationale. */
205
165
  const DEFAULT_MID_LOOP_BREAK_EVEN_THRESHOLD = 1;
206
166
 
207
167
  /** Tool names never pruned by default: orchestration/subagent-style tools and todo-like state. */
@@ -240,10 +200,6 @@ const DEFAULT_ERROR_PURGE_MIN_TURNS_OLD = 4;
240
200
  const DEDUPE_STRATEGY_ID = "dedupe";
241
201
  const ERROR_PURGE_STRATEGY_ID = "error-purge";
242
202
 
243
- // ============================================================================
244
- // Config
245
- // ============================================================================
246
-
247
203
  export interface PruneProtections {
248
204
  /** Tool names (case-insensitive) whose calls are never pruned. */
249
205
  toolNames: string[];
@@ -254,22 +210,11 @@ export interface PruneProtections {
254
210
  }
255
211
 
256
212
  export interface PruneThresholds {
257
- /**
258
- * Minimum raw text characters a fresh AUTOMATIC prune proposal must remove
259
- * (original text chars minus placeholder chars) to be considered at all
260
- * (pe-qdzb). Enforced by `runDynamicContextPruningPipeline` BEFORE the
261
- * net-benefit gate (see `PruneGateConfig`/`gate`, which uses break-even token
262
- * math and is a separate, later check): text-only candidates below this floor
263
- * are dropped up front and never reach gate/batch consideration. Image-bearing
264
- * tool-result candidates bypass this raw-text floor (images receive no
265
- * synthetic character count) and proceed to the token-based gate. Manual
266
- * prunes and already-persisted/replayed decisions always bypass this floor,
267
- * same as the gate. A value of 0 disables the floor (no filtering).
268
- */
213
+ /** Minimum automatic-prune text saving; see README.md Configuration for bypass rules. */
269
214
  minCharsSaved: number;
270
215
  }
271
216
 
272
- /** Per-strategy toggles/config (pe-u8gd, pe-qs8j). Each strategy can be disabled independently. */
217
+ /** Per-strategy toggles/config. Each strategy can be disabled independently. */
273
218
  export interface StrategiesConfig {
274
219
  dedupe: { enabled: boolean };
275
220
  errorPurge: {
@@ -277,11 +222,10 @@ export interface StrategiesConfig {
277
222
  /** A tool call's input is only eligible for purging once it is older than this many turns. */
278
223
  minTurnsOld: number;
279
224
  };
280
- /** Superseded file-ops strategy (pe-qs8j): stale read/write/edit outputs replaced by a placeholder. */
225
+ /** Superseded file-ops strategy: stale read/write/edit outputs replaced by a placeholder. */
281
226
  supersededFileOps: { enabled: boolean };
282
227
  }
283
228
 
284
- /** Net-benefit gate operating mode (pe-s2ho). */
285
229
  export type GateMode = "on" | "off" | "always-apply";
286
230
 
287
231
  /**
@@ -484,11 +428,6 @@ async function writeConfig(config: DynamicContextPruningConfig): Promise<void> {
484
428
  await fs.writeFile(configPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
485
429
  }
486
430
 
487
- // ============================================================================
488
- // Token estimator (chars/4-style; deliberately simple, good enough for
489
- // relative before/after accounting used by later savings-accounting work).
490
- // ============================================================================
491
-
492
431
  export function estimateTokensForText(text: string): number {
493
432
  if (!text) return 0;
494
433
  return Math.max(0, Math.ceil(text.length / 4));
@@ -510,29 +449,8 @@ export function estimateTokensForContent(
510
449
  export type TokenEstimator = (text: string) => number;
511
450
 
512
451
  /**
513
- * Classify the agent's current state from the message payload alone (pe-zy4s):
514
- * this is the runtime-observable definition used both by the live `context`
515
- * event handler and (via re-export) by the offline benchmark, so the two can
516
- * never drift apart.
517
- *
518
- * Walk backwards from the end of `messages`, skipping any message that is
519
- * neither `user`, `assistant`, nor `toolResult` (e.g. system-role bookkeeping
520
- * entries). The first such message found determines the state:
521
- * - `user` (a plain user turn message) => "idle": this call is the FIRST LLM
522
- * call of a turn, i.e. the decision is being made from a turn-start state.
523
- * - `assistant` or `toolResult` (mid-chain) => "mid_loop": the agent is
524
- * iterating (already made at least one call this turn, or is reacting to a
525
- * tool result).
526
- * - No such message found (empty history, or system-only) => "idle" (safe
527
- * default: nothing suggests an in-progress loop).
528
- *
529
- * The benchmark (scripts/benchmark.mjs) reclassifies its candidate labels to
530
- * this SAME definition (pe-zy4s), reusing this function directly, so the
531
- * defaults below rest on evidence measured with the exact definition the
532
- * runtime observes. (An earlier turn-end-based benchmark definition -- "idle"
533
- * meant "this IS the turn's last assistant message", only knowable in
534
- * hindsight -- has been retired: the runtime cannot know in advance whether a
535
- * call is a turn's last one, so that definition was never runtime-observable.)
452
+ * Shared runtime/benchmark state classifier; see README.md Configuration and
453
+ * docs/v2-design.md §4 for the turn-start definition and retired turn-end analysis.
536
454
  */
537
455
  export function classifyAgentStateFromMessages(messages: MinimalMessage[]): AgentState {
538
456
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -544,7 +462,7 @@ export function classifyAgentStateFromMessages(messages: MinimalMessage[]): Agen
544
462
  return "idle";
545
463
  }
546
464
 
547
- /** Resolve which break-even threshold applies for a given agent state (pe-s2ho state-conditioning hook). */
465
+ /** Resolve which break-even threshold applies for a given agent state. */
548
466
  export function resolveBreakEvenThreshold(
549
467
  gateConfig: PruneGateConfig,
550
468
  agentState: AgentState,
@@ -578,7 +496,7 @@ function estimateMessageTokens(message: MinimalMessage, estimateTokens: TokenEst
578
496
 
579
497
  /**
580
498
  * Sum estimated tokens for messages from `fromIndex` to the end of the array.
581
- * Used both for cache-bust tail sizing (pe-s2ho cost model) and for raw/
499
+ * Used both for cache-bust tail sizing and for raw/
582
500
  * effective context-size snapshots. Clamped to valid indices; an out-of-range
583
501
  * `fromIndex` (e.g. the very last message, or beyond) degrades gracefully.
584
502
  */
@@ -595,10 +513,6 @@ export function estimateTailTokens(
595
513
  return total;
596
514
  }
597
515
 
598
- // ============================================================================
599
- // Protections
600
- // ============================================================================
601
-
602
516
  function globToRegExp(glob: string): RegExp {
603
517
  let pattern = "^";
604
518
  for (let i = 0; i < glob.length; i++) {
@@ -698,7 +612,7 @@ export function isWithinRecencyWindow(index: number, boundaryIndex: number): boo
698
612
  * Used by the error-input purge strategy's "older than N turns" rule; a
699
613
  * message at the very end of the conversation has 0 turns elapsed.
700
614
  */
701
- export function computeTurnsElapsedSince(messages: MinimalMessage[], index: number): number {
615
+ function computeTurnsElapsedSince(messages: MinimalMessage[], index: number): number {
702
616
  let count = 0;
703
617
  for (let i = index + 1; i < messages.length; i++) {
704
618
  if (messages[i].role === "user") count++;
@@ -706,10 +620,6 @@ export function computeTurnsElapsedSince(messages: MinimalMessage[], index: numb
706
620
  return count;
707
621
  }
708
622
 
709
- // ============================================================================
710
- // Correlation
711
- // ============================================================================
712
-
713
623
  export interface ToolCallPairIndices {
714
624
  assistantIndex?: number;
715
625
  toolCallBlockIndex?: number;
@@ -722,7 +632,7 @@ export interface ToolCallPairIndices {
722
632
  * toolCallIds against the same `messages` array (e.g. the pipeline's
723
633
  * per-decision protection/savings/apply passes) should build this once and
724
634
  * pass it to `findToolCallPairIndices` and friends to avoid an O(n) rescan
725
- * per decision (pe-e0zd).
635
+ * per decision.
726
636
  */
727
637
  export function buildToolCallPairIndex(
728
638
  messages: MinimalMessage[],
@@ -798,10 +708,6 @@ export function findToolCallPairIndices(
798
708
  return pair;
799
709
  }
800
710
 
801
- // ============================================================================
802
- // Proposals & persisted decisions
803
- // ============================================================================
804
-
805
711
  export type PruneTargetKind = "tool_result_content" | "tool_call_input";
806
712
 
807
713
  /**
@@ -826,7 +732,7 @@ export interface ProposedPrune {
826
732
  export interface PruneDecisionRecord {
827
733
  idempotencyKey: string;
828
734
  strategyId: string;
829
- correlation: { type: "toolCallId"; toolCallId: string } | { type: "entryId"; entryId: string };
735
+ correlation: { type: "toolCallId"; toolCallId: string };
830
736
  kind: PruneTargetKind;
831
737
  reason: string;
832
738
  placeholder?: string;
@@ -843,15 +749,11 @@ export function buildIdempotencyKey(proposal: ProposedPrune): string {
843
749
  * omitting `strategyId` (unlike `idempotencyKey`). Two decisions from
844
750
  * different strategies that both replace the same tool result's content
845
751
  * share this key even though their idempotencyKeys differ — that's exactly
846
- * the overlap pe-j7sb collapses before gating/apply (see
752
+ * the overlap the pipeline collapses before gating/apply (see
847
753
  * `runDynamicContextPruningPipeline`).
848
754
  */
849
755
  function buildDecisionTargetKey(decision: PruneDecisionRecord): string {
850
- const correlationId =
851
- decision.correlation.type === "toolCallId"
852
- ? decision.correlation.toolCallId
853
- : decision.correlation.entryId;
854
- return `${decision.kind}:${decision.correlation.type}:${correlationId}`;
756
+ return `${decision.kind}:${decision.correlation.type}:${decision.correlation.toolCallId}`;
855
757
  }
856
758
 
857
759
  export function proposalToDecisionRecord(
@@ -885,8 +787,6 @@ export function parseDecisionRecord(data: unknown): PruneDecisionRecord | undefi
885
787
  let correlation: PruneDecisionRecord["correlation"] | undefined;
886
788
  if (rawCorrelation?.type === "toolCallId" && typeof rawCorrelation.toolCallId === "string") {
887
789
  correlation = { type: "toolCallId", toolCallId: rawCorrelation.toolCallId };
888
- } else if (rawCorrelation?.type === "entryId" && typeof rawCorrelation.entryId === "string") {
889
- correlation = { type: "entryId", entryId: rawCorrelation.entryId };
890
790
  }
891
791
  if (!correlation) return undefined;
892
792
 
@@ -902,10 +802,6 @@ export function parseDecisionRecord(data: unknown): PruneDecisionRecord | undefi
902
802
  };
903
803
  }
904
804
 
905
- // ============================================================================
906
- // Restore / undo (pe-8re9): tombstones that reverse a persisted decision
907
- // ============================================================================
908
-
909
805
  /**
910
806
  * A restore record is a tombstone: it records that the decision identified by
911
807
  * `idempotencyKey` should stop being applied, WITHOUT deleting or mutating
@@ -1013,11 +909,7 @@ export function resolvePruneTombstoneState(entries: MinimalSessionEntry[]): Prun
1013
909
  return { activeDecisions, activeIdempotencyKeys, restoredKeys, lastDecisionByKey };
1014
910
  }
1015
911
 
1016
- // ============================================================================
1017
- // Apply step (content-replacement only; never drops messages)
1018
- // ============================================================================
1019
-
1020
- export function buildPlaceholderText(reason: string, charsRemoved: number): string {
912
+ function buildPlaceholderText(reason: string, charsRemoved: number): string {
1021
913
  return `[pruned by ${EXTENSION_ID}: ${reason} (${charsRemoved} chars removed)]`;
1022
914
  }
1023
915
 
@@ -1031,8 +923,7 @@ function isImageBearingToolResultTarget(
1031
923
  decision: PruneDecisionRecord,
1032
924
  pairIndex?: Map<string, ToolCallPairIndices>,
1033
925
  ): boolean {
1034
- if (decision.kind !== "tool_result_content" || decision.correlation.type !== "toolCallId")
1035
- return false;
926
+ if (decision.kind !== "tool_result_content") return false;
1036
927
  const pair = findToolCallPairIndices(messages, decision.correlation.toolCallId, pairIndex);
1037
928
  if (pair.resultIndex === undefined) return false;
1038
929
  const target = messages[pair.resultIndex];
@@ -1056,11 +947,6 @@ export function applyPruneDecision(
1056
947
  decision: PruneDecisionRecord,
1057
948
  pairIndex?: Map<string, ToolCallPairIndices>,
1058
949
  ): ApplyResult {
1059
- if (decision.correlation.type !== "toolCallId") {
1060
- // v1 only ever targets tool call/result pairs; entryId correlation is
1061
- // reserved for future strategies and intentionally not applied yet.
1062
- return { applied: false, charsRemoved: 0 };
1063
- }
1064
950
  const pair = findToolCallPairIndices(messages, decision.correlation.toolCallId, pairIndex);
1065
951
 
1066
952
  if (decision.kind === "tool_result_content") {
@@ -1081,7 +967,7 @@ export function applyPruneDecision(
1081
967
  }
1082
968
  if (pair.resultIndex === undefined) return { applied: false, charsRemoved: 0 };
1083
969
  const resultMessage = messages[pair.resultIndex] as MinimalToolResultMessage;
1084
- // Only errored-call inputs may ever be redacted (ticket invariant).
970
+ // Only errored-call inputs may ever be redacted.
1085
971
  if (!resultMessage.isError) return { applied: false, charsRemoved: 0 };
1086
972
 
1087
973
  const assistantMessage = messages[pair.assistantIndex] as MinimalAssistantMessage;
@@ -1132,7 +1018,6 @@ export function isDecisionProtected(
1132
1018
  recencyBoundaryIndex: number,
1133
1019
  pairIndex?: Map<string, ToolCallPairIndices>,
1134
1020
  ): boolean {
1135
- if (decision.correlation.type !== "toolCallId") return true; // no safe way to check protections yet
1136
1021
  const pair = findToolCallPairIndices(messages, decision.correlation.toolCallId, pairIndex);
1137
1022
  if (
1138
1023
  pair.resultIndex !== undefined &&
@@ -1154,10 +1039,6 @@ export function isDecisionProtected(
1154
1039
  return false;
1155
1040
  }
1156
1041
 
1157
- // ============================================================================
1158
- // Savings estimation, cache cost model & net-benefit gate (pe-s2ho)
1159
- // ============================================================================
1160
-
1161
1042
  export interface DecisionSavingsEstimate {
1162
1043
  /** Earliest message index this decision changes; used for cache-bust tail sizing. */
1163
1044
  position: number;
@@ -1165,36 +1046,44 @@ export interface DecisionSavingsEstimate {
1165
1046
  tokensRemoved: number;
1166
1047
  }
1167
1048
 
1049
+ type DecisionApplyPreview =
1050
+ | {
1051
+ kind: "tool_result_content";
1052
+ position: number;
1053
+ before: (MinimalTextContent | MinimalImageContent)[];
1054
+ after: (MinimalTextContent | MinimalImageContent)[];
1055
+ }
1056
+ | {
1057
+ kind: "tool_call_input";
1058
+ position: number;
1059
+ beforeArgs: Record<string, unknown>;
1060
+ afterArgs: Record<string, unknown>;
1061
+ };
1062
+
1168
1063
  /**
1169
- * Estimate the token savings and affected position of a single decision
1170
- * without mutating the caller's `messages`. Returns undefined when the
1171
- * decision's target is absent or the decision would not actually apply
1172
- * (mirrors `applyPruneDecision`'s no-op conditions).
1064
+ * Non-mutating before/after for one decision. A shallow array copy keeps the
1065
+ * caller's messages intact; pair-index positions stay valid because slice()
1066
+ * preserves length and order. Undefined when the target is absent or apply
1067
+ * would no-op.
1173
1068
  */
1174
- export function estimateDecisionSavings(
1069
+ function previewDecisionApply(
1175
1070
  messages: MinimalMessage[],
1176
1071
  decision: PruneDecisionRecord,
1177
- estimateTokens: TokenEstimator = estimateTokensForText,
1178
1072
  pairIndex?: Map<string, ToolCallPairIndices>,
1179
- ): DecisionSavingsEstimate | undefined {
1180
- if (decision.correlation.type !== "toolCallId") return undefined;
1073
+ ): DecisionApplyPreview | undefined {
1181
1074
  const pair = findToolCallPairIndices(messages, decision.correlation.toolCallId, pairIndex);
1182
-
1183
- // Apply against a shallow copy of the array (not the message objects) so
1184
- // the caller's messages/content are never mutated by this preview. Indices
1185
- // in `pairIndex` (built from `messages`) remain valid against `preview`
1186
- // since slice() preserves length/order.
1187
1075
  const preview = messages.slice();
1188
1076
  const result = applyPruneDecision(preview, decision, pairIndex);
1189
1077
  if (!result.applied) return undefined;
1190
1078
 
1191
1079
  if (decision.kind === "tool_result_content") {
1192
1080
  if (pair.resultIndex === undefined) return undefined;
1193
- const before = messages[pair.resultIndex] as MinimalToolResultMessage;
1194
- const after = preview[pair.resultIndex] as MinimalToolResultMessage;
1195
- const tokensBefore = estimateTokensForContent(before.content);
1196
- const tokensAfter = estimateTokensForContent(after.content);
1197
- return { position: pair.resultIndex, tokensRemoved: Math.max(0, tokensBefore - tokensAfter) };
1081
+ return {
1082
+ kind: "tool_result_content",
1083
+ position: pair.resultIndex,
1084
+ before: (messages[pair.resultIndex] as MinimalToolResultMessage).content,
1085
+ after: (preview[pair.resultIndex] as MinimalToolResultMessage).content,
1086
+ };
1198
1087
  }
1199
1088
 
1200
1089
  if (decision.kind === "tool_call_input") {
@@ -1206,11 +1095,11 @@ export function estimateDecisionSavings(
1206
1095
  const afterBlock = (preview[pair.assistantIndex] as MinimalAssistantMessage).content[
1207
1096
  pair.toolCallBlockIndex
1208
1097
  ] as MinimalToolCallContent;
1209
- const tokensBefore = estimateTokens(JSON.stringify(beforeBlock.arguments ?? {}));
1210
- const tokensAfter = estimateTokens(JSON.stringify(afterBlock.arguments ?? {}));
1211
1098
  return {
1099
+ kind: "tool_call_input",
1212
1100
  position: pair.assistantIndex,
1213
- tokensRemoved: Math.max(0, tokensBefore - tokensAfter),
1101
+ beforeArgs: beforeBlock.arguments ?? {},
1102
+ afterArgs: afterBlock.arguments ?? {},
1214
1103
  };
1215
1104
  }
1216
1105
 
@@ -1218,60 +1107,56 @@ export function estimateDecisionSavings(
1218
1107
  }
1219
1108
 
1220
1109
  /**
1221
- * Estimate the character savings of a single decision (pe-qdzb), i.e. the
1222
- * same before/after delta basis as `estimateDecisionSavings` but measured in
1223
- * raw characters rather than estimated tokens. Used by the pipeline to
1224
- * enforce `thresholds.minCharsSaved` against fresh automatic proposals
1225
- * *before* they reach the net-benefit gate. Returns undefined under the same
1226
- * conditions as `estimateDecisionSavings` (target absent / would not apply).
1227
- * Mirrors `estimateDecisionSavings`'s `Math.max(0, ...)` clamp, so a
1228
- * decision whose placeholder text would be LONGER than the original content
1229
- * (negative savings) is reported as 0 chars saved, not a negative number.
1110
+ * Estimate the token savings and affected position of a single decision
1111
+ * without mutating the caller's `messages`. Returns undefined when the
1112
+ * decision's target is absent or the decision would not actually apply
1113
+ * (mirrors `applyPruneDecision`'s no-op conditions).
1230
1114
  */
1231
- export function estimateDecisionCharsSaved(
1115
+ export function estimateDecisionSavings(
1232
1116
  messages: MinimalMessage[],
1233
1117
  decision: PruneDecisionRecord,
1118
+ estimateTokens: TokenEstimator = estimateTokensForText,
1234
1119
  pairIndex?: Map<string, ToolCallPairIndices>,
1235
- ): number | undefined {
1236
- if (decision.correlation.type !== "toolCallId") return undefined;
1237
- const pair = findToolCallPairIndices(messages, decision.correlation.toolCallId, pairIndex);
1238
-
1239
- // Same non-mutating preview approach as `estimateDecisionSavings`.
1240
- const preview = messages.slice();
1241
- const result = applyPruneDecision(preview, decision, pairIndex);
1242
- if (!result.applied) return undefined;
1243
-
1244
- if (decision.kind === "tool_result_content") {
1245
- if (pair.resultIndex === undefined) return undefined;
1246
- const before = messages[pair.resultIndex] as MinimalToolResultMessage;
1247
- const after = preview[pair.resultIndex] as MinimalToolResultMessage;
1248
- const charsBefore = contentTextLength(before.content);
1249
- const charsAfter = contentTextLength(after.content);
1250
- return Math.max(0, charsBefore - charsAfter);
1120
+ ): DecisionSavingsEstimate | undefined {
1121
+ const preview = previewDecisionApply(messages, decision, pairIndex);
1122
+ if (!preview) return undefined;
1123
+ if (preview.kind === "tool_result_content") {
1124
+ const tokensRemoved =
1125
+ estimateTokensForContent(preview.before) - estimateTokensForContent(preview.after);
1126
+ return { position: preview.position, tokensRemoved: Math.max(0, tokensRemoved) };
1251
1127
  }
1128
+ const tokensRemoved =
1129
+ estimateTokens(JSON.stringify(preview.beforeArgs)) -
1130
+ estimateTokens(JSON.stringify(preview.afterArgs));
1131
+ return { position: preview.position, tokensRemoved: Math.max(0, tokensRemoved) };
1132
+ }
1252
1133
 
1253
- if (decision.kind === "tool_call_input") {
1254
- if (pair.assistantIndex === undefined || pair.toolCallBlockIndex === undefined)
1255
- return undefined;
1256
- const beforeBlock = (messages[pair.assistantIndex] as MinimalAssistantMessage).content[
1257
- pair.toolCallBlockIndex
1258
- ] as MinimalToolCallContent;
1259
- const afterBlock = (preview[pair.assistantIndex] as MinimalAssistantMessage).content[
1260
- pair.toolCallBlockIndex
1261
- ] as MinimalToolCallContent;
1262
- const charsBefore = JSON.stringify(beforeBlock.arguments ?? {}).length;
1263
- const charsAfter = JSON.stringify(afterBlock.arguments ?? {}).length;
1264
- return Math.max(0, charsBefore - charsAfter);
1134
+ /**
1135
+ * Character savings for one decision: original text or argument JSON minus
1136
+ * the placeholder. Same no-op conditions as `estimateDecisionSavings`.
1137
+ * Clamped at 0 so a longer placeholder is reported as no savings. The
1138
+ * pipeline uses this as the minimum-size floor before the net-benefit gate.
1139
+ */
1140
+ export function estimateDecisionCharsSaved(
1141
+ messages: MinimalMessage[],
1142
+ decision: PruneDecisionRecord,
1143
+ pairIndex?: Map<string, ToolCallPairIndices>,
1144
+ ): number | undefined {
1145
+ const preview = previewDecisionApply(messages, decision, pairIndex);
1146
+ if (!preview) return undefined;
1147
+ if (preview.kind === "tool_result_content") {
1148
+ return Math.max(0, contentTextLength(preview.before) - contentTextLength(preview.after));
1265
1149
  }
1266
-
1267
- return undefined;
1150
+ return Math.max(
1151
+ 0,
1152
+ JSON.stringify(preview.beforeArgs).length - JSON.stringify(preview.afterArgs).length,
1153
+ );
1268
1154
  }
1269
1155
 
1270
1156
  /**
1271
- * Cache-aware cost model (pe-s2ho ticket NOTES, binding over the looser prose
1272
- * in the ticket body): prompt caches are prefix-based, so pruning at message
1273
- * position p invalidates ('busts') the cached tail from p onward exactly
1274
- * once. Modelled per NOTES as:
1157
+ * Cache-aware cost model: prompt caches are prefix-based, so pruning at
1158
+ * message position p invalidates ('busts') the cached tail from p onward
1159
+ * exactly once:
1275
1160
  *
1276
1161
  * penalty ~= (1 - r) * tailTokensAfterEarliestChange
1277
1162
  * recurringSaving ~= r * tokensRemoved
@@ -1326,7 +1211,7 @@ export interface NetBenefitGateResult {
1326
1211
  }
1327
1212
 
1328
1213
  /**
1329
- * Net-benefit gate (pe-s2ho): decides whether fresh, automatic prune
1214
+ * Net-benefit gate: decides whether fresh, automatic prune
1330
1215
  * proposals are worth the one-time cache bust they cause. Batch-aware: since
1331
1216
  * prompt caches are a single linear prefix, every candidate in one pipeline
1332
1217
  * pass is "behind" the earliest changed position and shares that one cache
@@ -1410,11 +1295,6 @@ export function evaluateNetBenefitGate(
1410
1295
  };
1411
1296
  }
1412
1297
 
1413
- // ============================================================================
1414
- // Savings accounting & stats API (consumed by pe-8re9 /context-pruning stats
1415
- // and the pe-e9pv benchmark harness)
1416
- // ============================================================================
1417
-
1418
1298
  export interface PruneStatsRecord {
1419
1299
  idempotencyKey: string;
1420
1300
  strategyId: string;
@@ -1530,14 +1410,8 @@ export function computeContextSizeSnapshot(
1530
1410
  };
1531
1411
  }
1532
1412
 
1533
- // ============================================================================
1534
- // Strategy registry (populated by follow-up tickets pe-u8gd/pe-qs8j)
1535
- // ============================================================================
1536
-
1537
1413
  export interface StrategyProposeInput {
1538
1414
  messages: MinimalMessage[];
1539
- protections: PruneProtections;
1540
- estimateTokens: TokenEstimator;
1541
1415
  /**
1542
1416
  * Full config, so strategies can read their own enable flag and any
1543
1417
  * strategy-specific settings (e.g. error-purge's `minTurnsOld`).
@@ -1546,7 +1420,7 @@ export interface StrategyProposeInput {
1546
1420
  */
1547
1421
  config: DynamicContextPruningConfig;
1548
1422
  /**
1549
- * Session working directory (pe-qs8j), used to normalize relative file
1423
+ * Session working directory, used to normalize relative file
1550
1424
  * paths (e.g. from read/write/edit tool args) against an absolute base so
1551
1425
  * the same file referenced via different relative/absolute spellings is
1552
1426
  * recognized as the same path. Optional/backward-compatible: undefined
@@ -1562,15 +1436,10 @@ export interface PruneStrategy {
1562
1436
  propose(input: StrategyProposeInput): ProposedPrune[];
1563
1437
  }
1564
1438
 
1565
- // ----------------------------------------------------------------------
1566
- // Argument canonicalization (shared by the dedupe strategy; exported for
1567
- // direct unit testing of key-order/nesting/whitespace behavior).
1568
- // ----------------------------------------------------------------------
1569
-
1570
1439
  /**
1571
1440
  * Recursively canonicalize a parsed tool-call arguments value: object keys
1572
1441
  * are sorted (recursively); array order and all string/number/boolean VALUES
1573
- * are left untouched (per ticket: never normalize argument values beyond
1442
+ * are left untouched (never normalize argument values beyond
1574
1443
  * structural key ordering). Serializing the result via JSON.stringify then
1575
1444
  * yields a deterministic, whitespace-free representation regardless of the
1576
1445
  * original key order.
@@ -1596,7 +1465,7 @@ export function buildDedupeKey(toolName: string, args: unknown): string {
1596
1465
  return `${toolName.trim().toLowerCase()}::${canonicalizeArgumentsJSON(args)}`;
1597
1466
  }
1598
1467
 
1599
- /** Exported for /prune picker item building (pe-8re9) and direct unit testing. */
1468
+ /** Exported for /prune picker item building and direct unit testing. */
1600
1469
  export interface ToolCallOccurrence {
1601
1470
  toolCallId: string;
1602
1471
  toolName: string;
@@ -1646,12 +1515,8 @@ export function collectCompletedToolCallOccurrences(
1646
1515
  return occurrences;
1647
1516
  }
1648
1517
 
1649
- // ----------------------------------------------------------------------
1650
- // Strategy 1: deduplication (pe-u8gd)
1651
- // ----------------------------------------------------------------------
1652
-
1653
1518
  /** Placeholder text for a duplicate tool result whose content was replaced. */
1654
- export function buildDedupePlaceholder(toolName: string, newestToolCallId: string): string {
1519
+ function buildDedupePlaceholder(toolName: string, newestToolCallId: string): string {
1655
1520
  return `[pruned by ${EXTENSION_ID}: duplicate ${toolName} call result; see newest occurrence (call ${newestToolCallId})]`;
1656
1521
  }
1657
1522
 
@@ -1688,10 +1553,6 @@ export const dedupeStrategy: PruneStrategy = {
1688
1553
  },
1689
1554
  };
1690
1555
 
1691
- // ----------------------------------------------------------------------
1692
- // Strategy 2: error-input purge (pe-u8gd)
1693
- // ----------------------------------------------------------------------
1694
-
1695
1556
  export const errorPurgeStrategy: PruneStrategy = {
1696
1557
  id: ERROR_PURGE_STRATEGY_ID,
1697
1558
  propose(input: StrategyProposeInput): ProposedPrune[] {
@@ -1716,50 +1577,6 @@ export const errorPurgeStrategy: PruneStrategy = {
1716
1577
  },
1717
1578
  };
1718
1579
 
1719
- // ----------------------------------------------------------------------
1720
- // Strategy 3: superseded file operations (pe-qs8j)
1721
- // ----------------------------------------------------------------------
1722
- //
1723
- // When the same file path is read and/or written multiple times in a
1724
- // session, older read/write/edit tool *outputs* for that path can become
1725
- // stale relative to a later operation. This strategy proposes replacing
1726
- // those older outputs with a placeholder, per the following CONSERVATIVE,
1727
- // binding rules (do not loosen without an architect decision):
1728
- //
1729
- // 1. A full-file read (no offset/limit args) is superseded by a LATER
1730
- // full-file read of the same normalized path.
1731
- // 2. A partial read (offset/limit args present) is NOT superseded by a
1732
- // later partial read UNLESS the later read's range fully covers the
1733
- // earlier one's range. If range coverage can't be reliably determined
1734
- // from the args (e.g. earlier read has no limit — reads to EOF — but
1735
- // the later read is bounded), skip: do not propose supersession.
1736
- // Full-file reads are represented as the range [1, EOF); comparing
1737
- // that representation via the same coverage check naturally satisfies
1738
- // rule 1 as a special case of rule 2 (see `readRangeCovers`).
1739
- // 3. ANY read of a path (full or partial) is superseded once a LATER
1740
- // *successful* (non-error) write/edit to that same path occurs — the
1741
- // old read output is now known-stale regardless of read-vs-read range
1742
- // comparisons. The placeholder explicitly notes the file has since
1743
- // changed. An ERRORED later write/edit does NOT count (the file may be
1744
- // unchanged; conservative).
1745
- // 4. Older write/edit outputs for a path are superseded by a LATER
1746
- // *successful* write/edit to that same path (again, a later errored
1747
- // write/edit never supersedes). Write/edit tool outputs are usually
1748
- // small, so proposals are only ever emitted when there is a non-empty
1749
- // result to prune; the shared net-benefit gate additionally decides
1750
- // whether the estimated savings are worth the cache-bust cost.
1751
- //
1752
- // The NEWEST operation for a given path (by message order) is never
1753
- // superseded/pruned, regardless of kind — there is nothing later to point
1754
- // the placeholder at.
1755
- //
1756
- // Path extraction is defensive: pi's built-in `read`/`write`/`edit` tools
1757
- // all accept a `path` argument (see packages/coding-agent/src/core/tools/
1758
- // {read,write,edit}.ts); some tool-call renderers also fall back to a
1759
- // legacy `file_path` name, and `filePath` is accepted as a further
1760
- // defensive fallback. `bash` is explicitly OUT of scope: this strategy does
1761
- // not attempt to parse shell commands for file paths.
1762
-
1763
1580
  const SUPERSEDED_FILE_OPS_STRATEGY_ID = "superseded-file-ops";
1764
1581
 
1765
1582
  type FileOpKind = "read" | "write" | "edit";
@@ -1916,7 +1733,7 @@ export const supersededFileOpsStrategy: PruneStrategy = {
1916
1733
  // `group` is already in message/chronological order because
1917
1734
  // `collectCompletedToolCallOccurrences` iterates messages in order.
1918
1735
  //
1919
- // Single reverse pass (pe-e0zd): instead of `group.slice(i + 1).find(...)`
1736
+ // Single reverse pass: instead of `group.slice(i + 1).find(...)`
1920
1737
  // per index (O(k^2) allocations+scans for a group of size k), walk the
1921
1738
  // group back-to-front and maintain:
1922
1739
  // - `nearestLaterSuccessfulWrite`: the nearest (chronologically first)
@@ -2040,10 +1857,6 @@ function collectProposals(input: StrategyProposeInput): ProposedPrune[] {
2040
1857
  return proposals;
2041
1858
  }
2042
1859
 
2043
- // ============================================================================
2044
- // Pipeline
2045
- // ============================================================================
2046
-
2047
1860
  export interface PipelineInput {
2048
1861
  messages: MinimalMessage[];
2049
1862
  config: DynamicContextPruningConfig;
@@ -2052,7 +1865,7 @@ export interface PipelineInput {
2052
1865
  knownIdempotencyKeys: ReadonlySet<string>;
2053
1866
  /**
2054
1867
  * Idempotency keys most-recently restored/undone via the /prune picker
2055
- * (pe-8re9; see `resolvePruneTombstoneState`). Fresh AUTOMATIC proposals
1868
+ * (see `resolvePruneTombstoneState`: last event wins). Fresh AUTOMATIC proposals
2056
1869
  * matching one of these keys are dropped so a restored decision does not
2057
1870
  * silently re-apply on the very next context event. Manual re-prunes
2058
1871
  * always use a fresh idempotencyKey namespaced by "manual" (see
@@ -2063,7 +1876,7 @@ export interface PipelineInput {
2063
1876
  restoredIdempotencyKeys?: ReadonlySet<string>;
2064
1877
  /** Omitted defaults to idle. The context handler passes the classified state. */
2065
1878
  agentState?: AgentState;
2066
- /** Session working directory (pe-qs8j), forwarded to strategies for path normalization. */
1879
+ /** Session working directory, forwarded to strategies for path normalization. */
2067
1880
  cwd?: string;
2068
1881
  }
2069
1882
 
@@ -2085,7 +1898,7 @@ export interface PipelineResult {
2085
1898
  * single place that mutates content. Pairing invariants are preserved by
2086
1899
  * construction: messages/blocks are never added or removed, only replaced.
2087
1900
  *
2088
- * Net-benefit gate (pe-s2ho): only NEW, automatic (non-manual) proposals are
1901
+ * Net-benefit gate: only NEW, automatic (non-manual) proposals are
2089
1902
  * gated; already-persisted decisions stay applied (their cache bust already
2090
1903
  * happened) and manual decisions always bypass the gate (user intent wins).
2091
1904
  */
@@ -2116,16 +1929,14 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2116
1929
  input.config.protections.recentTurns,
2117
1930
  );
2118
1931
  // Built once and reused across all protection/savings/apply checks below so
2119
- // each per-decision lookup is O(1) instead of an O(n) rescan of `messages`
2120
- // (pe-e0zd). Positions are stable across the apply loop below (and against
1932
+ // each per-decision lookup is O(1) instead of an O(n) rescan of `messages`.
1933
+ // Positions are stable across the apply loop below (and against
2121
1934
  // `input.messages`) because decisions only ever replace a message's
2122
1935
  // content at its existing index, never insert/remove/reorder messages.
2123
1936
  const pairIndex = buildToolCallPairIndex(messages);
2124
1937
 
2125
1938
  const proposals = collectProposals({
2126
1939
  messages,
2127
- protections: input.config.protections,
2128
- estimateTokens: estimateTokensForText,
2129
1940
  config: input.config,
2130
1941
  cwd: input.cwd,
2131
1942
  });
@@ -2154,18 +1965,12 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2154
1965
  const freshManual = dedupedFresh.filter((decision) => decision.source === "manual");
2155
1966
  const freshAutomaticUnfiltered = dedupedFresh.filter((decision) => decision.source !== "manual");
2156
1967
 
2157
- // Minimum-size floor (pe-qdzb): drop fresh automatic proposals whose
2158
- // character savings (original content chars minus placeholder chars) fall
2159
- // below `thresholds.minCharsSaved`, BEFORE gate evaluation so dropped
2160
- // candidates never consume batch/earliest-position gate consideration.
2161
- // Applies to automatic proposals only; manual (`freshManual` above) and
2162
- // already-persisted decisions (`input.persistedDecisions`, applied later)
2163
- // bypass this floor entirely, same as the net-benefit gate. Tool-result
2164
- // candidates containing images also bypass this raw-text floor because
2165
- // images have no character count; their token savings still go through the
2166
- // existing net-benefit gate. A floor of 0 (the config default before
2167
- // pe-c5n9's DEFAULT_MIN_CHARS_SAVED constant was introduced) is a no-op:
2168
- // every non-negative delta clears it.
1968
+ // Minimum-size floor: drop fresh automatic proposals whose character
1969
+ // savings fall below `thresholds.minCharsSaved` BEFORE gate evaluation, so
1970
+ // dropped candidates never consume batch/earliest-position consideration.
1971
+ // Manual and already-persisted decisions bypass this floor. Image-bearing
1972
+ // tool results bypass it too (no character count) and still hit the token
1973
+ // gate. A floor of 0 is a no-op.
2169
1974
  const minCharsSaved = input.config.thresholds.minCharsSaved;
2170
1975
  const freshAutomatic =
2171
1976
  minCharsSaved <= 0
@@ -2177,7 +1982,7 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2177
1982
  });
2178
1983
 
2179
1984
  // Collapse fresh AUTOMATIC candidates that target the same (kind,
2180
- // correlation) down to a single decision (pe-j7sb). Two strategies (e.g.
1985
+ // correlation) down to a single decision. Two strategies (e.g.
2181
1986
  // `dedupe` and `superseded-file-ops`) can independently propose a decision
2182
1987
  // for the exact same tool result; their idempotencyKeys differ (each
2183
1988
  // embeds its own strategyId) so the self-dedup above does not catch this,
@@ -2216,12 +2021,9 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2216
2021
  // target's tokensRemoved even though the apply-loop guard further down
2217
2022
  // correctly skips applying the fresh candidate — corrupting the gate's
2218
2023
  // net-benefit math (and potentially flipping ACCEPT/REJECT) even though no
2219
- // double-apply occurs. Only decisions with `correlation.type ===
2220
- // "toolCallId"` are applyable targets worth tracking here; anything else is
2221
- // ignored safely (it cannot collide with a toolCallId-keyed target).
2024
+ // double-apply occurs.
2222
2025
  const claimedTargetKeys = new Set<string>();
2223
2026
  for (const decision of [...input.persistedDecisions, ...freshManual]) {
2224
- if (decision.correlation.type !== "toolCallId") continue;
2225
2027
  claimedTargetKeys.add(buildDecisionTargetKey(decision));
2226
2028
  }
2227
2029
 
@@ -2269,8 +2071,8 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2269
2071
  // `applyPruneDecision` unconditionally re-applies its placeholder even when
2270
2072
  // the target is already a placeholder and reports `applied: true`, so
2271
2073
  // without this guard the second candidate would silently overwrite the
2272
- // first's placeholder AND still be credited full stats/newlyAppliedDecisions
2273
- // (pe-j7sb). The pre-gate collapse above already prevents this among fresh
2074
+ // first's placeholder AND still be credited full stats/newlyAppliedDecisions.
2075
+ // The pre-gate collapse above already prevents this among fresh
2274
2076
  // automatic candidates; this guard additionally covers persisted-vs-fresh
2275
2077
  // (and any other) overlap within a single call.
2276
2078
  const appliedTargetKeys = new Set<string>();
@@ -2310,10 +2112,6 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2310
2112
  return { messages, newlyAppliedDecisions, newlyAppliedStats, contextSizeSnapshot, gate };
2311
2113
  }
2312
2114
 
2313
- // ============================================================================
2314
- // /prune picker helpers (pe-8re9)
2315
- // ============================================================================
2316
-
2317
2115
  function resolveSessionProjection(ctx: ExtensionContext): MinimalSessionProjection | undefined {
2318
2116
  try {
2319
2117
  const sessionManager = ctx.sessionManager as unknown as {
@@ -2331,7 +2129,7 @@ export function sessionEntriesToMessages(
2331
2129
  entries: MinimalSessionEntry[],
2332
2130
  projection?: MinimalSessionProjection,
2333
2131
  ): MinimalMessage[] {
2334
- if (projection) return sessionProjectionToMessages(projection);
2132
+ if (projection) return projection.entries.flatMap((entry) => entry.messages);
2335
2133
  const messages: MinimalMessage[] = [];
2336
2134
  for (const entry of entries) {
2337
2135
  if (entry.type === "message" && entry.message) messages.push(entry.message);
@@ -2364,14 +2162,12 @@ export interface PrunableItem {
2364
2162
  * for manual prune/restore. `activeByToolCallId` should be built from
2365
2163
  * currently-ACTIVE decisions only (kind: "tool_result_content"), keyed by
2366
2164
  * toolCallId; `restoredToolCallIds` from currently-restored keys' underlying
2367
- * toolCallId (see `buildActiveResultDecisionMap`/`extractToolCallIdFromDecisionKey`
2368
- * usage in the extension wiring).
2165
+ * toolCallId (see `buildActiveResultDecisionMap` in the extension wiring).
2369
2166
  */
2370
2167
  export function buildPrunableItems(
2371
2168
  messages: MinimalMessage[],
2372
2169
  activeByToolCallId: Map<string, PruneDecisionRecord>,
2373
2170
  restoredToolCallIds: ReadonlySet<string>,
2374
- _estimateTokens: TokenEstimator = estimateTokensForText,
2375
2171
  ): PrunableItem[] {
2376
2172
  const items: PrunableItem[] = [];
2377
2173
  for (const occurrence of collectCompletedToolCallOccurrences(messages)) {
@@ -2404,7 +2200,6 @@ export function buildActiveResultDecisionMap(
2404
2200
  const map = new Map<string, PruneDecisionRecord>();
2405
2201
  for (const decision of activeDecisions) {
2406
2202
  if (decision.kind !== "tool_result_content") continue;
2407
- if (decision.correlation.type !== "toolCallId") continue;
2408
2203
  if (!map.has(decision.correlation.toolCallId))
2409
2204
  map.set(decision.correlation.toolCallId, decision);
2410
2205
  }
@@ -2466,10 +2261,6 @@ export function formatPrunableItemsReport(items: PrunableItem[]): string[] {
2466
2261
  return items.map((item, index) => `${index + 1}. ${formatPrunableItemOption(item)}`);
2467
2262
  }
2468
2263
 
2469
- // ============================================================================
2470
- // /context-pruning control + status/stats helpers (pe-8re9)
2471
- // ============================================================================
2472
-
2473
2264
  export type StrategyKey = "dedupe" | "errorPurge" | "supersededFileOps";
2474
2265
 
2475
2266
  const STRATEGY_ALIASES: Record<string, StrategyKey> = {
@@ -2659,10 +2450,6 @@ export function formatStatsReport(stats: CumulativePruneStats): string[] {
2659
2450
  return lines;
2660
2451
  }
2661
2452
 
2662
- // ============================================================================
2663
- // Extension wiring
2664
- // ============================================================================
2665
-
2666
2453
  function notify(
2667
2454
  ctx: ExtensionContext,
2668
2455
  message: string,
@@ -2722,9 +2509,6 @@ export default function dynamicContextPruningExtension(pi: ExtensionAPI) {
2722
2509
  persistedDecisions,
2723
2510
  knownIdempotencyKeys,
2724
2511
  restoredIdempotencyKeys,
2725
- // Real mid-loop/idle agent-state detection (pe-zy4s): derived straight from
2726
- // the message payload via classifyAgentStateFromMessages -- see its doc
2727
- // comment for the exact runtime-observable definition.
2728
2512
  agentState: classifyAgentStateFromMessages(event.messages as unknown as MinimalMessage[]),
2729
2513
  cwd: ctx.sessionManager.getCwd(),
2730
2514
  });
@@ -2805,11 +2589,8 @@ export default function dynamicContextPruningExtension(pi: ExtensionAPI) {
2805
2589
  const activeByToolCallId = buildActiveResultDecisionMap(persistedDecisions);
2806
2590
  const restoredToolCallIds = new Set(
2807
2591
  Array.from(lastDecisionByKey.values())
2808
- .filter(
2809
- (d) =>
2810
- d.correlation.type === "toolCallId" && restoredIdempotencyKeys.has(d.idempotencyKey),
2811
- )
2812
- .map((d) => (d.correlation as { type: "toolCallId"; toolCallId: string }).toolCallId),
2592
+ .filter((d) => restoredIdempotencyKeys.has(d.idempotencyKey))
2593
+ .map((d) => d.correlation.toolCallId),
2813
2594
  );
2814
2595
  const items = buildPrunableItems(messages, activeByToolCallId, restoredToolCallIds);
2815
2596
 
@@ -2839,12 +2620,8 @@ export default function dynamicContextPruningExtension(pi: ExtensionAPI) {
2839
2620
  const latestActive = buildActiveResultDecisionMap(persistedDecisions);
2840
2621
  const latestRestoredToolCallIds = new Set(
2841
2622
  Array.from(lastDecisionByKey.values())
2842
- .filter(
2843
- (d) =>
2844
- d.correlation.type === "toolCallId" &&
2845
- restoredIdempotencyKeys.has(d.idempotencyKey),
2846
- )
2847
- .map((d) => (d.correlation as { type: "toolCallId"; toolCallId: string }).toolCallId),
2623
+ .filter((d) => restoredIdempotencyKeys.has(d.idempotencyKey))
2624
+ .map((d) => d.correlation.toolCallId),
2848
2625
  );
2849
2626
  const latestItems = buildPrunableItems(
2850
2627
  latestMessages,