@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.
- package/README.md +8 -8
- package/extensions/agent-workflow-audit/README.md +1 -1
- package/extensions/agent-workflow-audit/index.ts +275 -59
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/git.ts +0 -8
- package/extensions/annotate-git-diff/glimpseui.d.ts +5 -65
- package/extensions/annotate-git-diff/package.json +1 -1
- package/extensions/annotate-last-message/glimpseui.d.ts +5 -65
- package/extensions/annotate-last-message/package.json +1 -1
- package/extensions/annotate-last-message/web/index.html +3 -30
- package/extensions/code-reviewer/README.md +5 -5
- package/extensions/code-reviewer/index.ts +252 -82
- package/extensions/code-reviewer/package.json +1 -1
- package/extensions/confirm-destructive/index.ts +2 -8
- package/extensions/confirm-destructive/package.json +1 -1
- package/extensions/context-cap/README.md +9 -9
- package/extensions/context-cap/index.ts +3 -3
- package/extensions/context-cap/package.json +2 -2
- package/extensions/context-inspector/index.ts +2 -46
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/README.md +4 -3
- package/extensions/contrarian/index.ts +48 -67
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dirty-repo-guard/index.ts +0 -4
- package/extensions/dirty-repo-guard/package.json +1 -1
- package/extensions/dynamic-context-pruning/README.md +20 -11
- package/extensions/dynamic-context-pruning/index.ts +118 -341
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +18 -11
- package/extensions/fast/index.ts +9 -5
- package/extensions/fast/package.json +1 -1
- package/extensions/git-footer/index.ts +2 -16
- package/extensions/git-footer/package.json +1 -1
- package/extensions/inline-bash/index.ts +0 -16
- package/extensions/inline-bash/package.json +1 -1
- package/extensions/librarian/README.md +1 -1
- package/extensions/librarian/index.ts +246 -70
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/README.md +5 -5
- package/extensions/minimal-footer/index.ts +5 -26
- package/extensions/minimal-footer/minimal-footer.example.json +1 -1
- package/extensions/minimal-footer/openai-usage.ts +2 -11
- package/extensions/minimal-footer/package.json +1 -1
- package/extensions/oracle/README.md +4 -3
- package/extensions/oracle/index.ts +46 -65
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/README.md +3 -0
- package/extensions/permission-gate/index.ts +127 -36
- package/extensions/permission-gate/package.json +1 -1
- package/extensions/project-mcp-json/README.md +2 -2
- package/extensions/project-mcp-json/package.json +1 -1
- package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
- package/extensions/quiet-tools/README.md +30 -19
- package/extensions/quiet-tools/index.ts +233 -166
- package/extensions/quiet-tools/package.json +2 -2
- package/extensions/review/README.md +2 -0
- package/extensions/review/package.json +1 -1
- package/extensions/todo/index.ts +0 -19
- package/extensions/todo/package.json +1 -1
- package/extensions/triage-comments/README.md +4 -2
- package/extensions/triage-comments/index.ts +344 -39
- package/extensions/triage-comments/package.json +1 -1
- 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
|
|
162
|
-
*
|
|
163
|
-
* `resolvePruneTombstoneState` for the
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
514
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 }
|
|
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
|
|
752
|
+
* the overlap the pipeline collapses before gating/apply (see
|
|
847
753
|
* `runDynamicContextPruningPipeline`).
|
|
848
754
|
*/
|
|
849
755
|
function buildDecisionTargetKey(decision: PruneDecisionRecord): string {
|
|
850
|
-
|
|
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"
|
|
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
|
|
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
|
-
*
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
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
|
-
|
|
1069
|
+
function previewDecisionApply(
|
|
1175
1070
|
messages: MinimalMessage[],
|
|
1176
1071
|
decision: PruneDecisionRecord,
|
|
1177
|
-
estimateTokens: TokenEstimator = estimateTokensForText,
|
|
1178
1072
|
pairIndex?: Map<string, ToolCallPairIndices>,
|
|
1179
|
-
):
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
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
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1224
|
-
*
|
|
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
|
|
1115
|
+
export function estimateDecisionSavings(
|
|
1232
1116
|
messages: MinimalMessage[],
|
|
1233
1117
|
decision: PruneDecisionRecord,
|
|
1118
|
+
estimateTokens: TokenEstimator = estimateTokensForText,
|
|
1234
1119
|
pairIndex?: Map<string, ToolCallPairIndices>,
|
|
1235
|
-
):
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
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
|
-
|
|
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
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
* (
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
2158
|
-
//
|
|
2159
|
-
//
|
|
2160
|
-
//
|
|
2161
|
-
//
|
|
2162
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|