pi-condense 2.12.0 → 2.13.0
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/CHANGELOG.md +10 -0
- package/PRUNING.md +50 -32
- package/package.json +1 -1
- package/src/commands.test.ts +46 -0
- package/src/commands.ts +4 -4
- package/src/error-purge.test.ts +91 -1
- package/src/error-purge.ts +48 -45
- package/src/frontier.test.ts +1 -0
- package/src/indexer.test.ts +28 -0
- package/src/indexer.ts +4 -0
- package/src/pruner.test.ts +112 -0
- package/src/pruner.ts +9 -8
- package/src/query-tool.test.ts +28 -0
- package/src/supersede.test.ts +73 -3
- package/src/supersede.ts +37 -9
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
|
|
|
7
7
|
Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
|
|
8
8
|
publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
|
|
9
9
|
|
|
10
|
+
## [2.13.0] - 2026-10-20
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Argument purge shrinks long string arguments of indexed, summarized calls in the same render that stubs their results; `context_tree_query` recovers the stored arguments and result. Dedup aliases keep their own arguments because only canonical arguments are indexed. The existing `purgeErrors` toggle and size gate cover both summarized and errored calls; cooldown remains error-only.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Protected reads now supersede earlier reads of the same normalized path only when their delivered line ranges cover the earlier content. Disjoint chunks and tails outside a capped full read stay verbatim; identical capped reads still supersede the older copy. Supersession keeps its existing cache-rewrite cadence.
|
|
19
|
+
|
|
10
20
|
## [2.12.0] - 2026-10-17
|
|
11
21
|
|
|
12
22
|
### Fixed
|
package/PRUNING.md
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
10. [Chain Compression](#chain-compression)
|
|
31
31
|
- [Protected-output relocation](#protected-output-relocation)
|
|
32
32
|
11. [Occurrence Identity](#occurrence-identity)
|
|
33
|
-
12. [
|
|
33
|
+
12. [Argument Purge](#argument-purge)
|
|
34
34
|
13. [Orphan Sweep](#orphan-sweep)
|
|
35
35
|
14. [Diagnostics](#diagnostics)
|
|
36
36
|
15. [Why Summarization Works: Research Evidence](#why-summarization-works-research-evidence)
|
|
@@ -184,7 +184,7 @@ graph TB
|
|
|
184
184
|
|
|
185
185
|
**Key points:**
|
|
186
186
|
|
|
187
|
-
- The `AssistantMessage` tool-call blocks are **kept
|
|
187
|
+
- The `AssistantMessage` tool-call blocks are **kept**, preserving their ids and result pairing; eligible long argument strings shrink through [Argument Purge](#argument-purge).
|
|
188
188
|
- `ToolResultMessage` entries for summarized tool calls are **replaced with a small stub** (`[Summarized in pruner summary, ref \`tN\`. Use context_tree_query to retrieve full output.]`) carrying`role: "toolResult"`, the original`toolCallId`/`toolName`/`timestamp`, and`isError: false`. The stub preserves role alternation, so pi-ai's`transformMessages.insertSyntheticToolResults` no longer injects a synthetic `{ isError: true, "No result provided" }` for the (no-longer-)orphaned tool call. See [Stub-replace instead of delete](#stub-replace-instead-of-delete).
|
|
189
189
|
- Every pruned tool call is also copied into the pruner's runtime/session index with its `toolCallId`, tool name, args, status, turn index, timestamp, and full `resultText`.
|
|
190
190
|
- A summary message is injected as a `"steer"` (`pi.sendMessage` runtime path) or appended directly via `sessionManager.appendCustomMessageEntry` (session path, used when Pi may already be shutting down). Both deliver before the next LLM call.
|
|
@@ -283,7 +283,7 @@ So after pruning, the model is working with a **two-layer memory**:
|
|
|
283
283
|
|
|
284
284
|
| Part of old turn | After pruning | Why |
|
|
285
285
|
|---|---|---|
|
|
286
|
-
| Assistant tool-call block | **Kept in context** | Preserves
|
|
286
|
+
| Assistant tool-call block | **Kept in context; eligible long argument strings shrink** | Preserves `toolCallId` anchors and result pairing; indexed arguments remain recoverable through `context_tree_query` (dedup aliases keep their own arguments) |
|
|
287
287
|
| Tool result message | **Replaced by a short stub in active context** | Saves tokens (typical 50–100× reduction per call) while keeping the `toolCallId` anchor and giving the model an explicit `context_tree_query` breadcrumb. The stub keeps `role: "toolResult"` and `isError: false` so role alternation stays intact |
|
|
288
288
|
| Summary message | **Added to context** | Gives the model a compact description of what happened |
|
|
289
289
|
| Indexed tool-call record | **Stored in pruner index** (`context-prune-index` session entry) | Lets the model re-open the original raw output later via `context_tree_query` |
|
|
@@ -584,8 +584,8 @@ graph LR
|
|
|
584
584
|
captured batches (from turn_end or session scan)
|
|
585
585
|
│
|
|
586
586
|
├─ 1. Protected-tools/paths filter (capture-time, see below)
|
|
587
|
-
│
|
|
588
|
-
│
|
|
587
|
+
│ protected reads stay verbatim unless a later same-path read covers their
|
|
588
|
+
│ delivered lines; stubbing waits for a shared rewrite or cold cache (see below)
|
|
589
589
|
│ tool calls whose toolName is in protectedTools, OR whose args.path
|
|
590
590
|
│ matches any protectedPaths glob, never enter the batch
|
|
591
591
|
│
|
|
@@ -633,7 +633,7 @@ Implementation: `src/pruner.ts` `pruneMessages(messages, indexer)` returns `{ me
|
|
|
633
633
|
|
|
634
634
|
### Protected tools & paths
|
|
635
635
|
|
|
636
|
-
A tool call is protected if **either** its `toolName` is in `protectedTools` **or** its `args.path` (string) matches any glob in `protectedPaths`. Protected calls are filtered out **at capture time** - they never enter the `pendingBatches` queue, so their raw `ToolResultMessage` stays verbatim in future LLM context until a
|
|
636
|
+
A tool call is protected if **either** its `toolName` is in `protectedTools` **or** its `args.path` (string) matches any glob in `protectedPaths`. Protected calls are filtered out **at capture time** - they never enter the `pendingBatches` queue, so their raw `ToolResultMessage` stays verbatim in future LLM context until a later read of the same path covers its delivered lines and supersedes it (below).
|
|
637
637
|
|
|
638
638
|
**`protectedTools: string[]`** (default `[]`) - allowlist of tool names. Covers tools whose output is a small handle that must be reused byte-for-byte (e.g. a session-id) or planning tools like `todowrite` / `todoread`.
|
|
639
639
|
|
|
@@ -643,9 +643,30 @@ Glob contract: full-path match against the raw `args.path` string with `\` norma
|
|
|
643
643
|
|
|
644
644
|
**Render-time re-check:** stub replacement runs in-flight on every turn (`pruneMessages`). If a tool call's persisted `args` now satisfy `isProtected` (e.g. a pattern was added mid-session), the stub is skipped and the raw result is left verbatim - this repairs already-summarized records in existing sessions with no schema change. Declared limitation: records inside already-compressed chains (`context-prune-chain` entries) are NOT repaired - their `protectedToolCallIds` set is fixed at compression time (forward-only). Dedup-alias edge: an alias resolving to an unprotected original stays stubbed.
|
|
645
645
|
|
|
646
|
-
**Supersession (phase 1b, `src/supersede.ts`):** protected calls are never indexed, so content-hash dedup never sees them.
|
|
646
|
+
**Supersession (phase 1b, `src/supersede.ts`):** protected calls are never indexed, so content-hash dedup never sees them. A paired occurrence participates only when `isProtected(toolName, args)` is true and `typeof args.path === "string"`. Occurrences group by normalized `args.path` (backslash -> slash). An earlier occurrence is a supersession candidate only when **any later occurrence** of that path covers its delivered line range, not just when the last occurrence covers it. Different paths never interact; disjoint chunks stay verbatim.
|
|
647
647
|
|
|
648
|
-
**
|
|
648
|
+
**Requested and delivered ranges:** numeric `offset` normalizes to `max(1, offset)`; absent, `null`, or non-numeric offsets start at line 1. Numeric `limit > 0` requests `[start, start + limit - 1]`; absent, `null`, or non-numeric limits give `[start, inf)`. A `limit <= 0` delivers no lines: it supersedes nothing and is covered by any later nonempty read of the path. A read that returns pi's oversized-first-line warning instead of content delivers no lines, so it covers nothing. Otherwise, without a cap footer, the delivered range is the requested range. A pi footer such as `[Showing lines 1-1800 of 3000 (50.0KB limit). Use offset=1801 to continue.]` bounds the delivered end at 1800. The footer parser also accepts the line-cap form without the parenthetical and any `(<size> limit)` form. This bound applies to both earlier and later reads: a capped full read never covers a tail it did not deliver. Coverage requires the later delivered start to be at or below the earlier start and its end at or above the earlier end.
|
|
649
|
+
|
|
650
|
+
For a file of 3000 lines, "capped" means pi truncated the output:
|
|
651
|
+
|
|
652
|
+
| Sequence on one path | Superseded |
|
|
653
|
+
|---|---|
|
|
654
|
+
| full, full | first |
|
|
655
|
+
| chunk `1-2000`, chunk `2001-` | none |
|
|
656
|
+
| chunk `1-2000`, full capped at line 2000 | chunk (delivered ranges equal) |
|
|
657
|
+
| chunk `2001-3000`, full capped at line 2000 | none (the later read never delivered the tail) |
|
|
658
|
+
| full, chunk | none |
|
|
659
|
+
| chunk 1, chunk 2, chunk 1 again | first chunk 1 |
|
|
660
|
+
| full capped at line 2000, full capped again | first (same delivered lines) |
|
|
661
|
+
| `limit: 2500` byte-capped at line 1800, full capped at line 2000 | first (`[1, 1800]` is inside `[1, 2000]`) |
|
|
662
|
+
| full capped at line 2000, `limit: 2500` byte-capped at line 1800 | none |
|
|
663
|
+
| `offset: 0, limit: 100`, full | first (offset 0 is line 1) |
|
|
664
|
+
| `offset: null` no limit, chunk `1-50` | none (`[1, inf)` is not covered by `[1, 50]`) |
|
|
665
|
+
| chunk `1-50`, `offset: null, limit: 50` | first (`[1, 50]` covers `[1, 50]`) |
|
|
666
|
+
|
|
667
|
+
Once activated by the cadence gate below, a candidate becomes the one-line stub `[Superseded: <path> was read again later in this conversation - see the newer read. Re-read the file if this earlier content is needed.]`. The stub keeps `toolCallId`/`toolName`/`timestamp`, so pi-ai's orphan repair never fires; the assistant `toolCall` block is untouched. A call without a paired result never participates. Recovery is "re-read the file" - no `t<N>` ref. Results pair only with an open same-id call in the preceding assistant turn; each assistant turn resets the open set, and any non-assistant/non-toolResult message clears it. A paired result consumes its call, so reused provider ids never pair through a global per-id cursor.
|
|
668
|
+
|
|
669
|
+
**Cadence (prompt-cache economics):** a superseded read is stubbed only when the pruner is already rewriting at or before its position - `state.floor` is the earliest result timestamp phase 1 will stub on the next render (indexed batches, plus dedup aliases registered in the pre-flush pass - those are stubbed by phase 1 whatever their batch's outcome; a `skipped-trivial`/`skipped-oversized` batch's own calls set none) or the `startUserTimestamp` of a chain compressed this turn - or on a guaranteed-cold event: `session_start`, `session_tree`, `model_select`, `session_compact`, `thinking_level_select` (`state.floor = 0`, activate all). Activation is session-sticky (`state.activated`, an in-memory `occKey` set cleared on `session_start`/`session_tree`); between those moments a freshly superseded copy stays verbatim on purpose, since a mid-prefix rewrite re-bills the whole tail once. `isProtected` is evaluated live, so a path that stops matching `protectedPaths` drops out of supersession and rejoins the normal pipeline. No config key: supersession is on whenever protection is. Specs: [delivered-range coverage](doc/specs/2026-10-20-multi-stage-context-retention.md#change-2-range-aware-supersession) and [cadence and stub contract](doc/specs/2026-09-07-protected-path-supersede.md).
|
|
649
670
|
|
|
650
671
|
Names and patterns that don't match any captured tool call are silently ignored.
|
|
651
672
|
|
|
@@ -724,7 +745,7 @@ The frontier's `lastAttemptedTurnIndex` uses a session-wide numbering domain: it
|
|
|
724
745
|
- **Tree browser (`/pruner tree`):** interactive, foldable tree of pruned tool calls grouped under their summaries. `Ctrl-O` on a summary node opens the full markdown summary in a bordered overlay.
|
|
725
746
|
- **Configurable summarizer thinking (`summarizerThinking`):** trade summary cost / latency for quality (`off` / `minimal` / `low` / `medium` / `high` / `xhigh`). `default` omits the option entirely so the provider chooses.
|
|
726
747
|
- **Cumulative stats:** `context-prune-stats` entries track input/output tokens and cost of every summarizer call; full detail surfaces in `/pruner stats`. Cost is also emitted on the `cost:external` pi.events channel for external aggregators (cumulative per session, live only).
|
|
727
|
-
- **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages)` = the sum of `charsOf(message)` (`src/context-metrics.ts`): whole-message JSON chars with each image block priced at a flat 1,600 tokens (6,400 chars) instead of its base64 length, the same estimate the frontier-gap trigger uses. This compares the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all five phases in a single point (stub-replace, supersede,
|
|
748
|
+
- **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages)` = the sum of `charsOf(message)` (`src/context-metrics.ts`): whole-message JSON chars with each image block priced at a flat 1,600 tokens (6,400 chars) instead of its base64 length, the same estimate the frontier-gap trigger uses. This compares the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all five phases in a single point (stub-replace, supersede, argument-purge, chain-range-prune, orphan-sweep); appears on the status line as `│ prune: ON · 92.0k->14.0k (-85%)` once at least one prune has occurred (the leading `│` keeps the segment visually isolated in the shared footer, load-order independent - there is no trailing divider, since the footer's own space-join between segments already provides one).
|
|
728
749
|
- **Live progress for `/pruner now`:** an `aboveEditor` widget shows one row per pending batch with braille spinner, streamed summary-char count, and ✓ / ⚠ status.
|
|
729
750
|
|
|
730
751
|
### Summarizer outage fallback
|
|
@@ -918,7 +939,7 @@ raw messages from session
|
|
|
918
939
|
│
|
|
919
940
|
├─ [1] tool-result stub-replace (per-batch; existing)
|
|
920
941
|
├─ [1b] supersede (older protected reads of a re-read path -> stub; see § Supersession)
|
|
921
|
-
├─ [2]
|
|
942
|
+
├─ [2] argument-purge (phase 2)
|
|
922
943
|
├─ [3] chain-range-prune (runs AFTER stubs)
|
|
923
944
|
│ resolve each entry to a positional index range
|
|
924
945
|
│ drop assistant / toolResult / per-batch-summary inside the range
|
|
@@ -1099,45 +1120,42 @@ Short refs (`tN`) always resolve 1:1 - one ref, one record - and are the primary
|
|
|
1099
1120
|
|
|
1100
1121
|
---
|
|
1101
1122
|
|
|
1102
|
-
##
|
|
1123
|
+
## Argument Purge
|
|
1103
1124
|
|
|
1104
|
-
|
|
1125
|
+
An indexed call whose result has been summarized loses its long string arguments in the same render its result is stubbed; `context_tree_query` returns both the arguments and the result.
|
|
1105
1126
|
|
|
1106
|
-
|
|
1127
|
+
Two exceptions stay separate: an errored call waits `purgeErrors.cooldownTurns` assistant turns (default 2), whether or not it was indexed, so recovery through `context_tree_query` is available only when it was also indexed. A dedup alias is never shrunk: the index stores only the canonical call's arguments, and recovery on the shared ref returns those arguments, not the alias's own prompt.
|
|
1128
|
+
|
|
1129
|
+
**Shared gates and shape:** `purgeErrors.enabled` controls both sources. The JSON-stringified `block.arguments` body must be at least `purgeErrors.minArgChars` characters (default 500). Only strings longer than 200 characters (`PURGE_STRING_MIN_CHARS` in `src/error-purge.ts`, not configurable) shrink. Keys, JSON types, array lengths, non-string values, and strings of 200 characters or fewer survive. A qualifying body with no long string stays unchanged. Keeping the shape preserves provider schema validation while leaving short paths and commands readable.
|
|
1130
|
+
|
|
1131
|
+
Both markers carry the original string length; summarized calls also carry the short ref from the result stub:
|
|
1107
1132
|
|
|
1108
1133
|
```
|
|
1109
|
-
|
|
1134
|
+
<purged-args size="30000" ref="t6"/>
|
|
1135
|
+
<purged-errored-args size="30000"/>
|
|
1110
1136
|
```
|
|
1111
1137
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
**What triggers a purge:**
|
|
1138
|
+
**Pairing and source selection:** phase 1 hands phase 2 a map from each stubbed result's resolved occurrence key to its short ref, excluding dedup aliases. Phase 2 pairs results positionally with open same-id tool-call blocks in the preceding assistant turn. Each assistant turn resets the open set, a result consumes its paired call, and any non-assistant/non-toolResult message clears the set. Rewrites target the exact assistant position and block id, so a reused provider id cannot authorize shrinking a sibling call in another turn. Timestamped results use `id@resultTimestamp`; timestamp-less results use the bare id for the handoff lookup, not for selecting a rewrite position.
|
|
1115
1139
|
|
|
1116
|
-
|
|
1117
|
-
- The error occurred at least `purgeErrors.cooldownTurns` assistant turns ago (default 2). The cooldown gives the model 1–2 turns to retry before context is mutated.
|
|
1118
|
-
- The JSON-stringified argument body is at least `purgeErrors.minArgChars` characters long (default 500). Small args are not worth the substitution. A body that qualifies but holds no string over 200 characters is returned unchanged.
|
|
1140
|
+
A result phase 1 stubs has `isError: false`, so its arguments get `<purged-args>` even if the original result errored. Before that stub render, an expired error cooldown can produce `<purged-errored-args>`. Argument purge does not rewrite result content; unsummarized error messages stay visible.
|
|
1119
1141
|
|
|
1120
|
-
**
|
|
1142
|
+
**Cadence and identity:** summarized calls have no separate cooldown. Shrinking coincides with the result-stub rewrite; delaying it would invalidate the prefix again on a later turn. Each context render starts from raw history and reapplies the same transformation: for fixed index and recovery-grace state, the emitted prefix bytes are deterministic across fresh-history renders. The input array is returned by identity only when nothing changes, including an already-shrunk input whose markers fall below the string floor.
|
|
1121
1143
|
|
|
1122
|
-
|
|
1123
|
-
- Non-errored `toolCall` argument bodies.
|
|
1124
|
-
- Argument bodies below `minArgChars`.
|
|
1125
|
-
- Keys, non-string values, array lengths, and strings of 200 characters or fewer inside a purged body.
|
|
1126
|
-
- Anything when `purgeErrors.enabled` is `false`.
|
|
1144
|
+
Protected calls and trivial batches have no phase 1 stub and do not qualify through the summarized source. Calls in the recovery grace window keep both their raw result and arguments until the window expires; stubbing and shrinking then happen together. Dedup alias results still get stubs, but their arguments stay intact. The separate errored source retains its cooldown behavior for calls with `isError: true`.
|
|
1127
1145
|
|
|
1128
|
-
**Transform position:**
|
|
1146
|
+
**Transform position:** argument purge runs in Phase 2, after stub-replace and supersession and before chain range prune.
|
|
1129
1147
|
|
|
1130
1148
|
```
|
|
1131
|
-
[stub-replace] → [supersede] → [
|
|
1149
|
+
[stub-replace] → [supersede] → [argument-purge] → [chain-range-prune] → [orphan-sweep]
|
|
1132
1150
|
```
|
|
1133
1151
|
|
|
1134
|
-
**Config keys:**
|
|
1152
|
+
**Config keys:** persisted names stay `purgeErrors.*`; the settings overlay labels the shared toggle and size gate as argument purge and the cooldown as error-only.
|
|
1135
1153
|
|
|
1136
1154
|
| Key | Default | Description |
|
|
1137
1155
|
|---|---|---|
|
|
1138
|
-
| `purgeErrors.enabled` | `true` |
|
|
1139
|
-
| `purgeErrors.cooldownTurns` | `2` |
|
|
1140
|
-
| `purgeErrors.minArgChars` | `500` | Minimum JSON body size for
|
|
1156
|
+
| `purgeErrors.enabled` | `true` | Toggle for summarized and errored argument purge |
|
|
1157
|
+
| `purgeErrors.cooldownTurns` | `2` | Assistant turns to wait after an error; applies only to the errored source |
|
|
1158
|
+
| `purgeErrors.minArgChars` | `500` | Minimum JSON body size for either source; only strings over 200 chars inside it are replaced |
|
|
1141
1159
|
|
|
1142
1160
|
---
|
|
1143
1161
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|
package/src/commands.test.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { pruneStatusText, setPruneStatusWidget, registerCommands } from "./comma
|
|
|
12
12
|
import { settingsPath } from "./config.js";
|
|
13
13
|
import type { ContextPruneConfig, ContextMetricsSnapshot, SummarizerStats } from "./types.js";
|
|
14
14
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
15
|
+
import { Container, SettingsList } from "@earendil-works/pi-tui";
|
|
16
|
+
import { initTheme } from "@earendil-works/pi-coding-agent";
|
|
15
17
|
|
|
16
18
|
const cfg = (enabled: boolean): ContextPruneConfig => ({ enabled } as ContextPruneConfig);
|
|
17
19
|
const cfgVisible = (enabled: boolean): ContextPruneConfig =>
|
|
@@ -41,6 +43,7 @@ function setupPrunerCommand(overrides: {
|
|
|
41
43
|
let handler: (args: string, ctx: any) => Promise<void>;
|
|
42
44
|
const notifications: { message: string; type?: string }[] = [];
|
|
43
45
|
const flushCalls: any[] = [];
|
|
46
|
+
let overlay: Container & { handleInput(data: string): void };
|
|
44
47
|
|
|
45
48
|
const flushPending =
|
|
46
49
|
overrides.flushPending ??
|
|
@@ -79,6 +82,9 @@ function setupPrunerCommand(overrides: {
|
|
|
79
82
|
notifications.push({ message, type });
|
|
80
83
|
},
|
|
81
84
|
setStatus() {},
|
|
85
|
+
async custom(factory: (...args: any[]) => typeof overlay) {
|
|
86
|
+
overlay = factory(undefined, undefined, undefined, () => {});
|
|
87
|
+
},
|
|
82
88
|
},
|
|
83
89
|
};
|
|
84
90
|
|
|
@@ -87,9 +93,49 @@ function setupPrunerCommand(overrides: {
|
|
|
87
93
|
notifications,
|
|
88
94
|
flushCalls,
|
|
89
95
|
currentConfig,
|
|
96
|
+
get overlay() { return overlay; },
|
|
90
97
|
};
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
describe("/pruner settings argument purge", () => {
|
|
101
|
+
initTheme("dark");
|
|
102
|
+
|
|
103
|
+
it.each([
|
|
104
|
+
["purgeErrorsEnabled", "Argument purge", "Shrink long strings in summarized calls when their results are stubbed in the same render, and in errored calls after the error cooldown, keeping argument shape."],
|
|
105
|
+
["purgeErrorsCooldown", "Error purge cooldown (turns)", "Wait this many turns after a tool error before purging its argument body."],
|
|
106
|
+
["purgeErrorsMinArgChars", "Argument purge min arg chars", "Only purge summarized or errored argument bodies at least this many chars."],
|
|
107
|
+
])("describes %s through the settings overlay", async (id, label, description) => {
|
|
108
|
+
const harness = setupPrunerCommand({ save: async () => {} });
|
|
109
|
+
await harness.run("settings");
|
|
110
|
+
const list = harness.overlay.children.find((child) => child instanceof SettingsList) as SettingsList;
|
|
111
|
+
list.selectItem(id);
|
|
112
|
+
|
|
113
|
+
const rendered = harness.overlay.render(400).join("\n");
|
|
114
|
+
expect(rendered).toContain(label);
|
|
115
|
+
expect(rendered).toContain(description);
|
|
116
|
+
if (id === "purgeErrorsCooldown") expect(rendered).not.toContain("same render");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("persists the shared disabled toggle and minimum using only existing purgeErrors fields", async () => {
|
|
120
|
+
const saved: ContextPruneConfig[] = [];
|
|
121
|
+
const harness = setupPrunerCommand({ save: async (config) => { saved.push(structuredClone(config)); } });
|
|
122
|
+
const initial = structuredClone(harness.currentConfig.value);
|
|
123
|
+
await harness.run("settings");
|
|
124
|
+
const list = harness.overlay.children.find((child) => child instanceof SettingsList) as SettingsList;
|
|
125
|
+
|
|
126
|
+
list.selectItem("purgeErrorsEnabled");
|
|
127
|
+
harness.overlay.handleInput(" ");
|
|
128
|
+
list.selectItem("purgeErrorsMinArgChars");
|
|
129
|
+
harness.overlay.handleInput(" ");
|
|
130
|
+
|
|
131
|
+
expect(saved).toEqual([
|
|
132
|
+
{ ...initial, purgeErrors: { ...initial.purgeErrors, enabled: false } },
|
|
133
|
+
{ ...initial, purgeErrors: { ...initial.purgeErrors, enabled: false, minArgChars: 1000 } },
|
|
134
|
+
]);
|
|
135
|
+
expect(harness.currentConfig.value).toEqual(saved[1]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
93
139
|
describe("/pruner now (empty capture)", () => {
|
|
94
140
|
it("invokes flushPending even when nothing is pending, so a flush-metrics entry is recorded", async () => {
|
|
95
141
|
const harness = setupPrunerCommand({ capturePendingBatches: () => [] });
|
package/src/commands.ts
CHANGED
|
@@ -672,10 +672,10 @@ export function registerCommands(
|
|
|
672
672
|
},
|
|
673
673
|
{
|
|
674
674
|
id: "purgeErrorsEnabled",
|
|
675
|
-
label: "
|
|
675
|
+
label: "Argument purge",
|
|
676
676
|
values: ["true", "false"],
|
|
677
677
|
currentValue: String(config.purgeErrors.enabled),
|
|
678
|
-
description: `Shrink long strings
|
|
678
|
+
description: `Shrink long strings in summarized calls when their results are stubbed in the same render, and in errored calls after the error cooldown, keeping argument shape. Currently ${config.purgeErrors.enabled ? "ON" : "OFF"}.`,
|
|
679
679
|
},
|
|
680
680
|
{
|
|
681
681
|
id: "purgeErrorsCooldown",
|
|
@@ -688,12 +688,12 @@ export function registerCommands(
|
|
|
688
688
|
},
|
|
689
689
|
{
|
|
690
690
|
id: "purgeErrorsMinArgChars",
|
|
691
|
-
label: "
|
|
691
|
+
label: "Argument purge min arg chars",
|
|
692
692
|
values: PURGE_MIN_ARG_PRESETS.map((p) => p.value),
|
|
693
693
|
currentValue: PURGE_MIN_ARG_PRESETS.some((p) => p.value === String(config.purgeErrors.minArgChars))
|
|
694
694
|
? String(config.purgeErrors.minArgChars)
|
|
695
695
|
: PURGE_MIN_ARG_PRESETS[1].value,
|
|
696
|
-
description: `Only purge
|
|
696
|
+
description: `Only purge summarized or errored argument bodies at least this many chars. Currently ${config.purgeErrors.minArgChars}.`,
|
|
697
697
|
},
|
|
698
698
|
{
|
|
699
699
|
// Read-only display row. Editing goes through `/pruner protected-tools`
|
package/src/error-purge.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { purgeErroredArgs } from "./error-purge.js";
|
|
3
|
+
import { occKey } from "./occurrence-key.js";
|
|
4
|
+
import { expectNoOrphanToolResults } from "./test-support.js";
|
|
3
5
|
import type { ErrorPurgeConfig } from "./types.js";
|
|
4
6
|
|
|
5
7
|
const defaultConfig: ErrorPurgeConfig = {
|
|
@@ -11,7 +13,7 @@ const defaultConfig: ErrorPurgeConfig = {
|
|
|
11
13
|
const LONG = "x".repeat(300);
|
|
12
14
|
const PLACEHOLDER_RE = /^<purged-errored-args size="\d+"\/>$/;
|
|
13
15
|
|
|
14
|
-
function makeAssistant(toolCallId: string, argsObj:
|
|
16
|
+
function makeAssistant(toolCallId: string, argsObj: any, turnN?: number) {
|
|
15
17
|
return {
|
|
16
18
|
role: "assistant",
|
|
17
19
|
content: [
|
|
@@ -271,3 +273,91 @@ describe("purgeErroredArgs", () => {
|
|
|
271
273
|
expect(original).toEqual(snapshot);
|
|
272
274
|
});
|
|
273
275
|
});
|
|
276
|
+
|
|
277
|
+
describe("positional argument purge", () => {
|
|
278
|
+
const marker = '<purged-args size="600" ref="t6"/>';
|
|
279
|
+
const body = "x".repeat(600);
|
|
280
|
+
|
|
281
|
+
it.each([
|
|
282
|
+
["below body threshold", { code: body }, 612, false],
|
|
283
|
+
["at body threshold", { code: body }, 611, true],
|
|
284
|
+
["200-char string", { code: "x".repeat(200) }, 10, false],
|
|
285
|
+
["201-char string", { code: "x".repeat(201) }, 10, true],
|
|
286
|
+
["only short strings", { code: Array(20).fill("x".repeat(100)) }, 500, false],
|
|
287
|
+
["top-level string", body, 10, true],
|
|
288
|
+
])("summarized gates: %s", (_, args, minArgChars, changed) => {
|
|
289
|
+
const messages = [makeAssistant("tc1", args), makeToolResult("tc1", false)];
|
|
290
|
+
expectNoOrphanToolResults(messages);
|
|
291
|
+
const refs = new Map([[occKey("tc1", 2), "t6"]]);
|
|
292
|
+
const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 99, minArgChars }, refs);
|
|
293
|
+
expectNoOrphanToolResults(result);
|
|
294
|
+
if (changed) {
|
|
295
|
+
expect(result).not.toBe(messages);
|
|
296
|
+
expect((result[0] as any).content[0].arguments).toEqual(
|
|
297
|
+
typeof args === "string" ? marker : { code: `<purged-args size="${args.code.length}" ref="t6"/>` },
|
|
298
|
+
);
|
|
299
|
+
} else expect(result).toBe(messages);
|
|
300
|
+
expect(purgeErroredArgs(result, { ...defaultConfig, minArgChars }, refs)).toBe(result);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("keeps nested shapes, grammar keys, and own prototype keys without mutation", () => {
|
|
304
|
+
const args = { code: body, nested: [[body, true, null, 42, "short"]], ...JSON.parse(`{"__proto__":"${body}"}`) };
|
|
305
|
+
const snapshot = structuredClone(args);
|
|
306
|
+
const messages = [makeAssistant("tc1", args), makeToolResult("tc1", false)];
|
|
307
|
+
expectNoOrphanToolResults(messages);
|
|
308
|
+
const result = purgeErroredArgs(messages, defaultConfig, new Map([[occKey("tc1", 2), "t6"]]));
|
|
309
|
+
expectNoOrphanToolResults(result);
|
|
310
|
+
const shrunk = (result[0] as any).content[0].arguments;
|
|
311
|
+
expect(Object.keys(shrunk)).toEqual(Object.keys(args));
|
|
312
|
+
expect(shrunk).toEqual({ code: marker, nested: [[marker, true, null, 42, "short"]], ...JSON.parse(`{"__proto__":${JSON.stringify(marker)}}`) });
|
|
313
|
+
expect(args).toEqual(snapshot);
|
|
314
|
+
expect(result[1]).toBe(messages[1]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it.each([false, true])("disabled leaves summarized and error sources intact (error=%s)", (isError) => {
|
|
318
|
+
const messages = [makeAssistant("tc1", { code: body }), makeToolResult("tc1", isError)];
|
|
319
|
+
expectNoOrphanToolResults(messages);
|
|
320
|
+
expect(purgeErroredArgs(messages, { ...defaultConfig, enabled: false, cooldownTurns: 0 }, new Map([[occKey("tc1", 2), "t6"]]))).toBe(messages);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("prefers the summarized marker even when the paired result is errored", () => {
|
|
324
|
+
const messages = [makeAssistant("tc1", { code: body }), makeToolResult("tc1", true)];
|
|
325
|
+
expectNoOrphanToolResults(messages);
|
|
326
|
+
const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 0 }, new Map([[occKey("tc1", 2), "t6"]]));
|
|
327
|
+
expectNoOrphanToolResults(result);
|
|
328
|
+
expect((result[0] as any).content[0].arguments.code).toBe(marker);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it.each([
|
|
332
|
+
["timestamped summarized", "summarized", true, "paired"],
|
|
333
|
+
["timestamp-less summarized", "summarized", false, "paired"],
|
|
334
|
+
["error only", "error", true, "paired"],
|
|
335
|
+
["summarized after aborted call", "summarized", true, "aborted"],
|
|
336
|
+
["timestamp-less after aborted call", "summarized", false, "aborted"],
|
|
337
|
+
["error after aborted call", "error", true, "aborted"],
|
|
338
|
+
["summarized after user barrier", "summarized", true, "barrier"],
|
|
339
|
+
["timestamp-less after user barrier", "summarized", false, "barrier"],
|
|
340
|
+
["error after user barrier", "error", true, "barrier"],
|
|
341
|
+
])("isolates reused ids: %s", (_, source, timestamped, earlier) => {
|
|
342
|
+
const first = makeAssistant("reused", { code: body });
|
|
343
|
+
const lastResult: any = { ...makeToolResult("reused", source === "error"), timestamp: 4 };
|
|
344
|
+
if (!timestamped) delete lastResult.timestamp;
|
|
345
|
+
const messages: any[] = [first];
|
|
346
|
+
if (earlier === "paired") {
|
|
347
|
+
// A timestamp-less sibling must not resolve the later timestamped key (or vice versa).
|
|
348
|
+
const sibling: any = makeToolResult("reused", false);
|
|
349
|
+
if (timestamped) delete sibling.timestamp;
|
|
350
|
+
messages.push(sibling);
|
|
351
|
+
}
|
|
352
|
+
if (earlier === "barrier") messages.push({ role: "user", content: "continue" });
|
|
353
|
+
messages.push(makeAssistant("reused", { code: body }, 3), lastResult);
|
|
354
|
+
expectNoOrphanToolResults(messages);
|
|
355
|
+
const refs = source === "summarized" ? new Map([[occKey("reused", timestamped ? 4 : undefined), "t6"]]) : new Map<string, string>();
|
|
356
|
+
const result = purgeErroredArgs(messages, { ...defaultConfig, cooldownTurns: 0 }, refs);
|
|
357
|
+
expectNoOrphanToolResults(result);
|
|
358
|
+
expect(result[0]).toBe(first);
|
|
359
|
+
expect((result[result.length - 2] as any).content[0].arguments.code).toBe(
|
|
360
|
+
source === "summarized" ? marker : '<purged-errored-args size="600"/>',
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
});
|
package/src/error-purge.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
+
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
1
2
|
import type { ErrorPurgeConfig } from "./types.js";
|
|
2
3
|
|
|
3
|
-
// Keep short paths and commands readable while reclaiming large
|
|
4
|
+
// Keep short paths and commands readable while reclaiming large completed-call bodies.
|
|
4
5
|
const PURGE_STRING_MIN_CHARS = 200;
|
|
5
6
|
|
|
6
|
-
function shrinkStrings(
|
|
7
|
+
function shrinkStrings(
|
|
8
|
+
value: unknown,
|
|
9
|
+
marker: (size: number) => string = (size) => `<purged-errored-args size="${size}"/>`,
|
|
10
|
+
): [unknown, boolean] {
|
|
7
11
|
if (typeof value === "string") {
|
|
8
|
-
if (value.length > PURGE_STRING_MIN_CHARS) return [
|
|
12
|
+
if (value.length > PURGE_STRING_MIN_CHARS) return [marker(value.length), true];
|
|
9
13
|
return [value, false];
|
|
10
14
|
}
|
|
11
15
|
if (Array.isArray(value)) {
|
|
12
16
|
let out: unknown[] | undefined;
|
|
13
17
|
for (let i = 0; i < value.length; i++) {
|
|
14
|
-
const [next, changed] = shrinkStrings(value[i]);
|
|
18
|
+
const [next, changed] = shrinkStrings(value[i], marker);
|
|
15
19
|
if (!changed) continue;
|
|
16
20
|
out ??= value.slice();
|
|
17
21
|
out[i] = next;
|
|
@@ -22,7 +26,7 @@ function shrinkStrings(value: unknown): [unknown, boolean] {
|
|
|
22
26
|
const object = value as Record<string, unknown>;
|
|
23
27
|
let out: Record<string, unknown> | undefined;
|
|
24
28
|
for (const key of Object.keys(object)) {
|
|
25
|
-
const [next, changed] = shrinkStrings(object[key]);
|
|
29
|
+
const [next, changed] = shrinkStrings(object[key], marker);
|
|
26
30
|
if (!changed) continue;
|
|
27
31
|
// Spread creates own data properties, including JSON's possible __proto__ key.
|
|
28
32
|
out ??= { ...object };
|
|
@@ -34,65 +38,64 @@ function shrinkStrings(value: unknown): [unknown, boolean] {
|
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
|
-
* Shrinks
|
|
38
|
-
* Keys and JSON types
|
|
39
|
-
* properties when replaying calls.
|
|
40
|
-
*
|
|
41
|
-
* Why only the arguments, not the whole toolCall or its toolResult:
|
|
42
|
-
* - The toolResult content (e.g. "Error: file not found") is small and carries
|
|
43
|
-
* the failure signal the model needs to understand what went wrong.
|
|
44
|
-
* - The toolCall block itself must remain so the provider can pair it with its
|
|
45
|
-
* result and avoid injecting a synthetic "No result provided" error.
|
|
46
|
-
* - The arguments body is what grows large — failed `write` / `edit` calls
|
|
47
|
-
* embed the full file content that will never be acted on again.
|
|
48
|
-
*
|
|
49
|
-
* Why the cooldown:
|
|
50
|
-
* - Gives the model 1–2 turns to retry before context is mutated. Purging
|
|
51
|
-
* immediately would remove the call detail before the model has had a
|
|
52
|
-
* chance to see the error and adapt.
|
|
53
|
-
*
|
|
54
|
-
* Turn index is computed internally by counting AssistantMessages in the input.
|
|
55
|
-
* This avoids threading a turn counter through index.ts.
|
|
41
|
+
* Shrinks summarized arguments immediately and errored arguments after cooldown.
|
|
42
|
+
* Keys and JSON types survive pi-ai grammar validation during replay.
|
|
56
43
|
*/
|
|
57
|
-
export function purgeErroredArgs(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
44
|
+
export function purgeErroredArgs(
|
|
45
|
+
messages: any[],
|
|
46
|
+
config: ErrorPurgeConfig,
|
|
47
|
+
summarizedRefs: ReadonlyMap<string, string> = new Map(),
|
|
48
|
+
): any[] {
|
|
49
|
+
if (!config.enabled) return messages;
|
|
50
|
+
|
|
51
|
+
const targets = new Map<number, Map<string, { turn: number; ref?: string }>>();
|
|
52
|
+
const open = new Map<string, { assistantIndex: number; turn: number }>();
|
|
62
53
|
let turnCount = 0;
|
|
63
|
-
for (
|
|
54
|
+
for (let i = 0; i < messages.length; i++) {
|
|
55
|
+
const msg = messages[i];
|
|
64
56
|
if (msg.role === "assistant") {
|
|
65
|
-
// Count each assistant turn; toolResults referencing the turn come next.
|
|
66
57
|
turnCount++;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
58
|
+
open.clear();
|
|
59
|
+
for (const block of msg.content) {
|
|
60
|
+
if (block.type === "toolCall") open.set(block.id, { assistantIndex: i, turn: turnCount });
|
|
61
|
+
}
|
|
62
|
+
} else if (msg.role === "toolResult") {
|
|
63
|
+
const paired = open.get(msg.toolCallId);
|
|
64
|
+
if (!paired) continue;
|
|
65
|
+
open.delete(msg.toolCallId);
|
|
66
|
+
const ref = summarizedRefs.get(occKey(msg.toolCallId, resultTimestampOf(msg.timestamp)));
|
|
67
|
+
if (ref === undefined && msg.isError !== true) continue;
|
|
68
|
+
let blocks = targets.get(paired.assistantIndex);
|
|
69
|
+
if (!blocks) targets.set(paired.assistantIndex, blocks = new Map());
|
|
70
|
+
blocks.set(msg.toolCallId, { turn: paired.turn, ref });
|
|
71
|
+
} else {
|
|
72
|
+
open.clear();
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
const currentTurnIndex = turnCount;
|
|
76
|
+
if (targets.size === 0) return messages;
|
|
76
77
|
|
|
77
78
|
// Pass 2: rewrite AssistantMessages whose toolCall args should be purged.
|
|
78
79
|
let anyModified = false;
|
|
79
|
-
const result = messages.map((msg) => {
|
|
80
|
-
|
|
80
|
+
const result = messages.map((msg, assistantIndex) => {
|
|
81
|
+
const targetsForTurn = targets.get(assistantIndex);
|
|
82
|
+
if (!targetsForTurn) return msg;
|
|
81
83
|
|
|
82
84
|
let contentModified = false;
|
|
83
85
|
const newContent = (msg.content as any[]).map((block) => {
|
|
84
86
|
if (block.type !== "toolCall") return block;
|
|
85
87
|
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
const age = currentTurnIndex - errorTurn;
|
|
90
|
-
if (age < config.cooldownTurns) return block;
|
|
88
|
+
const target = targetsForTurn.get(block.id);
|
|
89
|
+
if (!target) return block;
|
|
90
|
+
if (target.ref === undefined && turnCount - target.turn < config.cooldownTurns) return block;
|
|
91
91
|
|
|
92
92
|
const argBody = JSON.stringify(block.arguments);
|
|
93
93
|
if (argBody.length < config.minArgChars) return block;
|
|
94
94
|
|
|
95
|
-
const [shrunk, changed] = shrinkStrings(
|
|
95
|
+
const [shrunk, changed] = shrinkStrings(
|
|
96
|
+
block.arguments,
|
|
97
|
+
target.ref === undefined ? undefined : (size) => `<purged-args size="${size}" ref="${target.ref}"/>`,
|
|
98
|
+
);
|
|
96
99
|
if (!changed) return block;
|
|
97
100
|
|
|
98
101
|
contentModified = true;
|
package/src/frontier.test.ts
CHANGED
|
@@ -85,6 +85,7 @@ describe("PruneFrontierTracker.reconstructFromSession", () => {
|
|
|
85
85
|
|
|
86
86
|
const indexer = {
|
|
87
87
|
isSummarized: (id: string) => id === "tc-old" || id === "tc-stub",
|
|
88
|
+
isDedupAlias: () => false,
|
|
88
89
|
hasLegacyBareRecord: (id: string) => id === "tc-old" || id === "tc-stub",
|
|
89
90
|
getShortRefForToolCallId: (id: string) => (id === "tc-stub" ? "t1" : id === "tc-old" ? "told" : undefined),
|
|
90
91
|
getRecord: () => undefined,
|
package/src/indexer.test.ts
CHANGED
|
@@ -132,6 +132,28 @@ describe("occurrence keying", () => {
|
|
|
132
132
|
expect(records.map((r) => r.resultTimestamp)).toEqual([1150, 9150]);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
test("isDedupAlias checks exact registered keys without resolving refs or sibling occurrences", () => {
|
|
136
|
+
const indexer = new ToolCallIndexer();
|
|
137
|
+
indexer.addBatch(batch(0, 1000, [{ id: "bash_23", ts: 1150, text: "SAME" }]), () => {});
|
|
138
|
+
indexer.addBatch(batch(1, 2000, [{ id: "bash_23", ts: 2150, text: "OTHER" }]), () => {});
|
|
139
|
+
indexer.registerSummaryRefs([{ shortId: "t1", toolCallId: "bash_23", resultTimestamp: 1150 }]);
|
|
140
|
+
indexer.registerDuplicate("bash_23@9150", "bash_23@1150", () => {});
|
|
141
|
+
indexer.registerDuplicate("bash_legacy", "bash_23@1150", () => {});
|
|
142
|
+
|
|
143
|
+
for (const [key, expected] of [
|
|
144
|
+
["bash_23@9150", true],
|
|
145
|
+
["bash_legacy", true],
|
|
146
|
+
["bash_23@1150", false],
|
|
147
|
+
["bash_23@2150", false],
|
|
148
|
+
["bash_23", false],
|
|
149
|
+
["bash_legacy@9150", false],
|
|
150
|
+
["unknown", false],
|
|
151
|
+
["t1", false],
|
|
152
|
+
] as const) {
|
|
153
|
+
expect(indexer.isDedupAlias(key)).toBe(expected);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
135
157
|
test("summary body lookups are occurrence-keyed", () => {
|
|
136
158
|
const indexer = new ToolCallIndexer();
|
|
137
159
|
indexer.registerSummaryBody(["bash_23@1150"], "summary of FIRST");
|
|
@@ -302,6 +324,10 @@ describe("session rebuild", () => {
|
|
|
302
324
|
expect(indexer.getRecord("bash_99@4150")?.resultText).toBe("FIRST");
|
|
303
325
|
expect(indexer.getShortRefForToolCallId("bash_99@4150")).toBe("t1");
|
|
304
326
|
expect(indexer.getPerBatchSummaryTextForToolCallIds(["bash_23@2150"])).toBe("summary text");
|
|
327
|
+
expect(indexer.isDedupAlias("bash_99@4150")).toBe(true);
|
|
328
|
+
expect(indexer.isDedupAlias("bash_99")).toBe(false);
|
|
329
|
+
expect(indexer.isDedupAlias("bash_23@1150")).toBe(false);
|
|
330
|
+
expect(indexer.isDedupAlias("t1")).toBe(false);
|
|
305
331
|
});
|
|
306
332
|
|
|
307
333
|
test("legacy entries without resultTimestamp keep bare-id keys", () => {
|
|
@@ -336,6 +362,8 @@ describe("session rebuild", () => {
|
|
|
336
362
|
expect(indexer.hasLegacyBareRecord("bash_7")).toBe(true);
|
|
337
363
|
expect(indexer.hasLegacyBareRecord("bash_23")).toBe(false);
|
|
338
364
|
expect(indexer.isSummarized("bash_8")).toBe(true);
|
|
365
|
+
expect(indexer.isDedupAlias("bash_8")).toBe(true);
|
|
366
|
+
expect(indexer.isDedupAlias("bash_7")).toBe(false);
|
|
339
367
|
});
|
|
340
368
|
});
|
|
341
369
|
|
package/src/indexer.ts
CHANGED
|
@@ -183,6 +183,10 @@ export class ToolCallIndexer {
|
|
|
183
183
|
return this.index.has(occurrenceKey) || this.dedupAliasToOriginal.has(occurrenceKey);
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
isDedupAlias(key: string): boolean {
|
|
187
|
+
return this.dedupAliasToOriginal.has(key);
|
|
188
|
+
}
|
|
189
|
+
|
|
186
190
|
/**
|
|
187
191
|
* Returns the full runtime index map.
|
|
188
192
|
*/
|
package/src/pruner.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { pruneMessages, sizeMessages } from "./pruner.js";
|
|
3
|
+
import { purgeErroredArgs } from "./error-purge.js";
|
|
3
4
|
import { charsOf } from "./context-metrics.js";
|
|
4
5
|
import { ToolCallIndexer } from "./indexer.js";
|
|
5
6
|
import { CUSTOM_TYPE_INDEX } from "./types.js";
|
|
@@ -33,6 +34,7 @@ function makeMockIndexer({
|
|
|
33
34
|
} = {}) {
|
|
34
35
|
return {
|
|
35
36
|
isSummarized: (id: string) => summarized.has(id),
|
|
37
|
+
isDedupAlias: (_key: string) => false,
|
|
36
38
|
hasLegacyBareRecord: (id: string) => summarized.has(id),
|
|
37
39
|
getShortRefForToolCallId: (id: string) => shortRefs.get(id),
|
|
38
40
|
getRecord: (id: string) => records.get(id),
|
|
@@ -47,6 +49,116 @@ function makeMockIndexer({
|
|
|
47
49
|
} as any;
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
describe("summarized argument handoff", () => {
|
|
53
|
+
const prompt = "p".repeat(600);
|
|
54
|
+
const config = { enabled: true, minArgChars: 300, cooldownTurns: 2 };
|
|
55
|
+
const history = (name = "subagent"): any[] => [
|
|
56
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "call", name, arguments: { prompt } }] },
|
|
57
|
+
{ role: "toolResult", toolCallId: "call", toolName: name, timestamp: 10, content: [{ type: "text", text: "original output" }], isError: false },
|
|
58
|
+
];
|
|
59
|
+
const render = (messages: any[], indexer: any, options = config, protection?: any, grace = 0) => {
|
|
60
|
+
expectNoOrphanToolResults(messages);
|
|
61
|
+
const result = pruneWithZeroSweepAssertion(messages, indexer, undefined, options, protection, grace);
|
|
62
|
+
expectNoOrphanToolResults(result.messages);
|
|
63
|
+
return result.messages;
|
|
64
|
+
};
|
|
65
|
+
const indexed = (key = "call@10", record?: any) => makeMockIndexer({
|
|
66
|
+
summarized: new Set([key]), shortRefs: new Map([[key, "t6"]]),
|
|
67
|
+
records: new Map([[key, record]]),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
for (const row of [
|
|
71
|
+
{ label: "summary", record: undefined },
|
|
72
|
+
{ label: "spill", record: { spillPath: "/tmp/output", toolName: "subagent" } },
|
|
73
|
+
{ label: "legacy fallback", key: "call" },
|
|
74
|
+
{ label: "timestamp-less", key: "call" },
|
|
75
|
+
]) {
|
|
76
|
+
it(`stubs and shrinks together: ${row.label}`, () => {
|
|
77
|
+
const raw = history();
|
|
78
|
+
if (row.label === "timestamp-less") delete raw[1].timestamp;
|
|
79
|
+
const out = render(raw, indexed(row.key, row.record));
|
|
80
|
+
expect(out[1].content[0].text).toContain("`t6`");
|
|
81
|
+
expect(out[0].content[0].arguments.prompt).toBe(`<purged-args size="${prompt.length}" ref="t6"/>`);
|
|
82
|
+
expect(raw[0].content[0].arguments.prompt).toBe(prompt);
|
|
83
|
+
expect(purgeErroredArgs(out, config, new Map([[row.label === "timestamp-less" ? "call" : "call@10", "t6"]]))).toBe(out);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const row of [
|
|
88
|
+
{ label: "disabled", options: { ...config, enabled: false } },
|
|
89
|
+
{ label: "body below gate", options: { ...config, minArgChars: 1000 } },
|
|
90
|
+
{ label: "no long strings", args: { a: "a".repeat(180), b: "b".repeat(180) } },
|
|
91
|
+
{ label: "unindexed", unindexed: true },
|
|
92
|
+
{ label: "protected", record: { toolName: "read", args: { path: "keep.md" } }, protection: { protectedTools: [], protectedPaths: ["keep.md"] } },
|
|
93
|
+
]) {
|
|
94
|
+
it(`retains arguments: ${row.label}`, () => {
|
|
95
|
+
const raw = history();
|
|
96
|
+
if (row.args) raw[0].content[0].arguments = row.args;
|
|
97
|
+
const out = render(raw, row.unindexed ? makeMockIndexer() : indexed(undefined, row.record), row.options, row.protection);
|
|
98
|
+
expect(out[0]).toBe(raw[0]);
|
|
99
|
+
expect(out[1] === raw[1]).toBe(!!row.unindexed || !!row.protection);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
it("pairs timestamp-less handoff positionally without shrinking a reused-id sibling", () => {
|
|
104
|
+
const indexer = indexed("call");
|
|
105
|
+
indexer.hasLegacyBareRecord = () => false;
|
|
106
|
+
const raw = [...history(), { role: "user", content: "next" }, ...history()];
|
|
107
|
+
delete raw[4].timestamp;
|
|
108
|
+
const out = render(raw, indexer);
|
|
109
|
+
expect(out[0]).toBe(raw[0]);
|
|
110
|
+
expect(out[1]).toBe(raw[1]);
|
|
111
|
+
expect(out[3].content[0].arguments.prompt).toBe('<purged-args size="600" ref="t6"/>');
|
|
112
|
+
expect(out[4].content[0].text).toContain("`t6`");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("uses the same raw-id fallback ref in the stub and argument marker", () => {
|
|
116
|
+
const out = render(history(), makeMockIndexer({ summarized: new Set(["call@10"]) }));
|
|
117
|
+
expect(out[1].content[0].text).toContain("`call`");
|
|
118
|
+
expect(out[0].content[0].arguments.prompt).toBe('<purged-args size="600" ref="call"/>');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("advances recovery grace with stub and shrink at the same boundary", () => {
|
|
122
|
+
const raw = history("context_tree_query");
|
|
123
|
+
const indexer = indexed();
|
|
124
|
+
expect(render(raw, indexer, config, undefined, 1)).toBe(raw);
|
|
125
|
+
raw.push({ role: "user", content: "next" }, { role: "assistant", content: [] }, { role: "user", content: "again" });
|
|
126
|
+
const out = render(raw, indexer, config, undefined, 1);
|
|
127
|
+
expect(out[1].content[0].text).toContain("`t6`");
|
|
128
|
+
expect(out[0].content[0].arguments.prompt).toBe(`<purged-args size="600" ref="t6"/>`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("switches aged error markers on indexing and keeps fresh-render prefixes stable", () => {
|
|
132
|
+
const raw = history();
|
|
133
|
+
raw[1].isError = true;
|
|
134
|
+
raw.push({ role: "assistant", content: [] }, { role: "user", content: "next" }, { role: "assistant", content: [] });
|
|
135
|
+
const before = render(structuredClone(raw), makeMockIndexer());
|
|
136
|
+
expect(before[0].content[0].arguments.prompt).toBe('<purged-errored-args size="600"/>');
|
|
137
|
+
const indexer = indexed();
|
|
138
|
+
const first = render(structuredClone(raw), indexer);
|
|
139
|
+
expect(first[0].content[0].arguments.prompt).toBe('<purged-args size="600" ref="t6"/>');
|
|
140
|
+
expect(first[1].isError).toBe(false);
|
|
141
|
+
expect(JSON.stringify(render(structuredClone(raw), indexer))).toBe(JSON.stringify(first));
|
|
142
|
+
const extended = [...structuredClone(raw), { role: "user", content: "continue" }, { role: "assistant", content: [] }];
|
|
143
|
+
expect(JSON.stringify(render(extended, indexer).slice(0, raw.length))).toBe(JSON.stringify(first));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("keeps dedup alias prompts while shrinking the recoverable canonical prompt", () => {
|
|
147
|
+
const indexer = new ToolCallIndexer();
|
|
148
|
+
const raw = history();
|
|
149
|
+
indexer.addBatch({ turnIndex: 0, timestamp: 0, assistantText: "", toolCalls: [{ toolCallId: "call", toolName: "subagent", args: raw[0].content[0].arguments, resultText: "original output", isError: false, resultTimestamp: 10 }] }, () => {});
|
|
150
|
+
indexer.registerSummaryRefs([{ shortId: "t6", toolCallId: "call", resultTimestamp: 10 }]);
|
|
151
|
+
indexer.registerDuplicate("alias@20", "call@10", () => {});
|
|
152
|
+
raw.push({ role: "assistant", content: [{ type: "toolCall", id: "alias", name: "subagent", arguments: { prompt: "alias".repeat(120) } }] }, { ...raw[1], toolCallId: "alias", timestamp: 20 });
|
|
153
|
+
const out = render(raw, indexer);
|
|
154
|
+
expect(out[0].content[0].arguments.prompt).toBe('<purged-args size="600" ref="t6"/>');
|
|
155
|
+
expect(out[2]).toBe(raw[2]);
|
|
156
|
+
expect(out[1].content[0].text).toContain("`t6`");
|
|
157
|
+
expect(out[3].content[0].text).toContain("`t6`");
|
|
158
|
+
expect(indexer.getRecord("call@10")?.args).toEqual({ prompt });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
50
162
|
const enabledCC: ChainCompressionConfig = {
|
|
51
163
|
enabled: true,
|
|
52
164
|
rollingWindow: 0,
|
package/src/pruner.ts
CHANGED
|
@@ -46,9 +46,9 @@ export function sizeMessages(messages: any[]): number {
|
|
|
46
46
|
* See src/supersede.ts. Runs before phase 3 so a superseded read inside a
|
|
47
47
|
* compressed chain relocates as the stub, not the verbatim body.
|
|
48
48
|
*
|
|
49
|
-
* Phase 2
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* Phase 2 - argument purge: shrinks summarized toolCall arguments in the
|
|
50
|
+
* stub render and failed toolCall arguments after cooldown. Unsummarized
|
|
51
|
+
* toolResult error messages stay visible.
|
|
52
52
|
*
|
|
53
53
|
* Phase 3 — chain range prune: closed chains older than the rolling window
|
|
54
54
|
* are dropped (middle assistant + toolResult messages) and replaced with a
|
|
@@ -75,9 +75,8 @@ export function sizeMessages(messages: any[]): number {
|
|
|
75
75
|
* the only consumer (`index.ts` live-reclaim) reads them solely under
|
|
76
76
|
* `if (result.pruned)`.
|
|
77
77
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* `context_tree_query`.
|
|
78
|
+
* Tool-call ids, names, and result pairing stay unchanged; eligible argument
|
|
79
|
+
* strings shrink with a ref for recovery through `context_tree_query`.
|
|
81
80
|
*/
|
|
82
81
|
export function pruneMessages(
|
|
83
82
|
messages: any[],
|
|
@@ -91,6 +90,7 @@ export function pruneMessages(
|
|
|
91
90
|
): { messages: any[]; pruned: boolean; beforeChars: number; afterChars: number } {
|
|
92
91
|
// Phase 1: stub-replace summarized tool results
|
|
93
92
|
let pruned = false;
|
|
93
|
+
const summarizedRefs = new Map<string, string>();
|
|
94
94
|
const inGrace = inGraceRecoveryToolCallIds(messages, recoveryGraceTurns);
|
|
95
95
|
const next = messages.map((msg) => {
|
|
96
96
|
if (msg.role !== "toolResult") return msg;
|
|
@@ -124,6 +124,7 @@ export function pruneMessages(
|
|
|
124
124
|
}
|
|
125
125
|
pruned = true;
|
|
126
126
|
const ref = indexer.getShortRefForToolCallId(lookupKey) ?? msg.toolCallId;
|
|
127
|
+
if (!indexer.isDedupAlias(lookupKey)) summarizedRefs.set(key, ref);
|
|
127
128
|
const text = record?.spillPath
|
|
128
129
|
? [
|
|
129
130
|
`[Oversized output spilled to file — ${record.spillBytes ?? "?"} bytes.]`,
|
|
@@ -155,9 +156,9 @@ export function pruneMessages(
|
|
|
155
156
|
}
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
// Phase 2:
|
|
159
|
+
// Phase 2: argument purge - summarized now, errored after cooldown
|
|
159
160
|
if (errorPurge?.enabled) {
|
|
160
|
-
const afterPurge = purgeErroredArgs(current, errorPurge);
|
|
161
|
+
const afterPurge = purgeErroredArgs(current, errorPurge, summarizedRefs);
|
|
161
162
|
if (afterPurge !== current) {
|
|
162
163
|
current = afterPurge;
|
|
163
164
|
pruned = true;
|
package/src/query-tool.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { pruneWithZeroSweepAssertion, expectNoOrphanToolResults } from "./test-support.js";
|
|
2
3
|
import { registerQueryTool } from "./query-tool.js";
|
|
3
4
|
import { imageDigest } from "./batch-capture.js";
|
|
4
5
|
import { ToolCallIndexer } from "./indexer.js";
|
|
@@ -91,6 +92,33 @@ describe("context_tree_query image recovery", () => {
|
|
|
91
92
|
});
|
|
92
93
|
|
|
93
94
|
describe("context_tree_query occurrence handling", () => {
|
|
95
|
+
test("alias ref recovers unclipped canonical arguments after rendering, not alias arguments", async () => {
|
|
96
|
+
const idx = new ToolCallIndexer();
|
|
97
|
+
const prompt = "canonical".repeat(80);
|
|
98
|
+
const aliasPrompt = "alias".repeat(120);
|
|
99
|
+
idx.addBatch({ turnIndex: 0, timestamp: 0, assistantText: "", toolCalls: [{ toolCallId: "canonical", toolName: "subagent", args: { prompt }, resultText: "shared original output", isError: false, resultTimestamp: 10 }] }, () => {});
|
|
100
|
+
idx.registerSummaryRefs([{ shortId: "t6", toolCallId: "canonical", resultTimestamp: 10 }]);
|
|
101
|
+
idx.registerDuplicate("alias@20", "canonical@10", () => {});
|
|
102
|
+
const raw = [
|
|
103
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "canonical", name: "subagent", arguments: { prompt } }] },
|
|
104
|
+
{ role: "toolResult", toolCallId: "canonical", toolName: "subagent", timestamp: 10, content: [{ type: "text", text: "shared original output" }] },
|
|
105
|
+
{ role: "assistant", content: [{ type: "toolCall", id: "alias", name: "subagent", arguments: { prompt: aliasPrompt } }] },
|
|
106
|
+
{ role: "toolResult", toolCallId: "alias", toolName: "subagent", timestamp: 20, content: [{ type: "text", text: "shared original output" }] },
|
|
107
|
+
];
|
|
108
|
+
expectNoOrphanToolResults(raw);
|
|
109
|
+
const out = pruneWithZeroSweepAssertion(raw, idx, undefined, { enabled: true, minArgChars: 300, cooldownTurns: 2 }).messages;
|
|
110
|
+
expectNoOrphanToolResults(out);
|
|
111
|
+
expect(out[0].content[0].arguments.prompt).toBe(`<purged-args size="${prompt.length}" ref="t6"/>`);
|
|
112
|
+
expect(out[2].content[0].arguments.prompt).toBe(aliasPrompt);
|
|
113
|
+
const aliasRef = idx.getShortRefForToolCallId("alias@20");
|
|
114
|
+
expect(aliasRef).toBe("t6");
|
|
115
|
+
expect(idx.getRecordsForId(aliasRef!)[0].args).toEqual({ prompt });
|
|
116
|
+
const text = await runTool(idx, [aliasRef!]);
|
|
117
|
+
expect(text).toContain(`Args: ${JSON.stringify({ prompt }, null, 2)}`);
|
|
118
|
+
expect(text).toContain("shared original output");
|
|
119
|
+
expect(text).not.toContain(aliasPrompt);
|
|
120
|
+
expect(text).not.toContain("<purged-args");
|
|
121
|
+
});
|
|
94
122
|
test("a bare id with two occurrences returns both blocks, chronologically", async () => {
|
|
95
123
|
const idx = new ToolCallIndexer();
|
|
96
124
|
capture(idx, "bash_23", 2150, "SECOND", 1);
|
package/src/supersede.test.ts
CHANGED
|
@@ -7,9 +7,9 @@ import {
|
|
|
7
7
|
findSuperseded,
|
|
8
8
|
lowerFloor,
|
|
9
9
|
supersededStub,
|
|
10
|
-
type SupersedeState,
|
|
11
10
|
} from "./supersede.js";
|
|
12
11
|
import { isProtected } from "./protected.js";
|
|
12
|
+
import { expectNoOrphanToolResults } from "./test-support.js";
|
|
13
13
|
import { occKey } from "./occurrence-key.js";
|
|
14
14
|
|
|
15
15
|
const cfg = { protectedTools: ["phase_tracker"], protectedPaths: ["**/skills/**/*.md"] };
|
|
@@ -42,9 +42,15 @@ describe("findSuperseded", () => {
|
|
|
42
42
|
expect(findSuperseded(msgs, prot).map((c) => c.toolCallId)).toEqual(["a", "b"]);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
test("different offset/limit slices of one path ->
|
|
45
|
+
test("different offset/limit slices of one path -> neither is a candidate", () => {
|
|
46
46
|
const msgs = [...call("a", 10, { path: SKILL, offset: 1, limit: 400 }), ...call("b", 20, { path: SKILL, offset: 400, limit: 50 })];
|
|
47
|
-
|
|
47
|
+
expectNoOrphanToolResults(msgs);
|
|
48
|
+
expect(findSuperseded(msgs, prot)).toEqual([]);
|
|
49
|
+
const state = createSupersedeState();
|
|
50
|
+
state.floor = 0;
|
|
51
|
+
const out = applySupersede(msgs, state, prot);
|
|
52
|
+
expect(out).toBe(msgs);
|
|
53
|
+
expectNoOrphanToolResults(out);
|
|
48
54
|
});
|
|
49
55
|
|
|
50
56
|
test("two paths interleaved -> per-path winners and candidates", () => {
|
|
@@ -153,6 +159,70 @@ describe("findSuperseded", () => {
|
|
|
153
159
|
});
|
|
154
160
|
});
|
|
155
161
|
|
|
162
|
+
type RangeRead = { offset?: unknown; limit?: unknown; cap?: string; text?: string; path?: string };
|
|
163
|
+
const oversizedLine = `[Line 1 is 61.2KB, exceeds 50.0KB limit. Use bash: sed -n '1p' ${SKILL} | head -c 51200]`;
|
|
164
|
+
const cap1800 = "[Showing lines 1-1800 of 3000 (50.0KB limit). Use offset=1801 to continue.]";
|
|
165
|
+
const cap2000 = "[Showing lines 1-2000 of 3000. Use offset=2001 to continue.]";
|
|
166
|
+
|
|
167
|
+
describe("delivered range coverage", () => {
|
|
168
|
+
const cases: [string, RangeRead[], string[]][] = [
|
|
169
|
+
["full/full", [{}, {}], ["r0"]],
|
|
170
|
+
["tail then oversized-first-line warning", [{ offset: 2001 }, { text: oversizedLine }], []],
|
|
171
|
+
["oversized-first-line warning then full", [{ text: oversizedLine }, {}], ["r0"]],
|
|
172
|
+
["disjoint chunks", [{ limit: 2000 }, { offset: 2001 }], []],
|
|
173
|
+
["chunk then capped full", [{ limit: 2000 }, { cap: cap2000 }], ["r0"]],
|
|
174
|
+
["tail then capped full", [{ offset: 2001, limit: 1000 }, { cap: cap2000 }], []],
|
|
175
|
+
["full then chunk", [{}, { limit: 50 }], []],
|
|
176
|
+
["chunk1/chunk2/chunk1", [{ limit: 2000 }, { offset: 2001 }, { limit: 2000 }], ["r0"]],
|
|
177
|
+
["equal capped full reads", [{ cap: cap2000 }, { cap: cap2000 }], ["r0"]],
|
|
178
|
+
["byte-capped limit then capped full", [{ limit: 2500, cap: cap1800 }, { cap: cap2000 }], ["r0"]],
|
|
179
|
+
["capped full then byte-capped limit", [{ cap: cap2000 }, { limit: 2500, cap: cap1800 }], []],
|
|
180
|
+
["zero offset", [{ offset: 0, limit: 100 }, {}], ["r0"]],
|
|
181
|
+
["null offset full then chunk", [{ offset: null }, { limit: 50 }], []],
|
|
182
|
+
["null offset limited", [{ limit: 50 }, { offset: null, limit: 50 }], ["r0"]],
|
|
183
|
+
["negative offset", [{ offset: -10, limit: 50 }, { limit: 50 }], ["r0"]],
|
|
184
|
+
["null limit is open", [{ limit: null }, { limit: 50 }], []],
|
|
185
|
+
["string values are open", [{ offset: "2001", limit: "50" }, { limit: 50 }], []],
|
|
186
|
+
["later string values cover full", [{}, { offset: "2001", limit: "50" }], ["r0"]],
|
|
187
|
+
["later null limit covers full", [{}, { limit: null }], ["r0"]],
|
|
188
|
+
["zero limit covers nothing", [{ limit: 50 }, { limit: 0 }], []],
|
|
189
|
+
["negative limit covers nothing", [{ limit: 50 }, { limit: -1 }], []],
|
|
190
|
+
["empty cannot cover empty", [{ limit: 0 }, { limit: -1 }], []],
|
|
191
|
+
["nonempty covers empty", [{ offset: 2001, limit: 0 }, { limit: 50 }], ["r0"]],
|
|
192
|
+
["partial overlap", [{ offset: 10, limit: 50 }, { offset: 20, limit: 50 }], []],
|
|
193
|
+
["equal end with earlier start", [{ offset: 10, limit: 41 }, { limit: 50 }], ["r0"]],
|
|
194
|
+
["different paths", [{ limit: 50 }, { path: OTHER }], []],
|
|
195
|
+
["intermediate covering read", [{ limit: 50 }, { limit: 100 }, { offset: 2001 }], ["r0"]],
|
|
196
|
+
["offset byte cap", [{ offset: 1001, limit: 2500, cap: "[Showing lines 1001-2800 of 4000 (50.0KB limit). Use offset=2801 to continue.]" }, { offset: 1001, limit: 1800 }], ["r0"]],
|
|
197
|
+
["ordinary continuation keeps limit", [{ limit: 50, cap: "[2950 more lines in file. Use offset=51 to continue.]" }, { limit: 50 }], ["r0"]],
|
|
198
|
+
["nonterminal notice is body text", [{ cap: cap2000 + "\nmore body" }, { limit: 2000 }], []],
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
test.each(cases)("%s", (_name, reads, expected) => {
|
|
202
|
+
const msgs = reads.flatMap(({ cap, text, ...args }, i) => {
|
|
203
|
+
const pair = call(`r${i}`, i * 10, { path: SKILL, ...args });
|
|
204
|
+
if (text) pair[1].content[0].text = text;
|
|
205
|
+
if (cap) pair[1].content[0].text += `\n\n${cap}`;
|
|
206
|
+
return pair;
|
|
207
|
+
});
|
|
208
|
+
expectNoOrphanToolResults(msgs);
|
|
209
|
+
expect(findSuperseded(msgs, prot).map((c) => c.toolCallId)).toEqual(expected);
|
|
210
|
+
const state = createSupersedeState();
|
|
211
|
+
expect(applySupersede(msgs, state, prot)).toBe(msgs);
|
|
212
|
+
state.floor = 0;
|
|
213
|
+
const out = applySupersede(msgs, state, prot);
|
|
214
|
+
expectNoOrphanToolResults(out);
|
|
215
|
+
if (expected.length === 0) expect(out).toBe(msgs);
|
|
216
|
+
for (let i = 0; i < reads.length; i++) {
|
|
217
|
+
if (expected.includes(`r${i}`)) expect(out[i * 2 + 1].content[0].text).toBe(supersededStub(SKILL));
|
|
218
|
+
else expect(out[i * 2 + 1]).toBe(msgs[i * 2 + 1]);
|
|
219
|
+
}
|
|
220
|
+
const sticky = applySupersede(msgs, state, prot);
|
|
221
|
+
expect(sticky).toEqual(out);
|
|
222
|
+
expectNoOrphanToolResults(sticky);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
156
226
|
describe("floor helpers", () => {
|
|
157
227
|
test("lowerFloor sets when undefined, lowers monotonically, ignores undefined input", () => {
|
|
158
228
|
const s = createSupersedeState();
|
package/src/supersede.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { normalizePath } from "./protected.js";
|
|
2
2
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Protected reads are never indexed, so nothing else in the pipeline ever
|
|
6
|
-
* collapses a re-read of the same skill file. This module keeps only the
|
|
7
|
-
* newest protected read per `args.path` verbatim (spec 2026-09-07).
|
|
8
|
-
*/
|
|
4
|
+
/** Protected reads stay verbatim until a later same-path read covers their delivered lines. */
|
|
9
5
|
export interface SupersededCandidate {
|
|
10
6
|
toolCallId: string;
|
|
11
7
|
path: string;
|
|
@@ -49,11 +45,34 @@ export function supersededStub(path: string): string {
|
|
|
49
45
|
|
|
50
46
|
export type IsProtectedFn = (toolName: string, args: unknown) => boolean;
|
|
51
47
|
|
|
48
|
+
interface DeliveredRange {
|
|
49
|
+
start: number;
|
|
50
|
+
end: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function deliveredRange(args: Record<string, unknown>, result: any): DeliveredRange {
|
|
54
|
+
const start = typeof args.offset === "number" ? Math.max(1, args.offset) : 1;
|
|
55
|
+
const limit = args.limit;
|
|
56
|
+
let end = typeof limit === "number" ? (limit <= 0 ? start - 1 : start + limit - 1) : Infinity;
|
|
57
|
+
for (const block of result.content ?? []) {
|
|
58
|
+
if (block?.type !== "text" || typeof block.text !== "string") continue;
|
|
59
|
+
if (/^\[Line \d+ is [^,]+, exceeds [^ ]+ limit\. Use bash: .*\]$/.test(block.text)) return { start, end: start - 1 };
|
|
60
|
+
const cap = block.text.match(/(?:^|\n)\[Showing lines \d+-(\d+) of \d+(?: \([^)]*limit\))?\. Use offset=\d+ to continue\.\]$/);
|
|
61
|
+
if (cap) end = Math.min(end, Number(cap[1]));
|
|
62
|
+
}
|
|
63
|
+
return { start, end };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function covers(later: DeliveredRange, earlier: DeliveredRange): boolean {
|
|
67
|
+
if (later.end < later.start) return false;
|
|
68
|
+
return earlier.end < earlier.start || (later.start <= earlier.start && later.end >= earlier.end);
|
|
69
|
+
}
|
|
70
|
+
|
|
52
71
|
export function findSuperseded(messages: any[], isProtected: IsProtectedFn): SupersededCandidate[] {
|
|
53
72
|
// Provider ids repeat across turns and an aborted call has no result, so pairing
|
|
54
73
|
// uses the same per-turn open-set model as orphan-sweep, not a global per-id cursor.
|
|
55
74
|
let open = new Map<string, any>();
|
|
56
|
-
const byPath = new Map<string, SupersededCandidate[]>();
|
|
75
|
+
const byPath = new Map<string, { candidate: SupersededCandidate; range: DeliveredRange }[]>();
|
|
57
76
|
|
|
58
77
|
for (let i = 0; i < messages.length; i++) {
|
|
59
78
|
const m = messages[i];
|
|
@@ -77,16 +96,25 @@ export function findSuperseded(messages: any[], isProtected: IsProtectedFn): Sup
|
|
|
77
96
|
timestamp: resultTimestampOf(m.timestamp),
|
|
78
97
|
resultIndex: i,
|
|
79
98
|
};
|
|
99
|
+
const occurrence = { candidate: cand, range: deliveredRange(args, m) };
|
|
80
100
|
const list = byPath.get(path);
|
|
81
|
-
if (list) list.push(
|
|
82
|
-
else byPath.set(path, [
|
|
101
|
+
if (list) list.push(occurrence);
|
|
102
|
+
else byPath.set(path, [occurrence]);
|
|
83
103
|
continue;
|
|
84
104
|
}
|
|
85
105
|
open = new Map();
|
|
86
106
|
}
|
|
87
107
|
|
|
88
108
|
const out: SupersededCandidate[] = [];
|
|
89
|
-
for (const list of byPath.values())
|
|
109
|
+
for (const list of byPath.values()) {
|
|
110
|
+
for (let i = 0; i < list.length - 1; i++) {
|
|
111
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
112
|
+
if (!covers(list[j].range, list[i].range)) continue;
|
|
113
|
+
out.push(list[i].candidate);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
90
118
|
out.sort((a, b) => a.resultIndex - b.resultIndex);
|
|
91
119
|
return out;
|
|
92
120
|
}
|