dsh-context-compression-improved 0.3.0 → 0.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.githooks/pre-push +37 -0
  2. package/package.json +3 -2
  3. package/packages/selector/cordis.patch.yml +12 -5
  4. package/packages/selector/lib/client.d.ts +24 -0
  5. package/packages/selector/lib/client.js +506 -5
  6. package/packages/selector/lib/config.js +27 -4
  7. package/packages/selector/lib/index.d.ts +7 -0
  8. package/packages/selector/lib/index.js +229 -1
  9. package/packages/selector/lib/pruner.d.ts +254 -0
  10. package/packages/selector/lib/pruner.js +714 -25
  11. package/packages/selector/package.json +0 -1
  12. package/packages/selector/src/client/EstimatorControls.tsx +101 -0
  13. package/packages/selector/src/client/ReviewOverlay.tsx +320 -0
  14. package/packages/selector/src/client/index.ts +17 -0
  15. package/packages/selector/src/client/locales.ts +38 -0
  16. package/packages/selector/src/client/preset-options.ts +1 -0
  17. package/packages/selector/src/client/review-scope.ts +16 -0
  18. package/packages/selector/src/client/settings-section.tsx +17 -8
  19. package/packages/selector/src/index.ts +308 -0
  20. package/packages/selector/src/profiles.ts +28 -1
  21. package/packages/selector/src/pruner/state.ts +27 -0
  22. package/packages/selector/src/pruner.ts +430 -10
  23. package/packages/selector/src/runtime/audit.ts +27 -0
  24. package/packages/selector/src/runtime/config.ts +33 -1
  25. package/packages/selector/src/runtime/tokenpilot/estimator.ts +60 -13
  26. package/packages/selector/src/runtime/tokenpilot/proposal.ts +223 -0
  27. package/packages/selector/src/runtime/tokenpilot/review-queue.ts +231 -0
  28. package/packages/selector/src/runtime/tokenpilot/review-storage.ts +122 -0
  29. package/packages/selector/src/runtime/types.ts +17 -0
  30. package/packages/selector/tests/code-skeleton.client.spec.ts +3 -2
  31. package/packages/selector/tests/custom-contract.client.spec.ts +3 -2
  32. package/packages/selector/tests/preset-options-write.client.spec.ts +34 -1
  33. package/packages/selector/tests/review-overlay.client.spec.tsx +118 -0
  34. package/packages/selector/tests/review-routes.host.spec.ts +290 -0
  35. package/packages/selector/tests/runtime/audit.spec.ts +44 -0
  36. package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +23 -0
  37. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +5 -0
  38. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +199 -0
  39. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +313 -0
  40. package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +168 -0
  41. package/packages/selector/tests/settings-seat.client.spec.ts +5 -4
@@ -1255,33 +1255,59 @@ function buildEstimatorSystemPrompt() {
1255
1255
  "For each numbered historical file read, decide whether the live agent is likely to",
1256
1256
  "reference that exact file state again later in the session. Reads whose file was",
1257
1257
  "already rewritten, or whose task has visibly moved on, are expired.",
1258
- "Answer with ONLY a JSON array: [{\"seq\":<number>,\"expired\":<boolean>}]."
1258
+ "Answer with ONLY a JSON array: [{\"seq\":<number>,\"expired\":<boolean>}].",
1259
+ "Optionally, if you can estimate how many user turns remain in this session, answer",
1260
+ "with {\"expectedRemainingTurns\":<number>,\"items\":[{\"seq\":<number>,\"expired\":<boolean>}]}",
1261
+ "instead; omit the field when you cannot estimate it."
1259
1262
  ].join(" ");
1260
1263
  }
1261
1264
  function buildEstimatorUserPrompt(samples) {
1262
1265
  return samples.map((sample) => `{"seq":${String(sample.seq)},"path":${JSON.stringify(sample.path)},"turn":${String(sample.turn)}}`).join("\n");
1263
1266
  }
1264
- /** Parse the estimator answer; anything malformed yields no verdicts. */
1265
- function parseEstimatorAnswer(text) {
1267
+ function parseVerdictArray(value) {
1268
+ if (!Array.isArray(value)) return [];
1269
+ const verdicts = [];
1270
+ for (const entry of value) {
1271
+ if (typeof entry !== "object" || entry === null) continue;
1272
+ const record = entry;
1273
+ if (typeof record.seq !== "number" || typeof record.expired !== "boolean") continue;
1274
+ verdicts.push({
1275
+ seq: record.seq,
1276
+ expired: record.expired
1277
+ });
1278
+ }
1279
+ return verdicts;
1280
+ }
1281
+ /**
1282
+ * Parse the estimator answer including the optional session-level
1283
+ * `expectedRemainingTurns`. Accepts both the legacy bare verdict array and the
1284
+ * extended object form; anything malformed yields no verdicts and no Ŝ.
1285
+ */
1286
+ function parseEstimatorAnswerDetailed(text) {
1287
+ const objectStart = text.indexOf("{");
1288
+ const objectEnd = text.lastIndexOf("}");
1289
+ if (objectStart >= 0 && objectEnd > objectStart) try {
1290
+ const parsed = JSON.parse(text.slice(objectStart, objectEnd + 1));
1291
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
1292
+ const record = parsed;
1293
+ const verdicts = parseVerdictArray(record.verdicts ?? record.items);
1294
+ if (verdicts.length > 0) {
1295
+ const turns = record.expectedRemainingTurns;
1296
+ if (typeof turns === "number" && Number.isFinite(turns) && turns >= 0) return {
1297
+ verdicts,
1298
+ expectedRemainingTurns: Math.floor(turns)
1299
+ };
1300
+ return { verdicts };
1301
+ }
1302
+ }
1303
+ } catch {}
1266
1304
  const start = text.indexOf("[");
1267
1305
  const end = text.lastIndexOf("]");
1268
- if (start < 0 || end <= start) return [];
1306
+ if (start < 0 || end <= start) return { verdicts: [] };
1269
1307
  try {
1270
- const parsed = JSON.parse(text.slice(start, end + 1));
1271
- if (!Array.isArray(parsed)) return [];
1272
- const verdicts = [];
1273
- for (const entry of parsed) {
1274
- if (typeof entry !== "object" || entry === null) continue;
1275
- const record = entry;
1276
- if (typeof record.seq !== "number" || typeof record.expired !== "boolean") continue;
1277
- verdicts.push({
1278
- seq: record.seq,
1279
- expired: record.expired
1280
- });
1281
- }
1282
- return verdicts;
1308
+ return { verdicts: parseVerdictArray(JSON.parse(text.slice(start, end + 1))) };
1283
1309
  } catch {
1284
- return [];
1310
+ return { verdicts: [] };
1285
1311
  }
1286
1312
  }
1287
1313
  /** One channel-bound estimator. `ask` resolves undefined on any failure. */
@@ -1453,6 +1479,361 @@ function dedupePlaceholder(entry, originalChars) {
1453
1479
  ].join(" ");
1454
1480
  }
1455
1481
  //#endregion
1482
+ //#region src/runtime/tokenpilot/proposal.ts
1483
+ /**
1484
+ * TokenPilot-inspired R4: benefit model for the human-gated review pipeline.
1485
+ *
1486
+ * Pure functions only: the classifier needs no I/O, no session state, and no
1487
+ * host services, so every decision is unit-testable and audit-replayable.
1488
+ *
1489
+ * The cost model follows the TokenPilot paper's cache-accounting view: one
1490
+ * merged mutation pays a one-time tail KV-cache refill penalty of
1491
+ * `(1−α)·tailTokens`, and every later turn recovers the reclaimed tokens at
1492
+ * the cache-hit discount `α`:
1493
+ *
1494
+ * ```
1495
+ * R = Σ(tokensBefore − tokensAfter) // net reclaimed tokens
1496
+ * paybackTurns = (1−α)·tailTokens / (α·R) // one-time refill / per-turn saving
1497
+ * expectedSaving = α·R·max(0, Ŝ − paybackTurns) // Ŝ = estimated remaining turns
1498
+ * ```
1499
+ *
1500
+ * `expectedSaving` is only produced when Ŝ is known (the estimator answered
1501
+ * with `expectedRemainingTurns`); it is never fabricated from a guess.
1502
+ */
1503
+ /**
1504
+ * Aggregate the batch-level benefit of a set of reduction candidates.
1505
+ *
1506
+ * Individual candidates whose replacement would grow the context contribute
1507
+ * zero recovery (they never make a batch look better than dropping them).
1508
+ */
1509
+ function computeBenefit(candidates, input) {
1510
+ const { alpha, tailTokens, remainingTurns } = input;
1511
+ let recoveredTokens = 0;
1512
+ for (const candidate of candidates) recoveredTokens += Math.max(0, candidate.tokensBefore - candidate.tokensAfter);
1513
+ const penaltyTokens = (1 - alpha) * tailTokens;
1514
+ const perTurnSaving = alpha * recoveredTokens;
1515
+ if (perTurnSaving <= 0) return remainingTurns === void 0 ? {
1516
+ recoveredTokens,
1517
+ penaltyTokens
1518
+ } : {
1519
+ recoveredTokens,
1520
+ penaltyTokens,
1521
+ expectedSaving: -penaltyTokens
1522
+ };
1523
+ const paybackTurns = penaltyTokens / perTurnSaving;
1524
+ if (remainingTurns === void 0) return {
1525
+ recoveredTokens,
1526
+ penaltyTokens,
1527
+ paybackTurns
1528
+ };
1529
+ return {
1530
+ recoveredTokens,
1531
+ penaltyTokens,
1532
+ paybackTurns,
1533
+ expectedSaving: perTurnSaving * Math.max(0, remainingTurns - paybackTurns)
1534
+ };
1535
+ }
1536
+ /**
1537
+ * Stable proposal identity: the sha-256 of the serialized item digests, cut to
1538
+ * 12 hex chars. Stable across re-enqueues of the same content so a repeated
1539
+ * classification cannot duplicate a pending proposal.
1540
+ */
1541
+ function proposalId(itemDigests) {
1542
+ const hash = createHash("sha256");
1543
+ for (const digest of itemDigests) hash.update(digest);
1544
+ hash.update(String(itemDigests.length));
1545
+ return hash.digest("hex").slice(0, 12);
1546
+ }
1547
+ /**
1548
+ * Canonical content digest reused from the dedup hash: plain-text results hash
1549
+ * through the dedupe canonicalization; rich blocks fall back to canonical JSON
1550
+ * so every candidate is freezable.
1551
+ */
1552
+ function contentDigest(content) {
1553
+ return dedupeHash(flattenPlainText(content) ?? JSON.stringify(content), "trim-eol");
1554
+ }
1555
+ function proposalKindFor(candidate, estimatorSeqs) {
1556
+ if (estimatorSeqs?.has(candidate.sourceSeq) === true) return "estimator";
1557
+ if (candidate.reducer === "dedupe-pointer") return "dedup";
1558
+ return "read-state";
1559
+ }
1560
+ /**
1561
+ * Triage planned replacements into the three review-mode buckets.
1562
+ *
1563
+ * Per candidate (R is per candidate, never cross-credited):
1564
+ * - `tokensAfter ≥ tokensBefore` → drop (nothing to recover);
1565
+ * - `tokensBefore ≥ reviewHighImpactTokens` → review ("直接送审": high impact
1566
+ * always waits for a human, even when the payback band would pass it);
1567
+ * - `paybackTurns ≤ 1`, or Ŝ known and `paybackTurns ≤ 0.25·Ŝ` → auto;
1568
+ * - Ŝ known and `paybackTurns ∈ (1, 3]` → review;
1569
+ * - everything else (Ŝ unknown with a slow payback) → drop.
1570
+ */
1571
+ function classifyCandidates(candidates, input) {
1572
+ const auto = [];
1573
+ const review = [];
1574
+ const drop = [];
1575
+ for (const candidate of candidates) {
1576
+ const benefit = computeBenefit([candidate], input);
1577
+ if (benefit.recoveredTokens <= 0) {
1578
+ drop.push(candidate);
1579
+ continue;
1580
+ }
1581
+ const highImpact = candidate.tokensBefore >= input.reviewHighImpactTokens;
1582
+ const payback = benefit.paybackTurns;
1583
+ if (!highImpact && payback !== void 0) {
1584
+ if (payback <= 1 || input.remainingTurns !== void 0 && payback <= .25 * input.remainingTurns) {
1585
+ auto.push(candidate);
1586
+ continue;
1587
+ }
1588
+ if (!(input.remainingTurns !== void 0 && payback <= 3)) {
1589
+ drop.push(candidate);
1590
+ continue;
1591
+ }
1592
+ }
1593
+ const item = {
1594
+ seq: candidate.sourceSeq,
1595
+ component: candidate.component,
1596
+ kind: proposalKindFor(candidate, input.estimatorSeqs),
1597
+ tokensBefore: candidate.tokensBefore,
1598
+ tokensAfter: candidate.tokensAfter,
1599
+ digest: contentDigest(candidate.content)
1600
+ };
1601
+ review.push({
1602
+ id: proposalId([item.digest]),
1603
+ kind: item.kind,
1604
+ items: [item],
1605
+ benefit
1606
+ });
1607
+ }
1608
+ return {
1609
+ auto,
1610
+ review,
1611
+ drop
1612
+ };
1613
+ }
1614
+ //#endregion
1615
+ //#region src/runtime/tokenpilot/review-queue.ts
1616
+ /** In-memory store: the fail-open fallback when no durable seam is available. */
1617
+ var MemoryReviewStore = class {
1618
+ sessions = /* @__PURE__ */ new Map();
1619
+ load(sessionId) {
1620
+ return this.sessions.get(sessionId);
1621
+ }
1622
+ save(sessionId, record) {
1623
+ this.sessions.set(sessionId, record);
1624
+ }
1625
+ ids() {
1626
+ return [...this.sessions.keys()];
1627
+ }
1628
+ };
1629
+ var ReviewQueue = class {
1630
+ store;
1631
+ options;
1632
+ constructor(store, options) {
1633
+ this.store = store;
1634
+ this.options = options;
1635
+ }
1636
+ /**
1637
+ * Fail-open store access: a throwing seam must never break the compression
1638
+ * pipeline. Reads degrade to "no stored record"; writes degrade to losing
1639
+ * durability for that call (the store itself is expected to warn).
1640
+ */
1641
+ safeLoad(sessionId) {
1642
+ try {
1643
+ return this.store.load(sessionId);
1644
+ } catch {
1645
+ return;
1646
+ }
1647
+ }
1648
+ safeSave(sessionId, record) {
1649
+ try {
1650
+ this.store.save(sessionId, record);
1651
+ } catch {}
1652
+ }
1653
+ sessionRecord(sessionId) {
1654
+ return this.safeLoad(sessionId) ?? {
1655
+ version: 1,
1656
+ proposals: []
1657
+ };
1658
+ }
1659
+ /**
1660
+ * Queue one classified proposal. A repeated classification of the same
1661
+ * content re-does nothing but refresh the patience clock, so re-enqueue
1662
+ * cannot duplicate a live proposal.
1663
+ * @returns `false` when an identical live proposal already exists.
1664
+ */
1665
+ enqueue(sessionId, skeleton, turnIndex) {
1666
+ const record = this.sessionRecord(sessionId);
1667
+ const existing = record.proposals.find((entry) => entry.id === skeleton.id);
1668
+ if (existing !== void 0 && existing.status !== "expired") {
1669
+ existing.lastTurnIndex = turnIndex;
1670
+ this.safeSave(sessionId, record);
1671
+ return false;
1672
+ }
1673
+ const proposal = {
1674
+ id: skeleton.id,
1675
+ sessionId,
1676
+ kind: skeleton.kind,
1677
+ items: skeleton.items.map((item) => ({ ...item })),
1678
+ benefit: { ...skeleton.benefit },
1679
+ status: "pending",
1680
+ enqueuedTurn: turnIndex,
1681
+ lastTurnIndex: turnIndex
1682
+ };
1683
+ this.safeSave(sessionId, {
1684
+ version: 1,
1685
+ proposals: [...record.proposals.filter((entry) => entry.id !== skeleton.id), proposal]
1686
+ });
1687
+ return true;
1688
+ }
1689
+ /** Live pending proposals of one session, oldest enqueue first. */
1690
+ listPending(sessionId) {
1691
+ return this.sessionRecord(sessionId).proposals.filter((entry) => entry.status === "pending").sort((left, right) => left.enqueuedTurn - right.enqueuedTurn);
1692
+ }
1693
+ /** Approved proposals waiting for the next turn-boundary batch. */
1694
+ listApproved(sessionId) {
1695
+ return this.sessionRecord(sessionId).proposals.filter((entry) => entry.status === "approved").sort((left, right) => left.enqueuedTurn - right.enqueuedTurn);
1696
+ }
1697
+ /**
1698
+ * Transition one pending proposal. Idempotent: deciding an unknown id or a
1699
+ * non-pending proposal changes nothing and reports the miss.
1700
+ */
1701
+ decide(sessionId, id, decision) {
1702
+ const record = this.sessionRecord(sessionId);
1703
+ const proposal = record.proposals.find((entry) => entry.id === id);
1704
+ if (proposal === void 0) return {
1705
+ ok: false,
1706
+ reason: "unknown-proposal"
1707
+ };
1708
+ if (proposal.status !== "pending") return {
1709
+ ok: false,
1710
+ reason: "not-pending"
1711
+ };
1712
+ proposal.status = decision;
1713
+ this.safeSave(sessionId, record);
1714
+ return { ok: true };
1715
+ }
1716
+ /**
1717
+ * Expire every pending proposal whose patience has run out at this turn
1718
+ * boundary. Expired proposals are removed from the store (the summary view
1719
+ * aggregates them from the audit log instead).
1720
+ * @returns the expired proposals, for the caller's audit emission.
1721
+ */
1722
+ expireTurn(sessionId, turnIndex) {
1723
+ const record = this.sessionRecord(sessionId);
1724
+ const keep = [];
1725
+ const expired = [];
1726
+ for (const proposal of record.proposals) {
1727
+ if (proposal.status === "pending" && turnIndex - proposal.lastTurnIndex > this.options.timeoutTurns) {
1728
+ expired.push({
1729
+ ...proposal,
1730
+ status: "expired"
1731
+ });
1732
+ continue;
1733
+ }
1734
+ keep.push(proposal);
1735
+ }
1736
+ if (expired.length > 0) this.safeSave(sessionId, {
1737
+ version: 1,
1738
+ proposals: keep
1739
+ });
1740
+ return expired;
1741
+ }
1742
+ /**
1743
+ * Settle an approved proposal with its execution receipt and retire it from
1744
+ * the live store. The caller is responsible for auditing the receipt; the
1745
+ * queue only records which proposal left and why.
1746
+ */
1747
+ recordReceipt(sessionId, id, receipt) {
1748
+ const record = this.sessionRecord(sessionId);
1749
+ const proposal = record.proposals.find((entry) => entry.id === id);
1750
+ if (proposal === void 0 || proposal.status !== "approved") return void 0;
1751
+ this.safeSave(sessionId, {
1752
+ version: 1,
1753
+ proposals: record.proposals.filter((entry) => entry.id !== id)
1754
+ });
1755
+ return {
1756
+ ...proposal,
1757
+ receipt
1758
+ };
1759
+ }
1760
+ };
1761
+ //#endregion
1762
+ //#region src/runtime/tokenpilot/review-storage.ts
1763
+ /** Domain name — `UNIT_NAME_RE` (`/^[a-z][a-z0-9_]*$/`) allows no hyphens. */
1764
+ const REVIEW_STORAGE_DOMAIN = "context_compression_review";
1765
+ /** The one declared table: one record per session id. */
1766
+ const REVIEW_STORAGE_TABLE = "sessions";
1767
+ /** Structural validator: accepts exactly the shape this module persists. */
1768
+ function reviewSessionRecordValidator() {
1769
+ return { safeParse(value) {
1770
+ if (typeof value !== "object" || value === null) return { success: false };
1771
+ const record = value;
1772
+ if (record.version !== 1 || !Array.isArray(record.proposals)) return { success: false };
1773
+ for (const proposal of record.proposals) {
1774
+ if (typeof proposal !== "object" || proposal === null) return { success: false };
1775
+ const entry = proposal;
1776
+ if (typeof entry.id !== "string" || typeof entry.sessionId !== "string") return { success: false };
1777
+ if (entry.kind !== "estimator" && entry.kind !== "dedup" && entry.kind !== "read-state") return { success: false };
1778
+ if (entry.status !== "pending" && entry.status !== "approved") return { success: false };
1779
+ if (!Number.isSafeInteger(entry.enqueuedTurn) || !Number.isSafeInteger(entry.lastTurnIndex)) return { success: false };
1780
+ if (!Array.isArray(entry.items) || typeof entry.benefit !== "object" || entry.benefit === null) return { success: false };
1781
+ for (const item of entry.items) {
1782
+ if (typeof item !== "object" || item === null) return { success: false };
1783
+ const one = item;
1784
+ if (!Number.isSafeInteger(one.seq) || typeof one.digest !== "string") return { success: false };
1785
+ }
1786
+ }
1787
+ return {
1788
+ success: true,
1789
+ data: value
1790
+ };
1791
+ } };
1792
+ }
1793
+ function reviewStorageSpec() {
1794
+ return {
1795
+ name: REVIEW_STORAGE_DOMAIN,
1796
+ version: 1,
1797
+ layout: "per-record",
1798
+ tables: { [REVIEW_STORAGE_TABLE]: { valueSchema: reviewSessionRecordValidator() } }
1799
+ };
1800
+ }
1801
+ /** Adapter presenting the sync KV face the queue expects over the domain table. */
1802
+ var StorageDomainReviewStore = class {
1803
+ table;
1804
+ constructor(table) {
1805
+ this.table = table;
1806
+ }
1807
+ load(sessionId) {
1808
+ const value = this.table.get(sessionId);
1809
+ return typeof value === "object" && value !== null ? value : void 0;
1810
+ }
1811
+ save(sessionId, record) {
1812
+ this.table.put(sessionId, record).catch(() => void 0);
1813
+ }
1814
+ ids() {
1815
+ return [...this.table.keys()];
1816
+ }
1817
+ };
1818
+ /**
1819
+ * Attempt to open the review storage domain through the optional
1820
+ * `storageDomain` seam.
1821
+ * @param getService - resolved once with the seam name; `undefined` means the
1822
+ * host lacks the service.
1823
+ * @returns the durable store, or `undefined` when the seam is absent or fails
1824
+ * (the caller falls back to the in-memory store and logs one warning).
1825
+ */
1826
+ async function openReviewStorage(getService) {
1827
+ let service;
1828
+ try {
1829
+ service = getService("storageDomain");
1830
+ } catch {
1831
+ return;
1832
+ }
1833
+ if (service === void 0 || service === null) return void 0;
1834
+ return new StorageDomainReviewStore((await service.open(reviewStorageSpec())).table(REVIEW_STORAGE_TABLE));
1835
+ }
1836
+ //#endregion
1456
1837
  //#region src/runtime/reducers.ts
1457
1838
  /** Deterministic, evidence-backed reducers for fresh tool results. */
1458
1839
  const ANSI_PATTERN = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/gu;
@@ -2488,8 +2869,18 @@ var ToolResultPruner = class extends Service {
2488
2869
  postflightDiagnostics: /* @__PURE__ */ new WeakMap(),
2489
2870
  activeRequestBoundaries: /* @__PURE__ */ new WeakMap(),
2490
2871
  tailTrimBoundaryAttempts: /* @__PURE__ */ new WeakMap(),
2491
- policyResolutionAudits: /* @__PURE__ */ new WeakMap()
2872
+ policyResolutionAudits: /* @__PURE__ */ new WeakMap(),
2873
+ reviewStore: new MemoryReviewStore(),
2874
+ reviewQueues: /* @__PURE__ */ new WeakMap(),
2875
+ reviewClocks: /* @__PURE__ */ new WeakMap(),
2876
+ estimatorRemainingTurns: /* @__PURE__ */ new WeakMap(),
2877
+ reviewSummaries: /* @__PURE__ */ new WeakMap()
2492
2878
  };
2879
+ openReviewStorage((name) => this.ctx.get(name)).then((store) => {
2880
+ if (store !== void 0) this.state.reviewStore = store;
2881
+ }).catch(() => {
2882
+ this.ctx.logger.warn("context-compression review storage unavailable; keeping in-memory review queue");
2883
+ });
2493
2884
  ctx.on("session/event", (session, event) => {
2494
2885
  this.scanForSeededNativeSummary(session);
2495
2886
  if (event.type === "compaction/summary") {
@@ -2508,6 +2899,7 @@ var ToolResultPruner = class extends Service {
2508
2899
  this.state.activeRequestBoundaries.set(agent.session, boundary);
2509
2900
  try {
2510
2901
  if (!signal.aborted) try {
2902
+ this.reviewClock(agent.session, turn);
2511
2903
  this.runRequestBoundary(agent.session, turn, step - 1, signal);
2512
2904
  } catch (error) {
2513
2905
  this.auditFailure(agent.session, "fresh", "request-boundary", error);
@@ -2529,6 +2921,12 @@ var ToolResultPruner = class extends Service {
2529
2921
  this.auditFailure(agent.session, "fresh", "terminal-pass", error);
2530
2922
  ctx.logger.warn("context-compression terminal pass failed open: %o", error);
2531
2923
  }
2924
+ try {
2925
+ this.expireReviewProposals(agent.session, turn);
2926
+ this.applyApprovedProposals(agent.session);
2927
+ } catch (error) {
2928
+ ctx.logger.warn("context-compression review turn-boundary pass failed open: %o", error);
2929
+ }
2532
2930
  this.postflightEstimatorPass(agent.session, signal).catch(() => void 0);
2533
2931
  });
2534
2932
  }
@@ -2569,7 +2967,7 @@ var ToolResultPruner = class extends Service {
2569
2967
  const exactUnavailable = eligible.some((candidate) => candidate.count.kind !== "exact-tokenizer");
2570
2968
  if (exactUnavailable) this.warnExactUnavailable(session, view, "native");
2571
2969
  const planned = eligible.map((candidate) => this.planNative(candidate, session, stage, policy, view)).filter((entry) => entry !== null);
2572
- landed.push(...this.landAll(session, planned));
2970
+ landed.push(...this.landAll(session, this.triageForReview(session, policy, planned)));
2573
2971
  if (landed.length === 0) {
2574
2972
  const exact = eligible.flatMap((candidate) => candidate.count.kind === "exact-tokenizer" ? [candidate.count.tokens] : []);
2575
2973
  this.auditComponent(session, policy, "native-tool-result", "pressure", "skipped", exactUnavailable ? "exact-tokenizer-unavailable" : exact.length === 0 ? "no-tool-result-candidates" : Math.max(...exact) <= policy.nativeTriggerTokens ? "at-or-below-trigger" : planned.length === 0 ? "no-valid-reduction" : "recovery-tool-unavailable", {
@@ -2591,13 +2989,13 @@ var ToolResultPruner = class extends Service {
2591
2989
  if (historyOutcome.kind === "planned") {
2592
2990
  const capacityPressure = this.capacityPressureActive(session, view, policy);
2593
2991
  historyAllowed = this.adaptiveHistoryAllowed(session, view, historyOutcome.plans, capacityPressure);
2594
- if (historyAllowed) landed.push(...this.landAll(session, historyOutcome.plans));
2992
+ if (historyAllowed) landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans)));
2595
2993
  }
2596
2994
  } else {
2597
2995
  historyAllowed = this.historyAllowed(session, policy, view);
2598
2996
  if (historyAllowed) {
2599
2997
  historyOutcome = this.planHistoricalAging(session, policy, view);
2600
- if (historyOutcome.kind === "planned") landed.push(...this.landAll(session, historyOutcome.plans));
2998
+ if (historyOutcome.kind === "planned") landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans)));
2601
2999
  }
2602
3000
  }
2603
3001
  if (!landed.some((entry) => entry.stage === "pressure")) this.auditHistoryEvaluation(session, policy, view, historyAllowed, historyOutcome);
@@ -2752,7 +3150,9 @@ var ToolResultPruner = class extends Service {
2752
3150
  verdicts = /* @__PURE__ */ new Map();
2753
3151
  this.state.estimatorVerdicts.set(session, verdicts);
2754
3152
  }
2755
- for (const verdict of parseEstimatorAnswer(answer)) {
3153
+ const detailed = parseEstimatorAnswerDetailed(answer);
3154
+ if (detailed.expectedRemainingTurns !== void 0) this.state.estimatorRemainingTurns.set(session, detailed.expectedRemainingTurns);
3155
+ for (const verdict of detailed.verdicts) {
2756
3156
  if (verdicts.has(verdict.seq)) continue;
2757
3157
  verdicts.set(verdict.seq, verdict.expired);
2758
3158
  if (verdict.expired) expired += 1;
@@ -2776,10 +3176,297 @@ var ToolResultPruner = class extends Service {
2776
3176
  ok
2777
3177
  });
2778
3178
  }
3179
+ /**
3180
+ * The per-session review queue, or `undefined` while review mode is off
3181
+ * (every review path must then behave exactly like before).
3182
+ */
3183
+ reviewQueueFor(session, policy) {
3184
+ const presetOptions = policy?.presetOptions;
3185
+ if (presetOptions?.reviewMode !== true) return void 0;
3186
+ let queue = this.state.reviewQueues.get(session);
3187
+ if (queue === void 0) {
3188
+ queue = new ReviewQueue(this.state.reviewStore, { timeoutTurns: presetOptions.reviewTimeoutTurns });
3189
+ this.state.reviewQueues.set(session, queue);
3190
+ }
3191
+ return queue;
3192
+ }
3193
+ /**
3194
+ * Monotonic per-session turn clock for review patience and expiry. Bumped by
3195
+ * the agent loop payloads (`pre-step` / `turn-stopping`); passes without a
3196
+ * turn coordinate reuse the last observed value.
3197
+ */
3198
+ reviewClock(session, turn) {
3199
+ const previous = this.state.reviewClocks.get(session) ?? 0;
3200
+ const next = typeof turn === "number" && Number.isSafeInteger(turn) && turn > previous ? turn : previous;
3201
+ this.state.reviewClocks.set(session, next);
3202
+ return next;
3203
+ }
3204
+ auditReviewOutcome(session, proposal, event, extra = {}, turnIndex) {
3205
+ const tokensBefore = proposal.items.reduce((sum, item) => sum + item.tokensBefore, 0);
3206
+ const tokensAfter = proposal.items.reduce((sum, item) => sum + item.tokensAfter, 0);
3207
+ emitCompressionAudit(this.ctx.logger, {
3208
+ schemaVersion: 1,
3209
+ kind: "review-outcome",
3210
+ sessionId: String(session.id),
3211
+ proposalId: proposal.id,
3212
+ proposalKind: proposal.kind,
3213
+ event,
3214
+ ...extra.decision === void 0 ? {} : { decision: extra.decision },
3215
+ ...extra.receiptStatus === void 0 ? {} : { receiptStatus: extra.receiptStatus },
3216
+ ...extra.reasonCode === void 0 ? {} : { reasonCode: extra.reasonCode },
3217
+ itemSeqs: proposal.items.map((item) => item.seq),
3218
+ tokensBefore,
3219
+ tokensAfter,
3220
+ ...turnIndex === void 0 ? {} : { turnIndex }
3221
+ });
3222
+ }
3223
+ /**
3224
+ * Review-mode triage hook: classify one pass's planned replacements and
3225
+ * withhold the review bucket from landing, enqueuing it for human approval
3226
+ * instead. With review mode off (or nothing planned) this is the identity.
3227
+ *
3228
+ * The digest freezes each candidate's ORIGINAL surface content, so the apply
3229
+ * point can prove "what is removed now is what was approved then".
3230
+ */
3231
+ triageForReview(session, policy, plans) {
3232
+ const queue = this.reviewQueueFor(session, policy);
3233
+ if (queue === void 0 || plans.length === 0) return plans;
3234
+ const presetOptions = policy.presetOptions;
3235
+ const estimatorVerdicts = this.state.estimatorVerdicts.get(session);
3236
+ const estimatorSeqs = new Set([...estimatorVerdicts?.entries() ?? []].filter(([, expired]) => expired).map(([seq]) => seq));
3237
+ const input = {
3238
+ alpha: presetOptions.cacheHitDiscountAlpha,
3239
+ tailTokens: Math.max(1, policy.historyKeepRecentTokens),
3240
+ reviewHighImpactTokens: presetOptions.reviewHighImpactTokens,
3241
+ ...this.state.estimatorRemainingTurns.get(session) === void 0 ? {} : { remainingTurns: this.state.estimatorRemainingTurns.get(session) },
3242
+ estimatorSeqs
3243
+ };
3244
+ const classified = classifyCandidates(plans.map((plan) => ({
3245
+ sourceSeq: plan.sourceSeq,
3246
+ tokensBefore: plan.tokensBefore,
3247
+ tokensAfter: plan.tokensAfter,
3248
+ component: plan.component,
3249
+ reducer: plan.reducer,
3250
+ content: plan.candidate.event.data.message.content[0].content
3251
+ })), input);
3252
+ const autoSeqs = new Set(classified.auto.map((candidate) => candidate.sourceSeq));
3253
+ const clock = this.reviewClock(session);
3254
+ for (const skeleton of classified.review) if (queue.enqueue(String(session.id), skeleton, clock)) this.auditReviewOutcome(session, {
3255
+ id: skeleton.id,
3256
+ kind: skeleton.kind,
3257
+ items: skeleton.items
3258
+ }, "enqueue", {}, clock);
3259
+ return plans.filter((plan) => autoSeqs.has(plan.sourceSeq));
3260
+ }
3261
+ /**
3262
+ * Execute every approved proposal of one session as ONE merged replacement
3263
+ * batch at the current turn boundary, following the upstream applied-receipt
3264
+ * discipline: applied receipts are built only from real mutation evidence —
3265
+ * estimates never cross into applied savings.
3266
+ *
3267
+ * Per proposal: every item's frozen digest is re-checked against the current
3268
+ * surface content; any mismatch voids the whole proposal (deferred with a
3269
+ * reason code) instead of deleting something the user never approved.
3270
+ * Fail-open: any unexpected error only logs and leaves the queue intact.
3271
+ */
3272
+ applyApprovedProposals(session) {
3273
+ const policy = this.activePolicy(session);
3274
+ const queue = this.reviewQueueFor(session, policy);
3275
+ if (queue === void 0) return;
3276
+ const sessionId = String(session.id);
3277
+ const approved = [...queue.listApproved(sessionId)];
3278
+ if (approved.length === 0) return;
3279
+ try {
3280
+ const view = measureForCompaction(this.ctx, session);
3281
+ const candidatesBySeq = new Map(this.snapshot(session, view).map((candidate) => [candidate.seq, candidate]));
3282
+ const settled = [];
3283
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3284
+ for (const proposal of approved) {
3285
+ const voidedReason = this.reviewProposalVoided(session, proposal);
3286
+ if (voidedReason !== void 0) {
3287
+ settled.push({
3288
+ proposal,
3289
+ receipt: {
3290
+ status: "deferred",
3291
+ reasonCode: voidedReason,
3292
+ estimatedTokens: proposal.benefit.recoveredTokens,
3293
+ updatedAt: now
3294
+ }
3295
+ });
3296
+ continue;
3297
+ }
3298
+ const batchPlans = [];
3299
+ let planned = true;
3300
+ for (const item of proposal.items) {
3301
+ const candidate = candidatesBySeq.get(item.seq);
3302
+ if (candidate === void 0) {
3303
+ planned = false;
3304
+ break;
3305
+ }
3306
+ const plan = this.planAggregate(candidate, session, view, "review-approved-whole-result", "pressure", void 0, "history", policy?.historyMode);
3307
+ if (plan === null) {
3308
+ planned = false;
3309
+ break;
3310
+ }
3311
+ batchPlans.push(plan);
3312
+ }
3313
+ if (!planned || batchPlans.length === 0) {
3314
+ settled.push({
3315
+ proposal,
3316
+ receipt: {
3317
+ status: "deferred",
3318
+ reasonCode: "review_receipt_execution_invalid",
3319
+ estimatedTokens: proposal.benefit.recoveredTokens,
3320
+ updatedAt: now
3321
+ }
3322
+ });
3323
+ continue;
3324
+ }
3325
+ const landed = this.landAll(session, batchPlans);
3326
+ if (landed.length === 0) {
3327
+ settled.push({
3328
+ proposal,
3329
+ receipt: {
3330
+ status: "deferred",
3331
+ reasonCode: "review_receipt_execution_invalid",
3332
+ estimatedTokens: proposal.benefit.recoveredTokens,
3333
+ updatedAt: now
3334
+ }
3335
+ });
3336
+ continue;
3337
+ }
3338
+ const landedForProposal = new Map(landed.map((entry) => [entry.originalSeq, entry]));
3339
+ const measuredItems = proposal.items.map((item) => {
3340
+ const entry = landedForProposal.get(item.seq);
3341
+ return entry === void 0 ? item : {
3342
+ ...item,
3343
+ tokensBefore: entry.tokensBefore,
3344
+ tokensAfter: entry.tokensAfter
3345
+ };
3346
+ });
3347
+ const appliedTokens = measuredItems.reduce((sum, item) => sum + item.tokensBefore - item.tokensAfter, 0);
3348
+ settled.push({
3349
+ proposal,
3350
+ auditItems: measuredItems,
3351
+ receipt: {
3352
+ status: "applied",
3353
+ estimatedTokens: proposal.benefit.recoveredTokens,
3354
+ appliedTokens,
3355
+ updatedAt: now
3356
+ }
3357
+ });
3358
+ }
3359
+ for (const { proposal, auditItems, receipt } of settled) {
3360
+ queue.recordReceipt(sessionId, proposal.id, receipt);
3361
+ const summary = this.reviewSummaryFor(session);
3362
+ if (receipt.status === "applied") summary.reviewApplied += 1;
3363
+ else summary.voided += 1;
3364
+ this.auditReviewOutcome(session, auditItems === void 0 ? proposal : {
3365
+ ...proposal,
3366
+ items: auditItems
3367
+ }, receipt.status === "applied" ? "apply-receipt" : "apply-void", receipt.status === "applied" ? { receiptStatus: "applied" } : {
3368
+ receiptStatus: "deferred",
3369
+ reasonCode: receipt.reasonCode
3370
+ });
3371
+ }
3372
+ } catch (error) {
3373
+ this.ctx.logger.warn("context-compression review apply failed open: %o", error);
3374
+ }
3375
+ }
3376
+ /**
3377
+ * Current surface content at one seq: the newest covering replacement's
3378
+ * blocks when the seq was rewritten, otherwise the original event's blocks.
3379
+ */
3380
+ surfaceContentAt(session, seq) {
3381
+ let content;
3382
+ for (const event of sessionEvents(session)) {
3383
+ if (event.type !== "tool/result") continue;
3384
+ const op = event.surfaceOp;
3385
+ if (typeof op === "object" && op.op === "replace" && op.startSeq <= seq && seq <= op.endSeq) content = event.data.message.content[0].content;
3386
+ }
3387
+ if (content !== void 0) return content;
3388
+ const original = sessionEvents(session).find((entry) => entry.seq === seq);
3389
+ return original?.type === "tool/result" ? original.data.message.content[0].content : void 0;
3390
+ }
3391
+ /**
3392
+ * The execution-point digest check: `undefined` when every item's frozen
3393
+ * digest still matches the current surface content, otherwise the aligned
3394
+ * reason code explaining the void.
3395
+ */
3396
+ reviewProposalVoided(session, proposal) {
3397
+ for (const item of proposal.items) {
3398
+ const current = this.surfaceContentAt(session, item.seq);
3399
+ if (current === void 0) return "review_receipt_missing_candidate";
3400
+ if (contentDigest(current) !== item.digest) return "review_receipt_digest_invalid";
3401
+ }
3402
+ }
3403
+ reviewSummaryFor(session) {
3404
+ let summary = this.state.reviewSummaries.get(session);
3405
+ if (summary === void 0) {
3406
+ summary = {
3407
+ autoApplied: 0,
3408
+ reviewApplied: 0,
3409
+ expired: 0,
3410
+ voided: 0
3411
+ };
3412
+ this.state.reviewSummaries.set(session, summary);
3413
+ }
3414
+ return summary;
3415
+ }
3416
+ /** Live pending review proposals of one session; empty when review mode is off. */
3417
+ listReviewProposals(session) {
3418
+ return this.reviewQueueFor(session, this.activePolicy(session))?.listPending(String(session.id)) ?? [];
3419
+ }
3420
+ /**
3421
+ * Every session's live pending proposals, for the floating window's
3422
+ * aggregate badge (the client carries no session id of its own).
3423
+ */
3424
+ listAllReviewProposals() {
3425
+ const ids = this.state.reviewStore.ids?.() ?? [];
3426
+ const reader = new ReviewQueue(this.state.reviewStore, { timeoutTurns: 1 });
3427
+ return ids.map((sessionId) => ({
3428
+ sessionId,
3429
+ proposals: [...reader.listPending(sessionId)]
3430
+ })).filter((entry) => entry.proposals.length > 0);
3431
+ }
3432
+ /** Four-state outcome counters of one session (floating-window summary row). */
3433
+ reviewSummary(session) {
3434
+ return { ...this.reviewSummaryFor(session) };
3435
+ }
3436
+ /**
3437
+ * Record one human decision. Returns the outcome, or `undefined` when
3438
+ * review mode is off for this session (the route maps that to 503).
3439
+ */
3440
+ decideReviewProposal(session, proposalId, decision) {
3441
+ const queue = this.reviewQueueFor(session, this.activePolicy(session));
3442
+ if (queue === void 0) return void 0;
3443
+ const sessionId = String(session.id);
3444
+ const pending = queue.listPending(sessionId).find((entry) => entry.id === proposalId);
3445
+ const outcome = queue.decide(sessionId, proposalId, decision);
3446
+ if (outcome.ok && pending !== void 0) this.auditReviewOutcome(session, pending, "decide", { decision }, this.reviewClock(session));
3447
+ return outcome;
3448
+ }
3449
+ /**
3450
+ * Expire stale pending proposals at one turn boundary and audit each.
3451
+ * Public because tests drive it directly; the turn-stopping handler calls
3452
+ * it with the loop's own turn index.
3453
+ */
3454
+ expireReviewProposals(session, turnIndex) {
3455
+ const queue = this.reviewQueueFor(session, this.activePolicy(session));
3456
+ if (queue === void 0) return [];
3457
+ const clock = this.reviewClock(session, turnIndex);
3458
+ const expired = queue.expireTurn(String(session.id), clock);
3459
+ if (expired.length > 0) this.reviewSummaryFor(session).expired += expired.length;
3460
+ for (const proposal of expired) this.auditReviewOutcome(session, proposal, "expire", {}, clock);
3461
+ return expired;
3462
+ }
2779
3463
  activePolicy(session, contextWindowTokens, stage = "pressure") {
2780
3464
  const settings = this.activeSettings(session);
2781
3465
  try {
2782
- const policy = resolvePolicy(this.state.config, settings.profile, settings.custom, {
3466
+ const policy = resolvePolicy(settings.presetOptions === void 0 ? this.state.config : {
3467
+ ...this.state.config,
3468
+ presetOptions: settings.presetOptions
3469
+ }, settings.profile, settings.custom, {
2783
3470
  ...contextWindowTokens === void 0 ? {} : { contextWindowTokens },
2784
3471
  autoCompactThresholdPercent: settings.autoCompact.thresholdPercent
2785
3472
  });
@@ -3100,7 +3787,8 @@ var ToolResultPruner = class extends Service {
3100
3787
  if (total > policy.aggregateTargetTokens) this.ctx.logger.warn("context-compression fresh aggregate residual: %d tokens exceed target %d", total, policy.aggregateTargetTokens);
3101
3788
  }
3102
3789
  }
3103
- const landed = this.landAll(session, candidates.map((candidate) => plans.get(candidate.seq)).filter((plan) => plan !== void 0));
3790
+ const freshCandidates = candidates.map((candidate) => plans.get(candidate.seq)).filter((plan) => plan !== void 0);
3791
+ const landed = this.landAll(session, this.triageForReview(session, policy, freshCandidates));
3104
3792
  const freshLanded = landed.some((entry) => entry.stage === "fresh" && plans.get(entry.originalSeq)?.component === "fresh");
3105
3793
  const aggregateLanded = landed.some((entry) => entry.stage === "fresh" && plans.get(entry.originalSeq)?.component === "aggregate");
3106
3794
  if (!freshLanded) this.auditComponent(session, policy, "fresh", "fresh", policy.freshEnabled ? "skipped" : "disabled", !policy.freshEnabled ? "profile-policy" : !exactAvailable ? "exact-tokenizer-unavailable" : (maxCandidateTokens ?? 0) <= policy.freshTriggerTokens ? "at-or-below-trigger" : freshPlanned > 0 && aggregatePlanned > 0 ? "superseded-by-aggregate" : freshPlanned === 0 ? "no-valid-reduction" : "recovery-tool-unavailable", {
@@ -3676,6 +4364,7 @@ var ToolResultPruner = class extends Service {
3676
4364
  tokenizerId: plan.tokenizerId,
3677
4365
  tokenizerRevision: plan.tokenizerRevision
3678
4366
  });
4367
+ if (plan.reducer !== "review-approved-whole-result") this.reviewSummaryFor(session).autoApplied += 1;
3679
4368
  return {
3680
4369
  originalSeq: candidate.seq,
3681
4370
  sourceSeq: plan.sourceSeq,