pi-condense 2.10.2 → 2.10.4
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 +17 -0
- package/PRUNING.md +28 -3
- package/README.md +2 -2
- package/index.ts +34 -1
- package/package.json +1 -1
- package/src/commands.test.ts +49 -0
- package/src/commands.ts +14 -13
- package/src/config.test.ts +99 -1
- package/src/config.ts +74 -17
- package/src/protected.test.ts +8 -1
- package/src/protected.ts +6 -1
- package/src/pruner.test.ts +74 -1
- package/src/pruner.ts +19 -1
- package/src/reload-rearm.integration.test.ts +518 -0
- package/src/supersede.test.ts +302 -0
- package/src/supersede.ts +117 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ 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.10.4] - 2026-09-15
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- `saveConfig` no longer overwrites a `settings.json` it could not read as a JSON object (unreadable, truncated, or non-object); only a missing file starts from empty. A failed `/pruner` save now shows an error notification naming the file instead of an unhandled promise rejection; the change still applies to the current session. (#15)
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `release.sh <level>` promotes the CHANGELOG `## [Unreleased]` section to the versioned heading and commits it with `package.json` in the single `Release X.Y.Z` commit; a missing or empty section fails the run. New CONFIG field `CHANGELOG_HEADING`.
|
|
19
|
+
- Release skill: a user instruction naming the level is the approval - no proposal step or re-confirmation; bundled follow-ups run after `verify`.
|
|
20
|
+
- AGENTS.md rewritten to always-on essentials plus routing; shared core bumped to v3. Session entry types table moved to `PRUNING.md`.
|
|
21
|
+
- `.pi/gauntlet-overrides.md` gains `tracker: github`, the release path, and a write-gate carve-out for user-named writes; the Tickets section is superseded by the core Ticket convention.
|
|
22
|
+
|
|
23
|
+
## [2.10.3] - 2026-09-07
|
|
24
|
+
|
|
25
|
+
- **Protected-path supersession.** Only the newest read of a protected path (`protectedPaths` / `protectedTools` calls with a string `path`) stays verbatim; earlier reads of the same path become a one-line `[Superseded: ...]` stub. Applied at render time (`pruneMessages` phase 1b, `src/supersede.ts`) and only when the pruner is already rewriting at or before that position, or on a cold-cache event (`session_start`, `session_tree`, `model_select`, `session_compact`, `thinking_level_select`) - never as the sole mid-prefix change. No new session entry, index record, or config key; supersession stops exactly when no protected call remains (`protectedPaths: []` with the default `protectedTools: []`); a read protected by tool name alone still participates. Spec: `doc/specs/2026-09-07-protected-path-supersede.md` (partially supersedes the 2026-06-11 protected-paths spec's "verbatim forever" edge case).
|
|
26
|
+
|
|
10
27
|
## [2.10.2] - 2026-09-06
|
|
11
28
|
|
|
12
29
|
- Protect `gauntlet-overrides.md` reads by default alongside skill files, including `.pi/` and `doc/` paths, so per-repo harness contracts survive context pruning. User-supplied `protectedPaths` still replaces the defaults; path matching remains limited to `args.path`.
|
package/PRUNING.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
2. [What Pruning Does](#what-pruning-does)
|
|
11
11
|
3. [Pruned Data Is Still Available](#pruned-data-is-still-available)
|
|
12
12
|
4. [What Actually Lives in the Pruner Index](#what-actually-lives-in-the-pruner-index)
|
|
13
|
+
- [Session entry types](#session-entry-types)
|
|
13
14
|
5. [How the Model Re-reads Raw Outputs](#how-the-model-re-reads-raw-outputs)
|
|
14
15
|
6. [How Prefix Caching Works](#how-prefix-caching-works)
|
|
15
16
|
7. [Why Frequent Pruning Busts Cache](#why-frequent-pruning-busts-cache)
|
|
@@ -287,6 +288,21 @@ So after pruning, the model is working with a **two-layer memory**:
|
|
|
287
288
|
| 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` |
|
|
288
289
|
| Duplicate of an already-indexed record (same toolName + content) | **Aliased to the original; no new summary, no LLM call** (`context-prune-dedup-alias` session entry) | See [Content-hash dedup](#content-hash-dedup) |
|
|
289
290
|
|
|
291
|
+
### Session entry types
|
|
292
|
+
|
|
293
|
+
Custom session entry types written by the extension (NOT in LLM context unless noted). Rebuilt on `session_start` where stated; see the `Written by` column for the source.
|
|
294
|
+
|
|
295
|
+
| customType | Written by | Purpose |
|
|
296
|
+
|---|---|---|
|
|
297
|
+
| `context-prune-index` | `indexer.addBatch`; also `indexer.backfillChainRecords` (uncovered-chain deterministic backfill, `src/chain-compressor.ts`) | One entry per summarized batch; rebuilds the in-memory `ToolCallRecord` map on `session_start`. A backfill-carrier entry additionally sets `backfilled: true` and carries `refs` (the allocated `t<N>` `SummaryToolCallRef[]`) - excluded from content-hash dedup canonical seeding on both the live path and `session_start` reconstruction; `refs` are re-registered via `registerSummaryRefs` on reconstruction since backfilled chains have no summary message to derive aliases from |
|
|
298
|
+
| `context-prune-summary` | `flushPending` (runtime: `pi.sendMessage` steer; session: `appendCustomMessageEntry`) | The summary message itself; IS in LLM context (replaces the pruned raw outputs) |
|
|
299
|
+
| `context-prune-stats` | `statsAccum.persist` | Cumulative summarizer token/cost snapshot |
|
|
300
|
+
| `context-prune-frontier` | `flushPending` | Last attempted prune boundary (advances even on `skipped-oversized` / `skipped-trivial` / `skipped-deduped`) |
|
|
301
|
+
| `context-prune-dedup-alias` | `indexer.registerDuplicate` | One entry per content-hash dedup hit; rebuilt on `session_start` to repopulate `dedupAliasToOriginal` |
|
|
302
|
+
| `context-prune-chain` | `chain-compressor.compressEligible` (called from `flushPending` in `index.ts` and from `/pruner compact`) | One entry per chain that has been range-dropped from LLM context; drops are decided **positionally** by `resolveRange` (`src/chain-range-prune.ts`), not by id. `droppedToolCallIds` is a diagnostic cross-check only (recorded-vs-actual mismatch emits `range-id-mismatch`); `droppedOccurrenceKeys` (optional) is load-bearing - it's what the occurrence-keyed synthetic-body lookup (per-batch summary text/coverage) is keyed against; protected-output text is NOT keyed off it - `src/chain-range-prune.ts` pulls `protectedToolCallIds` live by bare id within the resolved range instead. Also carries optional `rangeSummaryText` (fused LLM range summary) when `fuseRangeSummary` is on, and optional `protectedToolCallIds` (verbatim protected outputs - ids protected by tool name or path glob - are relocated into the synthetic body as `<protected-output>` tags at render time). Optional `bodySource: "deterministic"` marks a chain that had zero per-batch summary coverage: `rangeSummaryText` then holds a zero-LLM stub (call count, tool histogram, span duration, `t<N>` refs) built by the uncovered-chain backfill path in `chain-compressor.ts`, instead of a summarizer-derived body. Rebuilt on `session_start` to repopulate the chain registry. |
|
|
303
|
+
| `context-prune-diagnostic` | `pruneMessages` / `applyChainCompressions` / `chain-compressor.compressEligible` (via `DiagnosticSink.report`, `src/diagnostics.ts`) | One entry per distinct `(kind, dedupKey)` prune-time degradation (`unresolved-range` / `range-id-mismatch` / `orphan-sweep` / `backfill-empty`). Never in LLM context; deduped in-memory; reset on `session_start` and `session_tree`. Surfaced on the footer status widget as `diag u<N>/m<N>/o<N>/b<N>`. See [Diagnostics](#diagnostics). |
|
|
304
|
+
| `context-prune-flush-metrics` | `flushPending` (end of every non-concurrent attempt, single `finally` emit site, outside the chain-compression try/catch) | One entry per flush attempt, all outcomes (incl. `empty`/`error`): trigger, batch counts, pre-flush `ContextMetricsSnapshot` (open-cycle thinking, largest-chain share, frontier gap). Append-only observability log - never in LLM context, never reconstructed on `session_start`. |
|
|
305
|
+
|
|
290
306
|
## How the Model Re-reads Raw Outputs
|
|
291
307
|
|
|
292
308
|
The intended recovery flow is:
|
|
@@ -567,6 +583,8 @@ graph LR
|
|
|
567
583
|
captured batches (from turn_end or session scan)
|
|
568
584
|
│
|
|
569
585
|
├─ 1. Protected-tools/paths filter (capture-time, see below)
|
|
586
|
+
│ newest read per protected path stays verbatim; older reads of the same
|
|
587
|
+
│ path are stubbed at render time once the cache is cold anyway (see § Supersession)
|
|
570
588
|
│ tool calls whose toolName is in protectedTools, OR whose args.path
|
|
571
589
|
│ matches any protectedPaths glob, never enter the batch
|
|
572
590
|
│
|
|
@@ -614,7 +632,7 @@ Implementation: `src/pruner.ts` `pruneMessages(messages, indexer)` returns `{ me
|
|
|
614
632
|
|
|
615
633
|
### Protected tools & paths
|
|
616
634
|
|
|
617
|
-
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.
|
|
635
|
+
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 newer read of the same path supersedes it (below).
|
|
618
636
|
|
|
619
637
|
**`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`.
|
|
620
638
|
|
|
@@ -624,6 +642,10 @@ Glob contract: full-path match against the raw `args.path` string with `\` norma
|
|
|
624
642
|
|
|
625
643
|
**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.
|
|
626
644
|
|
|
645
|
+
**Supersession (phase 1b, `src/supersede.ts`):** protected calls are never indexed, so content-hash dedup never sees them. Instead, at render time only the **newest** protected occurrence per normalized `args.path` (backslash -> slash; `offset`/`limit` ignored) stays verbatim; every earlier occurrence with a paired result 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 (an aborted call cannot steal the win). Recovery is "re-read the file" - no `t<N>` ref. Provider tool-call ids repeat across turns and an aborted call has no result, so a result is paired only with the same-id call in the immediately preceding assistant message (the per-turn open-set model orphan-sweep uses) - never by a global per-id cursor.
|
|
646
|
+
|
|
647
|
+
**Cadence (prompt-cache economics):** a superseded read is stubbed only when the pruner is already rewriting at or before its position - `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` (`floor = 0`, activate all). Activation is session-sticky (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. Spec: `doc/specs/2026-09-07-protected-path-supersede.md`.
|
|
648
|
+
|
|
627
649
|
Names and patterns that don't match any captured tool call are silently ignored.
|
|
628
650
|
|
|
629
651
|
### Eager single-result spill
|
|
@@ -691,7 +713,7 @@ The last attempted prune boundary is persisted as `context-prune-frontier` so `f
|
|
|
691
713
|
- **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.
|
|
692
714
|
- **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.
|
|
693
715
|
- **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).
|
|
694
|
-
- **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages) = JSON.stringify(messages).length`, comparing the input array before pruning to the result after. Estimated tokens = chars / 4. The measurement covers all
|
|
716
|
+
- **Live reclaim ratio:** measured once per `pruneMessages` call via `sizeMessages(messages) = JSON.stringify(messages).length`, comparing 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, error-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).
|
|
695
717
|
- **Live progress for `/pruner now`:** an `aboveEditor` widget shows one row per pending batch with braille spinner, streamed summary-char count, and ✓ / ⚠ status.
|
|
696
718
|
|
|
697
719
|
### Summarizer outage fallback
|
|
@@ -884,6 +906,7 @@ A **closed chain** is a span of messages from one user message - or a non-pruner
|
|
|
884
906
|
raw messages from session
|
|
885
907
|
│
|
|
886
908
|
├─ [1] tool-result stub-replace (per-batch; existing)
|
|
909
|
+
├─ [1b] supersede (older protected reads of a re-read path -> stub; see § Supersession)
|
|
887
910
|
├─ [2] error-purge (phase 2)
|
|
888
911
|
├─ [3] chain-range-prune (runs AFTER stubs)
|
|
889
912
|
│ resolve each entry to a positional index range
|
|
@@ -966,6 +989,8 @@ Chain compression does not delete data from the session JSONL. The original tool
|
|
|
966
989
|
|
|
967
990
|
The protected output is relocated (moved), not copied — the original `ToolResultMessage` is dropped with the rest of the middle turns. The text stays in LLM context because it is embedded in the surviving synthetic block. It is NOT registered in the tool-call index and is NOT recoverable via `context_tree_query`; it does not need to be, because it is present verbatim.
|
|
968
991
|
|
|
992
|
+
Relocation reads the array **after** phase 1b, so a protected read that has been superseded relocates as its one-line stub, not the verbatim body - the verbatim copy is the newer read elsewhere in context.
|
|
993
|
+
|
|
969
994
|
The `context-prune-chain` session entry carries the matching `protectedToolCallIds` array so `session_start` reconstruction can re-embed the outputs on reload.
|
|
970
995
|
|
|
971
996
|
**Rejected alternative:** skip compression for any chain that contains a protected tool. Rejected because `todowrite`/`todoread` recur in most chains for opted-in users, so this strategy would forfeit most chain compression for the people who most need `protectedTools`.
|
|
@@ -1089,7 +1114,7 @@ Error purge replaces those arg bodies with compact stubs after the error has coo
|
|
|
1089
1114
|
**Transform position:** Error purge runs in Phase 2, after stub-replace and before chain range prune.
|
|
1090
1115
|
|
|
1091
1116
|
```
|
|
1092
|
-
[stub-replace] → [error-purge] → [chain-range-prune] → [orphan-sweep]
|
|
1117
|
+
[stub-replace] → [supersede] → [error-purge] → [chain-range-prune] → [orphan-sweep]
|
|
1093
1118
|
```
|
|
1094
1119
|
|
|
1095
1120
|
**Config keys:**
|
package/README.md
CHANGED
|
@@ -160,7 +160,7 @@ By default the extension is **off**. `/pruner on` enables it and it stays enable
|
|
|
160
160
|
|
|
161
161
|
## Configuration - the knobs most people touch
|
|
162
162
|
|
|
163
|
-
Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_AGENT_DIR` if set, else `~/.pi/agent`). Each pi preset gets its own settings.
|
|
163
|
+
Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_AGENT_DIR` if set, else `~/.pi/agent`). Each pi preset gets its own settings. A `settings.json` that cannot be read as a JSON object is never overwritten by a `/pruner` change: the change applies to the current session and an error notification names the file.
|
|
164
164
|
|
|
165
165
|
| Key | Default | Notes |
|
|
166
166
|
|---|---|---|
|
|
@@ -169,7 +169,7 @@ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_A
|
|
|
169
169
|
| `pruneOn` | `agent-message` | Trigger mode - see Architecture above |
|
|
170
170
|
| `autoBudgetThreshold` | `null` | Fraction (e.g. `0.8`) of the context window that force-flushes everything regardless of `pruneOn`; the trigger point is capped at 300k tokens |
|
|
171
171
|
| `frontierGapThresholdTokens` | `null` | Opt-in absolute-token flush trigger: fires at `turn_end` once the un-pruned tail past the prune frontier reaches N tokens, regardless of window size; recommended starting value `80000` |
|
|
172
|
-
| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md", "**/gauntlet-overrides.md"]` | Tool names / path globs that are never
|
|
172
|
+
| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md", "**/gauntlet-overrides.md"]` | Tool names / path globs that are never summarized; only the newest read per protected path stays verbatim (older reads of the same path are stubbed once the prompt cache is cold anyway) |
|
|
173
173
|
| `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
|
|
174
174
|
|
|
175
175
|
The default also protects reads of [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet)'s per-repo `gauntlet-overrides.md` so the repo's harness contract stays available for gate decisions after pruning.
|
package/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { StatsAccumulator, emitExternalCost } from "./src/stats.js";
|
|
|
45
45
|
import { PruneFrontierTracker } from "./src/frontier.js";
|
|
46
46
|
import { BlockRefIssuer } from "./src/block-refs.js";
|
|
47
47
|
import { compressEligible } from "./src/chain-compressor.js";
|
|
48
|
+
import { createSupersedeState, earliestChainStart, earliestResultTimestamp, lowerFloor } from "./src/supersede.js";
|
|
48
49
|
import { detectChains, withClosingMessage } from "./src/chain-detector.js";
|
|
49
50
|
import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js";
|
|
50
51
|
import { shouldBudgetFlush, shouldDeltaFlush, shouldFrontierGapFlush, usageFraction } from "./src/budget.js";
|
|
@@ -82,6 +83,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
82
83
|
// (dedup'd across the session's lifetime, not per-render).
|
|
83
84
|
const diagnostics = new DiagnosticSink((type, data) => pi.appendEntry(type, data));
|
|
84
85
|
|
|
86
|
+
// Newest-protected-read-wins state (spec 2026-09-07). In-memory only: on
|
|
87
|
+
// session_start / session_tree the cold floor re-activates everything.
|
|
88
|
+
const supersede = createSupersedeState();
|
|
89
|
+
|
|
85
90
|
// Pending batches — accumulated until the prune trigger fires
|
|
86
91
|
const pendingBatches: CapturedBatch[] = [];
|
|
87
92
|
let isFlushing = false;
|
|
@@ -454,6 +459,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
454
459
|
const dedupedBatches: CapturedBatch[] = [];
|
|
455
460
|
let firstFailureIndex = -1;
|
|
456
461
|
|
|
462
|
+
// Every tool call phase 1 will stub on the next render is a floor
|
|
463
|
+
// source for supersession: dedup aliases regardless of batch outcome,
|
|
464
|
+
// plus the batch's own calls when the batch was actually indexed.
|
|
465
|
+
const floorSources: import("./src/types.js").CapturedToolCall[] = [];
|
|
466
|
+
for (let i = 0; i < batches.length; i++) floorSources.push(...dedupedPerBatch[i].toolCalls);
|
|
467
|
+
|
|
457
468
|
for (let i = 0; i < batches.length; i++) {
|
|
458
469
|
const result = results[i];
|
|
459
470
|
if (result === null) {
|
|
@@ -530,6 +541,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
530
541
|
// Keep the in-memory summary-body registry current so chain compression
|
|
531
542
|
// can build synthetic chain messages without rescanning session entries.
|
|
532
543
|
indexer.registerSummaryBody(batchOccurrenceKeys, summaryText);
|
|
544
|
+
floorSources.push(...batch.toolCalls);
|
|
533
545
|
} else {
|
|
534
546
|
oversizedBatches.push(batch);
|
|
535
547
|
}
|
|
@@ -546,6 +558,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
546
558
|
processedBatches.push(batch);
|
|
547
559
|
}
|
|
548
560
|
|
|
561
|
+
lowerFloor(supersede, earliestResultTimestamp(floorSources));
|
|
562
|
+
|
|
549
563
|
// Restore unprocessed batches (those at and after the first failure)
|
|
550
564
|
if (firstFailureIndex >= 0) {
|
|
551
565
|
restoreBatches(batches.slice(firstFailureIndex));
|
|
@@ -664,6 +678,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
664
678
|
inGrace,
|
|
665
679
|
);
|
|
666
680
|
if (compressedEntries.length > 0) {
|
|
681
|
+
lowerFloor(supersede, earliestChainStart(compressedEntries));
|
|
667
682
|
statsAccum.addChainsCompressed(compressedEntries.length);
|
|
668
683
|
statsAccum.persist(pi);
|
|
669
684
|
emitExternalCost(pi, statsAccum);
|
|
@@ -785,6 +800,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
785
800
|
statsAccum.reconstructFromSession(ctx);
|
|
786
801
|
fallbackController.reset();
|
|
787
802
|
diagnostics.reset();
|
|
803
|
+
supersede.activated.clear();
|
|
804
|
+
supersede.floor = 0;
|
|
788
805
|
|
|
789
806
|
// Rebuild prune frontier from persisted session entries
|
|
790
807
|
frontier.reconstructFromSession(ctx);
|
|
@@ -826,6 +843,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
826
843
|
blockRefs.rebuildFrom(indexer.getChainEntries().map((e) => e.blockId));
|
|
827
844
|
statsAccum.reconstructFromSession(ctx);
|
|
828
845
|
diagnostics.reset();
|
|
846
|
+
supersede.activated.clear();
|
|
847
|
+
supersede.floor = 0;
|
|
829
848
|
frontier.reconstructFromSession(ctx);
|
|
830
849
|
// Pending batches belong to the old branch — discard them
|
|
831
850
|
pendingBatches.length = 0;
|
|
@@ -842,6 +861,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
842
861
|
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
|
|
843
862
|
});
|
|
844
863
|
|
|
864
|
+
// Cache is a per-model prefix; these three moments are cold regardless, so
|
|
865
|
+
// activating every pending supersession here costs no extra cache miss.
|
|
866
|
+
pi.on("model_select", async () => {
|
|
867
|
+
supersede.floor = 0;
|
|
868
|
+
});
|
|
869
|
+
pi.on("session_compact", async () => {
|
|
870
|
+
supersede.floor = 0;
|
|
871
|
+
});
|
|
872
|
+
pi.on("thinking_level_select", async () => {
|
|
873
|
+
supersede.floor = 0;
|
|
874
|
+
});
|
|
875
|
+
|
|
845
876
|
// ── turn_end: capture batch, flush immediately or queue ──────────────────
|
|
846
877
|
pi.on("turn_end", async (event, ctx) => {
|
|
847
878
|
if (!currentConfig.value.enabled) return;
|
|
@@ -994,7 +1025,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
994
1025
|
|
|
995
1026
|
// pruneMessages is the single source of truth for "is there work to do".
|
|
996
1027
|
// It returns the original array reference (pruned: false) only when none of
|
|
997
|
-
// the
|
|
1028
|
+
// the five phases changed anything; index/registry emptiness alone does not
|
|
998
1029
|
// imply a no-op, since error-purge (phase 2) prunes independently of them.
|
|
999
1030
|
// Calling it unconditionally is safe and avoids a split gate here.
|
|
1000
1031
|
const result = pruneMessages(
|
|
@@ -1005,6 +1036,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1005
1036
|
currentConfig.value,
|
|
1006
1037
|
currentConfig.value.recoveryGraceTurns,
|
|
1007
1038
|
diagnostics,
|
|
1039
|
+
{ state: supersede, isProtected: protectionPredicate },
|
|
1008
1040
|
);
|
|
1009
1041
|
if (result.pruned) {
|
|
1010
1042
|
messages = result.messages;
|
|
@@ -1046,6 +1078,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1046
1078
|
inGrace,
|
|
1047
1079
|
);
|
|
1048
1080
|
if (result.compressedEntries.length > 0) {
|
|
1081
|
+
lowerFloor(supersede, earliestChainStart(result.compressedEntries));
|
|
1049
1082
|
statsAccum.addChainsCompressed(result.compressedEntries.length);
|
|
1050
1083
|
statsAccum.persist(pi);
|
|
1051
1084
|
emitExternalCost(pi, statsAccum);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.4",
|
|
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
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Command handlers persist through src/config.ts, which resolves the settings
|
|
7
|
+
// path lazily from PI_CODING_AGENT_DIR; point it at a scratch dir so no test
|
|
8
|
+
// ever touches the developer's real settings.json.
|
|
9
|
+
process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-condense-commands-test-"));
|
|
10
|
+
|
|
2
11
|
import { pruneStatusText, setPruneStatusWidget, registerCommands } from "./commands.js";
|
|
12
|
+
import { settingsPath } from "./config.js";
|
|
3
13
|
import type { ContextPruneConfig, ContextMetricsSnapshot, SummarizerStats } from "./types.js";
|
|
4
14
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
5
15
|
|
|
@@ -26,6 +36,7 @@ function setupPrunerCommand(overrides: {
|
|
|
26
36
|
flushPending?: (ctx: any, options?: any) => Promise<any>;
|
|
27
37
|
getRearmed?: () => boolean;
|
|
28
38
|
getContextMetrics?: (ctx: any) => ContextMetricsSnapshot;
|
|
39
|
+
save?: (config: ContextPruneConfig) => Promise<void>;
|
|
29
40
|
} = {}) {
|
|
30
41
|
let handler: (args: string, ctx: any) => Promise<void>;
|
|
31
42
|
const notifications: { message: string; type?: string }[] = [];
|
|
@@ -59,6 +70,7 @@ function setupPrunerCommand(overrides: {
|
|
|
59
70
|
undefined,
|
|
60
71
|
overrides.getContextMetrics,
|
|
61
72
|
overrides.getRearmed,
|
|
73
|
+
overrides.save,
|
|
62
74
|
);
|
|
63
75
|
|
|
64
76
|
const ctx: any = {
|
|
@@ -66,6 +78,7 @@ function setupPrunerCommand(overrides: {
|
|
|
66
78
|
notify(message: string, type?: string) {
|
|
67
79
|
notifications.push({ message, type });
|
|
68
80
|
},
|
|
81
|
+
setStatus() {},
|
|
69
82
|
},
|
|
70
83
|
};
|
|
71
84
|
|
|
@@ -73,6 +86,7 @@ function setupPrunerCommand(overrides: {
|
|
|
73
86
|
run: (args: string) => handler(args, ctx),
|
|
74
87
|
notifications,
|
|
75
88
|
flushCalls,
|
|
89
|
+
currentConfig,
|
|
76
90
|
};
|
|
77
91
|
}
|
|
78
92
|
|
|
@@ -176,6 +190,41 @@ describe("setPruneStatusWidget", () => {
|
|
|
176
190
|
});
|
|
177
191
|
});
|
|
178
192
|
|
|
193
|
+
describe("/pruner off with a rejecting save (#15)", () => {
|
|
194
|
+
it("keeps the in-memory change, notifies an error naming the settings path, and raises no unhandledRejection", async () => {
|
|
195
|
+
const unhandled: unknown[] = [];
|
|
196
|
+
const recorder = (reason: unknown) => { unhandled.push(reason); };
|
|
197
|
+
process.on("unhandledRejection", recorder);
|
|
198
|
+
try {
|
|
199
|
+
const { run, notifications, currentConfig } = setupPrunerCommand({
|
|
200
|
+
save: () => Promise.reject(Object.assign(new Error("EACCES"), { code: "EACCES" })),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await run("off");
|
|
204
|
+
|
|
205
|
+
// The error toast lands after the handler returns; wait for it with a
|
|
206
|
+
// bounded poll rather than a microtask hop.
|
|
207
|
+
const deadline = Date.now() + 2000;
|
|
208
|
+
while (!notifications.some((n) => n.type === "error") && Date.now() < deadline) {
|
|
209
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
210
|
+
}
|
|
211
|
+
await new Promise((r) => setImmediate(r));
|
|
212
|
+
|
|
213
|
+
const errorIdx = notifications.findIndex((n) => n.type === "error");
|
|
214
|
+
const successIdx = notifications.findIndex((n) => n.message === "Context pruning disabled.");
|
|
215
|
+
expect(errorIdx).toBeGreaterThan(-1);
|
|
216
|
+
expect(successIdx).toBeGreaterThan(-1);
|
|
217
|
+
expect(successIdx).toBeLessThan(errorIdx);
|
|
218
|
+
expect(notifications[errorIdx].message).toContain(settingsPath());
|
|
219
|
+
expect(notifications[errorIdx].message).toContain("EACCES");
|
|
220
|
+
expect(currentConfig.value.enabled).toBe(false);
|
|
221
|
+
expect(unhandled).toEqual([]);
|
|
222
|
+
} finally {
|
|
223
|
+
process.off("unhandledRejection", recorder);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
179
228
|
describe("diagnostic counters on the status line", () => {
|
|
180
229
|
const zeroDiag = { "unresolved-range": 0, "range-id-mismatch": 0, "orphan-sweep": 0 } as const;
|
|
181
230
|
const mixedDiag = { "unresolved-range": 2, "range-id-mismatch": 0, "orphan-sweep": 1 } as const;
|
package/src/commands.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
DEFAULT_CONFIG,
|
|
24
24
|
} from "./types.js";
|
|
25
25
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import { saveConfig } from "./config.js";
|
|
26
|
+
import { saveConfig, persistConfig } from "./config.js";
|
|
27
27
|
import { MAX_BUDGET_WINDOW } from "./budget.js";
|
|
28
28
|
import { formatTokens, formatCost, formatCharProgress, formatCompactCount } from "./stats.js";
|
|
29
29
|
import { Container, Text, SettingsList, type SettingItem } from "@earendil-works/pi-tui";
|
|
@@ -477,6 +477,7 @@ export function registerCommands(
|
|
|
477
477
|
getDiagnosticCounts?: () => Record<DiagnosticKind, number>,
|
|
478
478
|
getContextMetrics?: (ctx: ExtensionCommandContext) => ContextMetricsSnapshot,
|
|
479
479
|
getRearmed?: () => boolean,
|
|
480
|
+
save: (config: ContextPruneConfig) => Promise<void> = saveConfig,
|
|
480
481
|
): void {
|
|
481
482
|
// Register the /pruner command
|
|
482
483
|
pi.registerCommand("pruner", {
|
|
@@ -822,7 +823,7 @@ export function registerCommands(
|
|
|
822
823
|
};
|
|
823
824
|
}
|
|
824
825
|
currentConfig.value = newConfig;
|
|
825
|
-
|
|
826
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), newConfig, save);
|
|
826
827
|
setPruneStatusWidget(ctx, newConfig, getLiveReclaim(), getDiagnosticCounts?.());
|
|
827
828
|
settingsList?.invalidate();
|
|
828
829
|
};
|
|
@@ -856,7 +857,7 @@ export function registerCommands(
|
|
|
856
857
|
// ── /pruner on ──
|
|
857
858
|
case "on": {
|
|
858
859
|
currentConfig.value = { ...currentConfig.value, enabled: true };
|
|
859
|
-
|
|
860
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
860
861
|
ctx.ui.notify("Context pruning enabled.");
|
|
861
862
|
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
862
863
|
break;
|
|
@@ -865,7 +866,7 @@ export function registerCommands(
|
|
|
865
866
|
// ── /pruner off ──
|
|
866
867
|
case "off": {
|
|
867
868
|
currentConfig.value = { ...currentConfig.value, enabled: false };
|
|
868
|
-
|
|
869
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
869
870
|
ctx.ui.notify("Context pruning disabled.");
|
|
870
871
|
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
871
872
|
break;
|
|
@@ -943,7 +944,7 @@ export function registerCommands(
|
|
|
943
944
|
summarizerModel: parsed.model,
|
|
944
945
|
summarizerThinking: parsed.thinking ?? currentConfig.value.summarizerThinking,
|
|
945
946
|
};
|
|
946
|
-
|
|
947
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
947
948
|
const thinkingText = parsed.thinking ? ` with thinking ${parsed.thinking}` : "";
|
|
948
949
|
ctx.ui.notify(`Summarizer model set to: ${parsed.model}${thinkingText}`);
|
|
949
950
|
}
|
|
@@ -971,7 +972,7 @@ export function registerCommands(
|
|
|
971
972
|
);
|
|
972
973
|
return;
|
|
973
974
|
}
|
|
974
|
-
|
|
975
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
975
976
|
ctx.ui.notify(`Summarizer thinking set to: ${currentConfig.value.summarizerThinking}`);
|
|
976
977
|
break;
|
|
977
978
|
}
|
|
@@ -989,7 +990,7 @@ export function registerCommands(
|
|
|
989
990
|
} else {
|
|
990
991
|
currentConfig.value = { ...currentConfig.value, pruneOn: modeArg as ContextPruneConfig["pruneOn"] };
|
|
991
992
|
}
|
|
992
|
-
|
|
993
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
993
994
|
setPruneStatusWidget(ctx, currentConfig.value, getLiveReclaim(), getDiagnosticCounts?.());
|
|
994
995
|
break;
|
|
995
996
|
}
|
|
@@ -1013,7 +1014,7 @@ export function registerCommands(
|
|
|
1013
1014
|
}
|
|
1014
1015
|
currentConfig.value = { ...currentConfig.value, batchingMode: batchArg as ContextPruneConfig["batchingMode"] };
|
|
1015
1016
|
}
|
|
1016
|
-
|
|
1017
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1017
1018
|
ctx.ui.notify(`Batching mode set to: ${batchingModeLabel(currentConfig.value.batchingMode)}`);
|
|
1018
1019
|
break;
|
|
1019
1020
|
}
|
|
@@ -1164,7 +1165,7 @@ export function registerCommands(
|
|
|
1164
1165
|
}
|
|
1165
1166
|
|
|
1166
1167
|
currentConfig.value = { ...currentConfig.value, protectedTools: nextList };
|
|
1167
|
-
|
|
1168
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1168
1169
|
ctx.ui.notify(`Protected tools: ${protectedToolsDisplay(nextList)}`);
|
|
1169
1170
|
break;
|
|
1170
1171
|
}
|
|
@@ -1197,7 +1198,7 @@ export function registerCommands(
|
|
|
1197
1198
|
}
|
|
1198
1199
|
|
|
1199
1200
|
currentConfig.value = { ...currentConfig.value, protectedPaths: nextList };
|
|
1200
|
-
|
|
1201
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1201
1202
|
ctx.ui.notify(`Protected paths: ${protectedToolsDisplay(nextList)}`);
|
|
1202
1203
|
break;
|
|
1203
1204
|
}
|
|
@@ -1221,7 +1222,7 @@ export function registerCommands(
|
|
|
1221
1222
|
break;
|
|
1222
1223
|
}
|
|
1223
1224
|
currentConfig.value = { ...currentConfig.value, minBatchChars: parsed };
|
|
1224
|
-
|
|
1225
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1225
1226
|
ctx.ui.notify(
|
|
1226
1227
|
parsed === 0
|
|
1227
1228
|
? "minBatchChars set to 0 — pre-flush trivial-batch skipping disabled."
|
|
@@ -1244,7 +1245,7 @@ export function registerCommands(
|
|
|
1244
1245
|
break;
|
|
1245
1246
|
}
|
|
1246
1247
|
currentConfig.value = { ...currentConfig.value, recoveryGraceTurns: parsed };
|
|
1247
|
-
|
|
1248
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1248
1249
|
ctx.ui.notify(
|
|
1249
1250
|
parsed === 0
|
|
1250
1251
|
? "recovery-grace set to 0 - context_tree_query output stubs immediately."
|
|
@@ -1269,7 +1270,7 @@ export function registerCommands(
|
|
|
1269
1270
|
}
|
|
1270
1271
|
const next = arg === "on" || arg === "true";
|
|
1271
1272
|
currentConfig.value = { ...currentConfig.value, dedupByContentHash: next };
|
|
1272
|
-
|
|
1273
|
+
void persistConfig((m, t) => ctx.ui.notify(m, t), currentConfig.value, save);
|
|
1273
1274
|
ctx.ui.notify(`Content-hash dedup turned ${next ? "ON" : "OFF"}.`);
|
|
1274
1275
|
break;
|
|
1275
1276
|
}
|
package/src/config.test.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { describe, expect, it, beforeAll, afterAll } from "bun:test";
|
|
1
|
+
import { describe, expect, it, beforeAll, afterAll, afterEach } from "bun:test";
|
|
2
2
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
7
|
+
import type { ContextPruneConfig } from "./types.js";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* config.ts resolves the settings path from getAgentDir() lazily on each
|
|
@@ -15,6 +17,8 @@ import { DEFAULT_CONFIG } from "./types.js";
|
|
|
15
17
|
let tmpDir: string;
|
|
16
18
|
let loadConfig: typeof import("./config.js").loadConfig;
|
|
17
19
|
let saveConfig: typeof import("./config.js").saveConfig;
|
|
20
|
+
let persistConfig: typeof import("./config.js").persistConfig;
|
|
21
|
+
let SettingsReadError: typeof import("./config.js").SettingsReadError;
|
|
18
22
|
let settingsPath: typeof import("./config.js").settingsPath;
|
|
19
23
|
|
|
20
24
|
beforeAll(async () => {
|
|
@@ -23,9 +27,17 @@ beforeAll(async () => {
|
|
|
23
27
|
const mod = await import("./config.js");
|
|
24
28
|
loadConfig = mod.loadConfig;
|
|
25
29
|
saveConfig = mod.saveConfig;
|
|
30
|
+
persistConfig = mod.persistConfig;
|
|
31
|
+
SettingsReadError = mod.SettingsReadError;
|
|
26
32
|
settingsPath = mod.settingsPath;
|
|
27
33
|
});
|
|
28
34
|
|
|
35
|
+
// Every case below may leave a malformed settings.json behind; remove it so
|
|
36
|
+
// the shared path is clean for the next case.
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await rm(settingsPath(), { force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
29
41
|
afterAll(async () => {
|
|
30
42
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
31
43
|
await rm(tmpDir, { recursive: true, force: true });
|
|
@@ -163,3 +175,89 @@ describe("loadConfig frontierGapThresholdTokens normalization", () => {
|
|
|
163
175
|
}
|
|
164
176
|
});
|
|
165
177
|
});
|
|
178
|
+
|
|
179
|
+
describe("saveConfig fails closed (#15)", () => {
|
|
180
|
+
const config: ContextPruneConfig = { ...DEFAULT_CONFIG, enabled: false };
|
|
181
|
+
|
|
182
|
+
it("creates settings.json containing only contextPrune when the file is absent", async () => {
|
|
183
|
+
await rm(settingsPath(), { force: true });
|
|
184
|
+
await saveConfig(config);
|
|
185
|
+
const written = JSON.parse(await readFile(settingsPath(), "utf-8"));
|
|
186
|
+
expect(Object.keys(written)).toEqual(["contextPrune"]);
|
|
187
|
+
expect(written.contextPrune.enabled).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("preserves other top-level keys and replaces contextPrune", async () => {
|
|
191
|
+
await writeFile(settingsPath(), '{"foo":1,"contextPrune":{"enabled":true}}');
|
|
192
|
+
await saveConfig(config);
|
|
193
|
+
const written = JSON.parse(await readFile(settingsPath(), "utf-8"));
|
|
194
|
+
expect(written.foo).toBe(1);
|
|
195
|
+
expect(written.contextPrune.enabled).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
for (const [label, raw] of [
|
|
199
|
+
["0-byte file", ""],
|
|
200
|
+
["truncated JSON", '{"foo":'],
|
|
201
|
+
["array", "[]"],
|
|
202
|
+
["null", "null"],
|
|
203
|
+
["string", '"str"'],
|
|
204
|
+
["number", "42"],
|
|
205
|
+
] as const) {
|
|
206
|
+
it(`rejects and leaves the file byte-identical for ${label}; loadConfig returns defaults`, async () => {
|
|
207
|
+
await writeFile(settingsPath(), raw);
|
|
208
|
+
const before = readFileSync(settingsPath());
|
|
209
|
+
await expect(saveConfig(config)).rejects.toBeInstanceOf(SettingsReadError);
|
|
210
|
+
expect(readFileSync(settingsPath()).equals(before)).toBe(true);
|
|
211
|
+
expect(await loadConfig()).toEqual({ ...DEFAULT_CONFIG });
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
it("rejects with reason EACCES when the injected read fails, leaving the file byte-identical", async () => {
|
|
216
|
+
await writeFile(settingsPath(), '{"foo":1}');
|
|
217
|
+
const before = readFileSync(settingsPath());
|
|
218
|
+
const read = (async () => {
|
|
219
|
+
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
|
|
220
|
+
}) as unknown as typeof import("node:fs/promises").readFile;
|
|
221
|
+
const err = await saveConfig(config, read).catch((e) => e);
|
|
222
|
+
expect(err).toBeInstanceOf(SettingsReadError);
|
|
223
|
+
expect(err.reason).toBe("EACCES");
|
|
224
|
+
expect(err.path).toBe(settingsPath());
|
|
225
|
+
expect(readFileSync(settingsPath()).equals(before)).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("persistConfig (#15)", () => {
|
|
230
|
+
const config: ContextPruneConfig = { ...DEFAULT_CONFIG, enabled: false };
|
|
231
|
+
|
|
232
|
+
it("notifies once with type error and the settings path when the file is truncated", async () => {
|
|
233
|
+
await writeFile(settingsPath(), '{"foo":');
|
|
234
|
+
const calls: { message: string; type?: string }[] = [];
|
|
235
|
+
await persistConfig((message, type) => calls.push({ message, type }), config);
|
|
236
|
+
expect(calls).toHaveLength(1);
|
|
237
|
+
expect(calls[0].type).toBe("error");
|
|
238
|
+
expect(calls[0].message).toContain(settingsPath());
|
|
239
|
+
expect(calls[0].message).toContain("invalid JSON");
|
|
240
|
+
expect(calls[0].message).toContain("Change applies to this session only.");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("does not notify when the file is missing", async () => {
|
|
244
|
+
await rm(settingsPath(), { force: true });
|
|
245
|
+
const calls: unknown[] = [];
|
|
246
|
+
await persistConfig((message, type) => calls.push({ message, type }), config);
|
|
247
|
+
expect(calls).toHaveLength(0);
|
|
248
|
+
expect(JSON.parse(await readFile(settingsPath(), "utf-8")).contextPrune.enabled).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("handles a save that rejects with undefined", async () => {
|
|
252
|
+
const calls: { message: string; type?: string }[] = [];
|
|
253
|
+
await persistConfig(
|
|
254
|
+
(message, type) => calls.push({ message, type }),
|
|
255
|
+
config,
|
|
256
|
+
() => Promise.reject(undefined),
|
|
257
|
+
);
|
|
258
|
+
expect(calls).toHaveLength(1);
|
|
259
|
+
expect(calls[0].type).toBe("error");
|
|
260
|
+
expect(calls[0].message).toContain(settingsPath());
|
|
261
|
+
expect(calls[0].message).toContain("undefined");
|
|
262
|
+
});
|
|
263
|
+
});
|