@diegopetrucci/pi-extensions 0.1.76 → 0.1.78

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 (53) 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 +277 -44
  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/package.json +1 -1
  7. package/extensions/annotate-last-message/package.json +1 -1
  8. package/extensions/annotate-last-message/web/index.html +3 -3
  9. package/extensions/code-reviewer/README.md +5 -5
  10. package/extensions/code-reviewer/index.ts +258 -54
  11. package/extensions/code-reviewer/package.json +1 -1
  12. package/extensions/confirm-destructive/index.ts +2 -8
  13. package/extensions/confirm-destructive/package.json +1 -1
  14. package/extensions/context-cap/README.md +9 -9
  15. package/extensions/context-cap/index.ts +3 -3
  16. package/extensions/context-cap/package.json +2 -2
  17. package/extensions/contrarian/README.md +4 -3
  18. package/extensions/contrarian/index.ts +47 -40
  19. package/extensions/contrarian/package.json +1 -1
  20. package/extensions/dirty-repo-guard/index.ts +0 -4
  21. package/extensions/dirty-repo-guard/package.json +1 -1
  22. package/extensions/dynamic-context-pruning/README.md +5 -5
  23. package/extensions/dynamic-context-pruning/index.ts +114 -232
  24. package/extensions/dynamic-context-pruning/package.json +1 -1
  25. package/extensions/fast/README.md +2 -2
  26. package/extensions/fast/package.json +1 -1
  27. package/extensions/inline-bash/index.ts +0 -16
  28. package/extensions/inline-bash/package.json +1 -1
  29. package/extensions/librarian/README.md +1 -1
  30. package/extensions/librarian/index.ts +247 -39
  31. package/extensions/librarian/package.json +1 -1
  32. package/extensions/minimal-footer/README.md +5 -5
  33. package/extensions/minimal-footer/index.ts +3 -10
  34. package/extensions/minimal-footer/minimal-footer.example.json +1 -1
  35. package/extensions/minimal-footer/package.json +1 -1
  36. package/extensions/oracle/README.md +4 -3
  37. package/extensions/oracle/index.ts +45 -38
  38. package/extensions/oracle/package.json +1 -1
  39. package/extensions/permission-gate/README.md +3 -0
  40. package/extensions/permission-gate/index.ts +151 -28
  41. package/extensions/permission-gate/package.json +1 -1
  42. package/extensions/project-mcp-json/README.md +2 -2
  43. package/extensions/project-mcp-json/package.json +1 -1
  44. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  45. package/extensions/quiet-tools/README.md +30 -19
  46. package/extensions/quiet-tools/index.ts +233 -166
  47. package/extensions/quiet-tools/package.json +2 -2
  48. package/extensions/todo/index.ts +0 -19
  49. package/extensions/todo/package.json +1 -1
  50. package/extensions/triage-comments/README.md +4 -2
  51. package/extensions/triage-comments/index.ts +349 -29
  52. package/extensions/triage-comments/package.json +1 -1
  53. 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";
@@ -149,21 +146,17 @@ export function sessionProjectionToMessages(
149
146
  return projection.entries.flatMap((entry) => entry.messages);
150
147
  }
151
148
 
152
- // ============================================================================
153
- // Constants
154
- // ============================================================================
155
-
156
149
  const EXTENSION_ID = "dynamic-context-pruning";
157
150
  const CONFIG_FILE_NAME = "dynamic-context-pruning.json";
158
151
  const DECISION_ENTRY_TYPE = "dynamic-context-pruning:decision";
159
152
  const STATS_ENTRY_TYPE = "dynamic-context-pruning:stats";
160
153
  /**
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.
154
+ * Restore/undo entries: a lightweight tombstone that records "the decision
155
+ * with this idempotencyKey should be treated as reverted". See
156
+ * `resolvePruneTombstoneState` for the chronological last-event-wins rule.
164
157
  */
165
158
  const RESTORE_ENTRY_TYPE = "dynamic-context-pruning:restore";
166
- /** strategyId used for user-initiated prunes from the /prune picker (pe-8re9). Always source:"manual". */
159
+ /** strategyId used for user-initiated prunes from the /prune picker. Always source:"manual". */
167
160
  const MANUAL_STRATEGY_ID = "manual";
168
161
 
169
162
  const DEFAULT_RECENT_TURNS = 4;
@@ -173,7 +166,7 @@ const DEFAULT_CACHED_PRICE_RATIO = 0.1;
173
166
 
174
167
  /**
175
168
  * Default net-benefit gate threshold (max amortization calls). Recalibrated
176
- * (pe-c5n9) from the representative-corpus benchmark (~/.the-last-harness/
169
+ * from the representative-corpus benchmark (~/.the-last-harness/
177
170
  * agent/sessions, 1,390+ session files, 556 candidates): this is the
178
171
  * hindsight-optimal break-even T at cachedPriceRatio r=0.1 (aggressive
179
172
  * prompt caching, the common Anthropic case). IMPORTANT caveats:
@@ -189,7 +182,7 @@ const DEFAULT_CACHED_PRICE_RATIO = 0.1;
189
182
  const DEFAULT_BREAK_EVEN_THRESHOLD = 22;
190
183
 
191
184
  /**
192
- * Default mid_loop break-even threshold (pe-zy4s follow-up). The package's own
185
+ * Default mid_loop break-even threshold. The package's own
193
186
  * representative-corpus benchmark (turn-START state definition — the same one
194
187
  * the runtime `context` handler uses) favored rejecting mid_loop candidates
195
188
  * at cachedPriceRatio r=0.1 (mid_loop-optimal T=1, total realized
@@ -240,10 +233,6 @@ const DEFAULT_ERROR_PURGE_MIN_TURNS_OLD = 4;
240
233
  const DEDUPE_STRATEGY_ID = "dedupe";
241
234
  const ERROR_PURGE_STRATEGY_ID = "error-purge";
242
235
 
243
- // ============================================================================
244
- // Config
245
- // ============================================================================
246
-
247
236
  export interface PruneProtections {
248
237
  /** Tool names (case-insensitive) whose calls are never pruned. */
249
238
  toolNames: string[];
@@ -257,7 +246,7 @@ export interface PruneThresholds {
257
246
  /**
258
247
  * Minimum raw text characters a fresh AUTOMATIC prune proposal must remove
259
248
  * (original text chars minus placeholder chars) to be considered at all
260
- * (pe-qdzb). Enforced by `runDynamicContextPruningPipeline` BEFORE the
249
+ * Enforced by `runDynamicContextPruningPipeline` BEFORE the
261
250
  * net-benefit gate (see `PruneGateConfig`/`gate`, which uses break-even token
262
251
  * math and is a separate, later check): text-only candidates below this floor
263
252
  * are dropped up front and never reach gate/batch consideration. Image-bearing
@@ -269,7 +258,7 @@ export interface PruneThresholds {
269
258
  minCharsSaved: number;
270
259
  }
271
260
 
272
- /** Per-strategy toggles/config (pe-u8gd, pe-qs8j). Each strategy can be disabled independently. */
261
+ /** Per-strategy toggles/config. Each strategy can be disabled independently. */
273
262
  export interface StrategiesConfig {
274
263
  dedupe: { enabled: boolean };
275
264
  errorPurge: {
@@ -277,11 +266,10 @@ export interface StrategiesConfig {
277
266
  /** A tool call's input is only eligible for purging once it is older than this many turns. */
278
267
  minTurnsOld: number;
279
268
  };
280
- /** Superseded file-ops strategy (pe-qs8j): stale read/write/edit outputs replaced by a placeholder. */
269
+ /** Superseded file-ops strategy: stale read/write/edit outputs replaced by a placeholder. */
281
270
  supersededFileOps: { enabled: boolean };
282
271
  }
283
272
 
284
- /** Net-benefit gate operating mode (pe-s2ho). */
285
273
  export type GateMode = "on" | "off" | "always-apply";
286
274
 
287
275
  /**
@@ -484,11 +472,6 @@ async function writeConfig(config: DynamicContextPruningConfig): Promise<void> {
484
472
  await fs.writeFile(configPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
485
473
  }
486
474
 
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
475
  export function estimateTokensForText(text: string): number {
493
476
  if (!text) return 0;
494
477
  return Math.max(0, Math.ceil(text.length / 4));
@@ -510,7 +493,7 @@ export function estimateTokensForContent(
510
493
  export type TokenEstimator = (text: string) => number;
511
494
 
512
495
  /**
513
- * Classify the agent's current state from the message payload alone (pe-zy4s):
496
+ * Classify the agent's current state from the message payload alone:
514
497
  * this is the runtime-observable definition used both by the live `context`
515
498
  * event handler and (via re-export) by the offline benchmark, so the two can
516
499
  * never drift apart.
@@ -527,7 +510,7 @@ export type TokenEstimator = (text: string) => number;
527
510
  * default: nothing suggests an in-progress loop).
528
511
  *
529
512
  * The benchmark (scripts/benchmark.mjs) reclassifies its candidate labels to
530
- * this SAME definition (pe-zy4s), reusing this function directly, so the
513
+ * this SAME definition, reusing this function directly, so the
531
514
  * defaults below rest on evidence measured with the exact definition the
532
515
  * runtime observes. (An earlier turn-end-based benchmark definition -- "idle"
533
516
  * meant "this IS the turn's last assistant message", only knowable in
@@ -544,7 +527,7 @@ export function classifyAgentStateFromMessages(messages: MinimalMessage[]): Agen
544
527
  return "idle";
545
528
  }
546
529
 
547
- /** Resolve which break-even threshold applies for a given agent state (pe-s2ho state-conditioning hook). */
530
+ /** Resolve which break-even threshold applies for a given agent state. */
548
531
  export function resolveBreakEvenThreshold(
549
532
  gateConfig: PruneGateConfig,
550
533
  agentState: AgentState,
@@ -578,7 +561,7 @@ function estimateMessageTokens(message: MinimalMessage, estimateTokens: TokenEst
578
561
 
579
562
  /**
580
563
  * 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/
564
+ * Used both for cache-bust tail sizing and for raw/
582
565
  * effective context-size snapshots. Clamped to valid indices; an out-of-range
583
566
  * `fromIndex` (e.g. the very last message, or beyond) degrades gracefully.
584
567
  */
@@ -595,10 +578,6 @@ export function estimateTailTokens(
595
578
  return total;
596
579
  }
597
580
 
598
- // ============================================================================
599
- // Protections
600
- // ============================================================================
601
-
602
581
  function globToRegExp(glob: string): RegExp {
603
582
  let pattern = "^";
604
583
  for (let i = 0; i < glob.length; i++) {
@@ -706,10 +685,6 @@ export function computeTurnsElapsedSince(messages: MinimalMessage[], index: numb
706
685
  return count;
707
686
  }
708
687
 
709
- // ============================================================================
710
- // Correlation
711
- // ============================================================================
712
-
713
688
  export interface ToolCallPairIndices {
714
689
  assistantIndex?: number;
715
690
  toolCallBlockIndex?: number;
@@ -722,7 +697,7 @@ export interface ToolCallPairIndices {
722
697
  * toolCallIds against the same `messages` array (e.g. the pipeline's
723
698
  * per-decision protection/savings/apply passes) should build this once and
724
699
  * pass it to `findToolCallPairIndices` and friends to avoid an O(n) rescan
725
- * per decision (pe-e0zd).
700
+ * per decision.
726
701
  */
727
702
  export function buildToolCallPairIndex(
728
703
  messages: MinimalMessage[],
@@ -798,10 +773,6 @@ export function findToolCallPairIndices(
798
773
  return pair;
799
774
  }
800
775
 
801
- // ============================================================================
802
- // Proposals & persisted decisions
803
- // ============================================================================
804
-
805
776
  export type PruneTargetKind = "tool_result_content" | "tool_call_input";
806
777
 
807
778
  /**
@@ -826,7 +797,7 @@ export interface ProposedPrune {
826
797
  export interface PruneDecisionRecord {
827
798
  idempotencyKey: string;
828
799
  strategyId: string;
829
- correlation: { type: "toolCallId"; toolCallId: string } | { type: "entryId"; entryId: string };
800
+ correlation: { type: "toolCallId"; toolCallId: string };
830
801
  kind: PruneTargetKind;
831
802
  reason: string;
832
803
  placeholder?: string;
@@ -843,15 +814,11 @@ export function buildIdempotencyKey(proposal: ProposedPrune): string {
843
814
  * omitting `strategyId` (unlike `idempotencyKey`). Two decisions from
844
815
  * different strategies that both replace the same tool result's content
845
816
  * share this key even though their idempotencyKeys differ — that's exactly
846
- * the overlap pe-j7sb collapses before gating/apply (see
817
+ * the overlap the pipeline collapses before gating/apply (see
847
818
  * `runDynamicContextPruningPipeline`).
848
819
  */
849
820
  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}`;
821
+ return `${decision.kind}:${decision.correlation.type}:${decision.correlation.toolCallId}`;
855
822
  }
856
823
 
857
824
  export function proposalToDecisionRecord(
@@ -885,8 +852,6 @@ export function parseDecisionRecord(data: unknown): PruneDecisionRecord | undefi
885
852
  let correlation: PruneDecisionRecord["correlation"] | undefined;
886
853
  if (rawCorrelation?.type === "toolCallId" && typeof rawCorrelation.toolCallId === "string") {
887
854
  correlation = { type: "toolCallId", toolCallId: rawCorrelation.toolCallId };
888
- } else if (rawCorrelation?.type === "entryId" && typeof rawCorrelation.entryId === "string") {
889
- correlation = { type: "entryId", entryId: rawCorrelation.entryId };
890
855
  }
891
856
  if (!correlation) return undefined;
892
857
 
@@ -902,10 +867,6 @@ export function parseDecisionRecord(data: unknown): PruneDecisionRecord | undefi
902
867
  };
903
868
  }
904
869
 
905
- // ============================================================================
906
- // Restore / undo (pe-8re9): tombstones that reverse a persisted decision
907
- // ============================================================================
908
-
909
870
  /**
910
871
  * A restore record is a tombstone: it records that the decision identified by
911
872
  * `idempotencyKey` should stop being applied, WITHOUT deleting or mutating
@@ -1013,10 +974,6 @@ export function resolvePruneTombstoneState(entries: MinimalSessionEntry[]): Prun
1013
974
  return { activeDecisions, activeIdempotencyKeys, restoredKeys, lastDecisionByKey };
1014
975
  }
1015
976
 
1016
- // ============================================================================
1017
- // Apply step (content-replacement only; never drops messages)
1018
- // ============================================================================
1019
-
1020
977
  export function buildPlaceholderText(reason: string, charsRemoved: number): string {
1021
978
  return `[pruned by ${EXTENSION_ID}: ${reason} (${charsRemoved} chars removed)]`;
1022
979
  }
@@ -1031,8 +988,7 @@ function isImageBearingToolResultTarget(
1031
988
  decision: PruneDecisionRecord,
1032
989
  pairIndex?: Map<string, ToolCallPairIndices>,
1033
990
  ): boolean {
1034
- if (decision.kind !== "tool_result_content" || decision.correlation.type !== "toolCallId")
1035
- return false;
991
+ if (decision.kind !== "tool_result_content") return false;
1036
992
  const pair = findToolCallPairIndices(messages, decision.correlation.toolCallId, pairIndex);
1037
993
  if (pair.resultIndex === undefined) return false;
1038
994
  const target = messages[pair.resultIndex];
@@ -1056,11 +1012,6 @@ export function applyPruneDecision(
1056
1012
  decision: PruneDecisionRecord,
1057
1013
  pairIndex?: Map<string, ToolCallPairIndices>,
1058
1014
  ): 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
1015
  const pair = findToolCallPairIndices(messages, decision.correlation.toolCallId, pairIndex);
1065
1016
 
1066
1017
  if (decision.kind === "tool_result_content") {
@@ -1081,7 +1032,7 @@ export function applyPruneDecision(
1081
1032
  }
1082
1033
  if (pair.resultIndex === undefined) return { applied: false, charsRemoved: 0 };
1083
1034
  const resultMessage = messages[pair.resultIndex] as MinimalToolResultMessage;
1084
- // Only errored-call inputs may ever be redacted (ticket invariant).
1035
+ // Only errored-call inputs may ever be redacted.
1085
1036
  if (!resultMessage.isError) return { applied: false, charsRemoved: 0 };
1086
1037
 
1087
1038
  const assistantMessage = messages[pair.assistantIndex] as MinimalAssistantMessage;
@@ -1132,7 +1083,6 @@ export function isDecisionProtected(
1132
1083
  recencyBoundaryIndex: number,
1133
1084
  pairIndex?: Map<string, ToolCallPairIndices>,
1134
1085
  ): boolean {
1135
- if (decision.correlation.type !== "toolCallId") return true; // no safe way to check protections yet
1136
1086
  const pair = findToolCallPairIndices(messages, decision.correlation.toolCallId, pairIndex);
1137
1087
  if (
1138
1088
  pair.resultIndex !== undefined &&
@@ -1154,10 +1104,6 @@ export function isDecisionProtected(
1154
1104
  return false;
1155
1105
  }
1156
1106
 
1157
- // ============================================================================
1158
- // Savings estimation, cache cost model & net-benefit gate (pe-s2ho)
1159
- // ============================================================================
1160
-
1161
1107
  export interface DecisionSavingsEstimate {
1162
1108
  /** Earliest message index this decision changes; used for cache-bust tail sizing. */
1163
1109
  position: number;
@@ -1165,36 +1111,44 @@ export interface DecisionSavingsEstimate {
1165
1111
  tokensRemoved: number;
1166
1112
  }
1167
1113
 
1114
+ type DecisionApplyPreview =
1115
+ | {
1116
+ kind: "tool_result_content";
1117
+ position: number;
1118
+ before: (MinimalTextContent | MinimalImageContent)[];
1119
+ after: (MinimalTextContent | MinimalImageContent)[];
1120
+ }
1121
+ | {
1122
+ kind: "tool_call_input";
1123
+ position: number;
1124
+ beforeArgs: Record<string, unknown>;
1125
+ afterArgs: Record<string, unknown>;
1126
+ };
1127
+
1168
1128
  /**
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).
1129
+ * Non-mutating before/after for one decision. A shallow array copy keeps the
1130
+ * caller's messages intact; pair-index positions stay valid because slice()
1131
+ * preserves length and order. Undefined when the target is absent or apply
1132
+ * would no-op.
1173
1133
  */
1174
- export function estimateDecisionSavings(
1134
+ function previewDecisionApply(
1175
1135
  messages: MinimalMessage[],
1176
1136
  decision: PruneDecisionRecord,
1177
- estimateTokens: TokenEstimator = estimateTokensForText,
1178
1137
  pairIndex?: Map<string, ToolCallPairIndices>,
1179
- ): DecisionSavingsEstimate | undefined {
1180
- if (decision.correlation.type !== "toolCallId") return undefined;
1138
+ ): DecisionApplyPreview | undefined {
1181
1139
  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
1140
  const preview = messages.slice();
1188
1141
  const result = applyPruneDecision(preview, decision, pairIndex);
1189
1142
  if (!result.applied) return undefined;
1190
1143
 
1191
1144
  if (decision.kind === "tool_result_content") {
1192
1145
  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) };
1146
+ return {
1147
+ kind: "tool_result_content",
1148
+ position: pair.resultIndex,
1149
+ before: (messages[pair.resultIndex] as MinimalToolResultMessage).content,
1150
+ after: (preview[pair.resultIndex] as MinimalToolResultMessage).content,
1151
+ };
1198
1152
  }
1199
1153
 
1200
1154
  if (decision.kind === "tool_call_input") {
@@ -1206,11 +1160,11 @@ export function estimateDecisionSavings(
1206
1160
  const afterBlock = (preview[pair.assistantIndex] as MinimalAssistantMessage).content[
1207
1161
  pair.toolCallBlockIndex
1208
1162
  ] as MinimalToolCallContent;
1209
- const tokensBefore = estimateTokens(JSON.stringify(beforeBlock.arguments ?? {}));
1210
- const tokensAfter = estimateTokens(JSON.stringify(afterBlock.arguments ?? {}));
1211
1163
  return {
1164
+ kind: "tool_call_input",
1212
1165
  position: pair.assistantIndex,
1213
- tokensRemoved: Math.max(0, tokensBefore - tokensAfter),
1166
+ beforeArgs: beforeBlock.arguments ?? {},
1167
+ afterArgs: afterBlock.arguments ?? {},
1214
1168
  };
1215
1169
  }
1216
1170
 
@@ -1218,60 +1172,56 @@ export function estimateDecisionSavings(
1218
1172
  }
1219
1173
 
1220
1174
  /**
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.
1175
+ * Estimate the token savings and affected position of a single decision
1176
+ * without mutating the caller's `messages`. Returns undefined when the
1177
+ * decision's target is absent or the decision would not actually apply
1178
+ * (mirrors `applyPruneDecision`'s no-op conditions).
1230
1179
  */
1231
- export function estimateDecisionCharsSaved(
1180
+ export function estimateDecisionSavings(
1232
1181
  messages: MinimalMessage[],
1233
1182
  decision: PruneDecisionRecord,
1183
+ estimateTokens: TokenEstimator = estimateTokensForText,
1234
1184
  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);
1185
+ ): DecisionSavingsEstimate | undefined {
1186
+ const preview = previewDecisionApply(messages, decision, pairIndex);
1187
+ if (!preview) return undefined;
1188
+ if (preview.kind === "tool_result_content") {
1189
+ const tokensRemoved =
1190
+ estimateTokensForContent(preview.before) - estimateTokensForContent(preview.after);
1191
+ return { position: preview.position, tokensRemoved: Math.max(0, tokensRemoved) };
1251
1192
  }
1193
+ const tokensRemoved =
1194
+ estimateTokens(JSON.stringify(preview.beforeArgs)) -
1195
+ estimateTokens(JSON.stringify(preview.afterArgs));
1196
+ return { position: preview.position, tokensRemoved: Math.max(0, tokensRemoved) };
1197
+ }
1252
1198
 
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);
1199
+ /**
1200
+ * Character savings for one decision: original text or argument JSON minus
1201
+ * the placeholder. Same no-op conditions as `estimateDecisionSavings`.
1202
+ * Clamped at 0 so a longer placeholder is reported as no savings. The
1203
+ * pipeline uses this as the minimum-size floor before the net-benefit gate.
1204
+ */
1205
+ export function estimateDecisionCharsSaved(
1206
+ messages: MinimalMessage[],
1207
+ decision: PruneDecisionRecord,
1208
+ pairIndex?: Map<string, ToolCallPairIndices>,
1209
+ ): number | undefined {
1210
+ const preview = previewDecisionApply(messages, decision, pairIndex);
1211
+ if (!preview) return undefined;
1212
+ if (preview.kind === "tool_result_content") {
1213
+ return Math.max(0, contentTextLength(preview.before) - contentTextLength(preview.after));
1265
1214
  }
1266
-
1267
- return undefined;
1215
+ return Math.max(
1216
+ 0,
1217
+ JSON.stringify(preview.beforeArgs).length - JSON.stringify(preview.afterArgs).length,
1218
+ );
1268
1219
  }
1269
1220
 
1270
1221
  /**
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:
1222
+ * Cache-aware cost model: prompt caches are prefix-based, so pruning at
1223
+ * message position p invalidates ('busts') the cached tail from p onward
1224
+ * exactly once:
1275
1225
  *
1276
1226
  * penalty ~= (1 - r) * tailTokensAfterEarliestChange
1277
1227
  * recurringSaving ~= r * tokensRemoved
@@ -1326,7 +1276,7 @@ export interface NetBenefitGateResult {
1326
1276
  }
1327
1277
 
1328
1278
  /**
1329
- * Net-benefit gate (pe-s2ho): decides whether fresh, automatic prune
1279
+ * Net-benefit gate: decides whether fresh, automatic prune
1330
1280
  * proposals are worth the one-time cache bust they cause. Batch-aware: since
1331
1281
  * prompt caches are a single linear prefix, every candidate in one pipeline
1332
1282
  * pass is "behind" the earliest changed position and shares that one cache
@@ -1410,11 +1360,6 @@ export function evaluateNetBenefitGate(
1410
1360
  };
1411
1361
  }
1412
1362
 
1413
- // ============================================================================
1414
- // Savings accounting & stats API (consumed by pe-8re9 /context-pruning stats
1415
- // and the pe-e9pv benchmark harness)
1416
- // ============================================================================
1417
-
1418
1363
  export interface PruneStatsRecord {
1419
1364
  idempotencyKey: string;
1420
1365
  strategyId: string;
@@ -1530,14 +1475,8 @@ export function computeContextSizeSnapshot(
1530
1475
  };
1531
1476
  }
1532
1477
 
1533
- // ============================================================================
1534
- // Strategy registry (populated by follow-up tickets pe-u8gd/pe-qs8j)
1535
- // ============================================================================
1536
-
1537
1478
  export interface StrategyProposeInput {
1538
1479
  messages: MinimalMessage[];
1539
- protections: PruneProtections;
1540
- estimateTokens: TokenEstimator;
1541
1480
  /**
1542
1481
  * Full config, so strategies can read their own enable flag and any
1543
1482
  * strategy-specific settings (e.g. error-purge's `minTurnsOld`).
@@ -1546,7 +1485,7 @@ export interface StrategyProposeInput {
1546
1485
  */
1547
1486
  config: DynamicContextPruningConfig;
1548
1487
  /**
1549
- * Session working directory (pe-qs8j), used to normalize relative file
1488
+ * Session working directory, used to normalize relative file
1550
1489
  * paths (e.g. from read/write/edit tool args) against an absolute base so
1551
1490
  * the same file referenced via different relative/absolute spellings is
1552
1491
  * recognized as the same path. Optional/backward-compatible: undefined
@@ -1562,15 +1501,10 @@ export interface PruneStrategy {
1562
1501
  propose(input: StrategyProposeInput): ProposedPrune[];
1563
1502
  }
1564
1503
 
1565
- // ----------------------------------------------------------------------
1566
- // Argument canonicalization (shared by the dedupe strategy; exported for
1567
- // direct unit testing of key-order/nesting/whitespace behavior).
1568
- // ----------------------------------------------------------------------
1569
-
1570
1504
  /**
1571
1505
  * Recursively canonicalize a parsed tool-call arguments value: object keys
1572
1506
  * are sorted (recursively); array order and all string/number/boolean VALUES
1573
- * are left untouched (per ticket: never normalize argument values beyond
1507
+ * are left untouched (never normalize argument values beyond
1574
1508
  * structural key ordering). Serializing the result via JSON.stringify then
1575
1509
  * yields a deterministic, whitespace-free representation regardless of the
1576
1510
  * original key order.
@@ -1596,7 +1530,7 @@ export function buildDedupeKey(toolName: string, args: unknown): string {
1596
1530
  return `${toolName.trim().toLowerCase()}::${canonicalizeArgumentsJSON(args)}`;
1597
1531
  }
1598
1532
 
1599
- /** Exported for /prune picker item building (pe-8re9) and direct unit testing. */
1533
+ /** Exported for /prune picker item building and direct unit testing. */
1600
1534
  export interface ToolCallOccurrence {
1601
1535
  toolCallId: string;
1602
1536
  toolName: string;
@@ -1646,10 +1580,6 @@ export function collectCompletedToolCallOccurrences(
1646
1580
  return occurrences;
1647
1581
  }
1648
1582
 
1649
- // ----------------------------------------------------------------------
1650
- // Strategy 1: deduplication (pe-u8gd)
1651
- // ----------------------------------------------------------------------
1652
-
1653
1583
  /** Placeholder text for a duplicate tool result whose content was replaced. */
1654
1584
  export function buildDedupePlaceholder(toolName: string, newestToolCallId: string): string {
1655
1585
  return `[pruned by ${EXTENSION_ID}: duplicate ${toolName} call result; see newest occurrence (call ${newestToolCallId})]`;
@@ -1688,10 +1618,6 @@ export const dedupeStrategy: PruneStrategy = {
1688
1618
  },
1689
1619
  };
1690
1620
 
1691
- // ----------------------------------------------------------------------
1692
- // Strategy 2: error-input purge (pe-u8gd)
1693
- // ----------------------------------------------------------------------
1694
-
1695
1621
  export const errorPurgeStrategy: PruneStrategy = {
1696
1622
  id: ERROR_PURGE_STRATEGY_ID,
1697
1623
  propose(input: StrategyProposeInput): ProposedPrune[] {
@@ -1716,10 +1642,6 @@ export const errorPurgeStrategy: PruneStrategy = {
1716
1642
  },
1717
1643
  };
1718
1644
 
1719
- // ----------------------------------------------------------------------
1720
- // Strategy 3: superseded file operations (pe-qs8j)
1721
- // ----------------------------------------------------------------------
1722
- //
1723
1645
  // When the same file path is read and/or written multiple times in a
1724
1646
  // session, older read/write/edit tool *outputs* for that path can become
1725
1647
  // stale relative to a later operation. This strategy proposes replacing
@@ -1916,7 +1838,7 @@ export const supersededFileOpsStrategy: PruneStrategy = {
1916
1838
  // `group` is already in message/chronological order because
1917
1839
  // `collectCompletedToolCallOccurrences` iterates messages in order.
1918
1840
  //
1919
- // Single reverse pass (pe-e0zd): instead of `group.slice(i + 1).find(...)`
1841
+ // Single reverse pass: instead of `group.slice(i + 1).find(...)`
1920
1842
  // per index (O(k^2) allocations+scans for a group of size k), walk the
1921
1843
  // group back-to-front and maintain:
1922
1844
  // - `nearestLaterSuccessfulWrite`: the nearest (chronologically first)
@@ -2040,10 +1962,6 @@ function collectProposals(input: StrategyProposeInput): ProposedPrune[] {
2040
1962
  return proposals;
2041
1963
  }
2042
1964
 
2043
- // ============================================================================
2044
- // Pipeline
2045
- // ============================================================================
2046
-
2047
1965
  export interface PipelineInput {
2048
1966
  messages: MinimalMessage[];
2049
1967
  config: DynamicContextPruningConfig;
@@ -2052,7 +1970,7 @@ export interface PipelineInput {
2052
1970
  knownIdempotencyKeys: ReadonlySet<string>;
2053
1971
  /**
2054
1972
  * Idempotency keys most-recently restored/undone via the /prune picker
2055
- * (pe-8re9; see `resolvePruneTombstoneState`). Fresh AUTOMATIC proposals
1973
+ * (see `resolvePruneTombstoneState`: last event wins). Fresh AUTOMATIC proposals
2056
1974
  * matching one of these keys are dropped so a restored decision does not
2057
1975
  * silently re-apply on the very next context event. Manual re-prunes
2058
1976
  * always use a fresh idempotencyKey namespaced by "manual" (see
@@ -2063,7 +1981,7 @@ export interface PipelineInput {
2063
1981
  restoredIdempotencyKeys?: ReadonlySet<string>;
2064
1982
  /** Omitted defaults to idle. The context handler passes the classified state. */
2065
1983
  agentState?: AgentState;
2066
- /** Session working directory (pe-qs8j), forwarded to strategies for path normalization. */
1984
+ /** Session working directory, forwarded to strategies for path normalization. */
2067
1985
  cwd?: string;
2068
1986
  }
2069
1987
 
@@ -2085,7 +2003,7 @@ export interface PipelineResult {
2085
2003
  * single place that mutates content. Pairing invariants are preserved by
2086
2004
  * construction: messages/blocks are never added or removed, only replaced.
2087
2005
  *
2088
- * Net-benefit gate (pe-s2ho): only NEW, automatic (non-manual) proposals are
2006
+ * Net-benefit gate: only NEW, automatic (non-manual) proposals are
2089
2007
  * gated; already-persisted decisions stay applied (their cache bust already
2090
2008
  * happened) and manual decisions always bypass the gate (user intent wins).
2091
2009
  */
@@ -2116,16 +2034,14 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2116
2034
  input.config.protections.recentTurns,
2117
2035
  );
2118
2036
  // 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
2037
+ // each per-decision lookup is O(1) instead of an O(n) rescan of `messages`.
2038
+ // Positions are stable across the apply loop below (and against
2121
2039
  // `input.messages`) because decisions only ever replace a message's
2122
2040
  // content at its existing index, never insert/remove/reorder messages.
2123
2041
  const pairIndex = buildToolCallPairIndex(messages);
2124
2042
 
2125
2043
  const proposals = collectProposals({
2126
2044
  messages,
2127
- protections: input.config.protections,
2128
- estimateTokens: estimateTokensForText,
2129
2045
  config: input.config,
2130
2046
  cwd: input.cwd,
2131
2047
  });
@@ -2154,18 +2070,12 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2154
2070
  const freshManual = dedupedFresh.filter((decision) => decision.source === "manual");
2155
2071
  const freshAutomaticUnfiltered = dedupedFresh.filter((decision) => decision.source !== "manual");
2156
2072
 
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.
2073
+ // Minimum-size floor: drop fresh automatic proposals whose character
2074
+ // savings fall below `thresholds.minCharsSaved` BEFORE gate evaluation, so
2075
+ // dropped candidates never consume batch/earliest-position consideration.
2076
+ // Manual and already-persisted decisions bypass this floor. Image-bearing
2077
+ // tool results bypass it too (no character count) and still hit the token
2078
+ // gate. A floor of 0 is a no-op.
2169
2079
  const minCharsSaved = input.config.thresholds.minCharsSaved;
2170
2080
  const freshAutomatic =
2171
2081
  minCharsSaved <= 0
@@ -2177,7 +2087,7 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2177
2087
  });
2178
2088
 
2179
2089
  // Collapse fresh AUTOMATIC candidates that target the same (kind,
2180
- // correlation) down to a single decision (pe-j7sb). Two strategies (e.g.
2090
+ // correlation) down to a single decision. Two strategies (e.g.
2181
2091
  // `dedupe` and `superseded-file-ops`) can independently propose a decision
2182
2092
  // for the exact same tool result; their idempotencyKeys differ (each
2183
2093
  // embeds its own strategyId) so the self-dedup above does not catch this,
@@ -2216,12 +2126,9 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2216
2126
  // target's tokensRemoved even though the apply-loop guard further down
2217
2127
  // correctly skips applying the fresh candidate — corrupting the gate's
2218
2128
  // 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).
2129
+ // double-apply occurs.
2222
2130
  const claimedTargetKeys = new Set<string>();
2223
2131
  for (const decision of [...input.persistedDecisions, ...freshManual]) {
2224
- if (decision.correlation.type !== "toolCallId") continue;
2225
2132
  claimedTargetKeys.add(buildDecisionTargetKey(decision));
2226
2133
  }
2227
2134
 
@@ -2269,8 +2176,8 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2269
2176
  // `applyPruneDecision` unconditionally re-applies its placeholder even when
2270
2177
  // the target is already a placeholder and reports `applied: true`, so
2271
2178
  // 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
2179
+ // first's placeholder AND still be credited full stats/newlyAppliedDecisions.
2180
+ // The pre-gate collapse above already prevents this among fresh
2274
2181
  // automatic candidates; this guard additionally covers persisted-vs-fresh
2275
2182
  // (and any other) overlap within a single call.
2276
2183
  const appliedTargetKeys = new Set<string>();
@@ -2310,10 +2217,6 @@ export function runDynamicContextPruningPipeline(input: PipelineInput): Pipeline
2310
2217
  return { messages, newlyAppliedDecisions, newlyAppliedStats, contextSizeSnapshot, gate };
2311
2218
  }
2312
2219
 
2313
- // ============================================================================
2314
- // /prune picker helpers (pe-8re9)
2315
- // ============================================================================
2316
-
2317
2220
  function resolveSessionProjection(ctx: ExtensionContext): MinimalSessionProjection | undefined {
2318
2221
  try {
2319
2222
  const sessionManager = ctx.sessionManager as unknown as {
@@ -2364,14 +2267,12 @@ export interface PrunableItem {
2364
2267
  * for manual prune/restore. `activeByToolCallId` should be built from
2365
2268
  * currently-ACTIVE decisions only (kind: "tool_result_content"), keyed by
2366
2269
  * toolCallId; `restoredToolCallIds` from currently-restored keys' underlying
2367
- * toolCallId (see `buildActiveResultDecisionMap`/`extractToolCallIdFromDecisionKey`
2368
- * usage in the extension wiring).
2270
+ * toolCallId (see `buildActiveResultDecisionMap` in the extension wiring).
2369
2271
  */
2370
2272
  export function buildPrunableItems(
2371
2273
  messages: MinimalMessage[],
2372
2274
  activeByToolCallId: Map<string, PruneDecisionRecord>,
2373
2275
  restoredToolCallIds: ReadonlySet<string>,
2374
- _estimateTokens: TokenEstimator = estimateTokensForText,
2375
2276
  ): PrunableItem[] {
2376
2277
  const items: PrunableItem[] = [];
2377
2278
  for (const occurrence of collectCompletedToolCallOccurrences(messages)) {
@@ -2404,7 +2305,6 @@ export function buildActiveResultDecisionMap(
2404
2305
  const map = new Map<string, PruneDecisionRecord>();
2405
2306
  for (const decision of activeDecisions) {
2406
2307
  if (decision.kind !== "tool_result_content") continue;
2407
- if (decision.correlation.type !== "toolCallId") continue;
2408
2308
  if (!map.has(decision.correlation.toolCallId))
2409
2309
  map.set(decision.correlation.toolCallId, decision);
2410
2310
  }
@@ -2466,10 +2366,6 @@ export function formatPrunableItemsReport(items: PrunableItem[]): string[] {
2466
2366
  return items.map((item, index) => `${index + 1}. ${formatPrunableItemOption(item)}`);
2467
2367
  }
2468
2368
 
2469
- // ============================================================================
2470
- // /context-pruning control + status/stats helpers (pe-8re9)
2471
- // ============================================================================
2472
-
2473
2369
  export type StrategyKey = "dedupe" | "errorPurge" | "supersededFileOps";
2474
2370
 
2475
2371
  const STRATEGY_ALIASES: Record<string, StrategyKey> = {
@@ -2659,10 +2555,6 @@ export function formatStatsReport(stats: CumulativePruneStats): string[] {
2659
2555
  return lines;
2660
2556
  }
2661
2557
 
2662
- // ============================================================================
2663
- // Extension wiring
2664
- // ============================================================================
2665
-
2666
2558
  function notify(
2667
2559
  ctx: ExtensionContext,
2668
2560
  message: string,
@@ -2722,9 +2614,6 @@ export default function dynamicContextPruningExtension(pi: ExtensionAPI) {
2722
2614
  persistedDecisions,
2723
2615
  knownIdempotencyKeys,
2724
2616
  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
2617
  agentState: classifyAgentStateFromMessages(event.messages as unknown as MinimalMessage[]),
2729
2618
  cwd: ctx.sessionManager.getCwd(),
2730
2619
  });
@@ -2805,11 +2694,8 @@ export default function dynamicContextPruningExtension(pi: ExtensionAPI) {
2805
2694
  const activeByToolCallId = buildActiveResultDecisionMap(persistedDecisions);
2806
2695
  const restoredToolCallIds = new Set(
2807
2696
  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),
2697
+ .filter((d) => restoredIdempotencyKeys.has(d.idempotencyKey))
2698
+ .map((d) => d.correlation.toolCallId),
2813
2699
  );
2814
2700
  const items = buildPrunableItems(messages, activeByToolCallId, restoredToolCallIds);
2815
2701
 
@@ -2839,12 +2725,8 @@ export default function dynamicContextPruningExtension(pi: ExtensionAPI) {
2839
2725
  const latestActive = buildActiveResultDecisionMap(persistedDecisions);
2840
2726
  const latestRestoredToolCallIds = new Set(
2841
2727
  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),
2728
+ .filter((d) => restoredIdempotencyKeys.has(d.idempotencyKey))
2729
+ .map((d) => d.correlation.toolCallId),
2848
2730
  );
2849
2731
  const latestItems = buildPrunableItems(
2850
2732
  latestMessages,