@lexwdex-org/opencode-dcp 3.4.13 → 3.5.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/dist/index.js CHANGED
@@ -27,6 +27,7 @@ function jaccard(a, b) {
27
27
  }
28
28
  var WINDOW_SIZE = 4;
29
29
  var DRIFT_BASELINE = 3;
30
+ var MIN_DRIFT_TOKENS = 6;
30
31
  function extractText(parts) {
31
32
  const texts = [];
32
33
  for (const part of parts) {
@@ -81,18 +82,21 @@ var AutoPruner = class {
81
82
  evaluate(state, text, at) {
82
83
  if (state.count + 1 < this.config.minMessages) return [];
83
84
  const signals = [];
84
- if (state.count > 0 && at - state.lastAt >= this.config.idleGapMs) {
85
+ const enabled = this.config.signals;
86
+ if (enabled.idleGap && state.count > 0 && at - state.lastAt >= this.config.idleGapMs) {
85
87
  signals.push("idle-gap");
86
88
  }
87
- if (state.count >= DRIFT_BASELINE && text) {
89
+ if (enabled.topicDrift && state.count >= DRIFT_BASELINE && text) {
88
90
  const current = tokenize(text);
89
- let max = 0;
90
- for (let index = Math.max(0, state.window.length - DRIFT_BASELINE); index < state.window.length; index++) {
91
- max = Math.max(max, jaccard(current, tokenize(state.window[index])));
91
+ if (current.size >= MIN_DRIFT_TOKENS) {
92
+ let max = 0;
93
+ for (let index = Math.max(0, state.window.length - DRIFT_BASELINE); index < state.window.length; index++) {
94
+ max = Math.max(max, jaccard(current, tokenize(state.window[index])));
95
+ }
96
+ if (max < this.config.driftThreshold) signals.push("topic-drift");
92
97
  }
93
- if (max < this.config.driftThreshold) signals.push("topic-drift");
94
98
  }
95
- if (state.count + 1 >= this.config.volumeThreshold) signals.push("volume");
99
+ if (enabled.volume && state.count + 1 >= this.config.volumeThreshold) signals.push("volume");
96
100
  return signals;
97
101
  }
98
102
  state(sessionID) {
@@ -977,6 +981,7 @@ var ParseErrorCode;
977
981
  var DEFAULT_FAILURE_COOLDOWN_MS = 3e4;
978
982
  var DEFAULT_AUTO_PRUNE = {
979
983
  enabled: true,
984
+ signals: { topicDrift: true, volume: false, idleGap: false },
980
985
  minMessages: 8,
981
986
  volumeThreshold: 30,
982
987
  driftThreshold: 0.18,
@@ -997,6 +1002,10 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
997
1002
  "summarize.failureCooldownMs",
998
1003
  "autoPrune",
999
1004
  "autoPrune.enabled",
1005
+ "autoPrune.signals",
1006
+ "autoPrune.signals.topicDrift",
1007
+ "autoPrune.signals.volume",
1008
+ "autoPrune.signals.idleGap",
1000
1009
  "autoPrune.minMessages",
1001
1010
  "autoPrune.volumeThreshold",
1002
1011
  "autoPrune.driftThreshold",
@@ -1171,6 +1180,27 @@ function validateConfigTypes(config) {
1171
1180
  });
1172
1181
  }
1173
1182
  }
1183
+ const signals = autoPrune.signals;
1184
+ if (signals !== void 0) {
1185
+ if (typeof signals !== "object" || signals === null || Array.isArray(signals)) {
1186
+ errors.push({
1187
+ key: "autoPrune.signals",
1188
+ expected: "object",
1189
+ actual: typeof signals
1190
+ });
1191
+ } else {
1192
+ for (const key of ["topicDrift", "volume", "idleGap"]) {
1193
+ const value = signals[key];
1194
+ if (value !== void 0 && typeof value !== "boolean") {
1195
+ errors.push({
1196
+ key: `autoPrune.signals.${key}`,
1197
+ expected: "boolean",
1198
+ actual: typeof value
1199
+ });
1200
+ }
1201
+ }
1202
+ }
1203
+ }
1174
1204
  }
1175
1205
  }
1176
1206
  const tool2 = config.tool;
@@ -1191,15 +1221,25 @@ function validateConfigTypes(config) {
1191
1221
  }
1192
1222
  return errors;
1193
1223
  }
1224
+ function needsSignalsMigrationHint(configData) {
1225
+ const autoPrune = configData.autoPrune;
1226
+ return autoPrune !== null && typeof autoPrune === "object" && !Array.isArray(autoPrune) && autoPrune.enabled === true && autoPrune.signals === void 0;
1227
+ }
1194
1228
  function showConfigWarnings(ctx, configPath, configData, isProject) {
1195
1229
  const invalidKeys = getInvalidConfigKeys(configData);
1196
1230
  const deprecatedKeys = getDeprecatedConfigKeys(configData);
1197
1231
  const typeErrors = validateConfigTypes(configData);
1198
- if (invalidKeys.length === 0 && deprecatedKeys.length === 0 && typeErrors.length === 0) {
1232
+ const signalsHint = needsSignalsMigrationHint(configData);
1233
+ if (!signalsHint && invalidKeys.length === 0 && deprecatedKeys.length === 0 && typeErrors.length === 0) {
1199
1234
  return;
1200
1235
  }
1201
1236
  const configType = isProject ? "project config" : "config";
1202
1237
  const messages = [];
1238
+ if (signalsHint) {
1239
+ messages.push(
1240
+ "auto-prune signals `volume` and `idleGap` are now disabled by default; re-enable via autoPrune.signals.*"
1241
+ );
1242
+ }
1203
1243
  if (deprecatedKeys.length > 0) {
1204
1244
  const keyList = deprecatedKeys.slice(0, 3).join(", ");
1205
1245
  const suffix = deprecatedKeys.length > 3 ? ` (+${deprecatedKeys.length - 3} more)` : "";
@@ -1348,8 +1388,15 @@ function mergeAutoPrune(base, override) {
1348
1388
  return base;
1349
1389
  }
1350
1390
  const number = (key, min, max = Number.POSITIVE_INFINITY) => typeof override[key] === "number" && Number.isFinite(override[key]) && override[key] >= min && override[key] <= max ? override[key] : base[key];
1391
+ const signalsOverride = override.signals;
1392
+ const signals = signalsOverride && typeof signalsOverride === "object" && !Array.isArray(signalsOverride) ? {
1393
+ topicDrift: typeof signalsOverride.topicDrift === "boolean" ? signalsOverride.topicDrift : base.signals.topicDrift,
1394
+ volume: typeof signalsOverride.volume === "boolean" ? signalsOverride.volume : base.signals.volume,
1395
+ idleGap: typeof signalsOverride.idleGap === "boolean" ? signalsOverride.idleGap : base.signals.idleGap
1396
+ } : base.signals;
1351
1397
  return {
1352
1398
  enabled: typeof override.enabled === "boolean" ? override.enabled : base.enabled,
1399
+ signals,
1353
1400
  minMessages: number("minMessages", 1),
1354
1401
  volumeThreshold: number("volumeThreshold", 2),
1355
1402
  driftThreshold: number("driftThreshold", 0, 1),
@@ -1371,7 +1418,7 @@ function deepCloneConfig(config) {
1371
1418
  commands: { ...config.commands },
1372
1419
  experimental: { ...config.experimental },
1373
1420
  summarize: { ...config.summarize },
1374
- autoPrune: { ...config.autoPrune },
1421
+ autoPrune: { ...config.autoPrune, signals: { ...config.autoPrune.signals } },
1375
1422
  tool: { ...config.tool }
1376
1423
  };
1377
1424
  }
@@ -1438,27 +1485,231 @@ Using previous/default values`
1438
1485
  return config;
1439
1486
  }
1440
1487
 
1441
- // lib/session-model.ts
1442
- function latestUserModel(messages) {
1443
- if (!Array.isArray(messages)) return null;
1444
- for (let index = messages.length - 1; index >= 0; index--) {
1445
- const info = messages[index]?.info;
1446
- if (info?.role !== "user") continue;
1447
- const providerID = info.model?.providerID;
1448
- const modelID = info.model?.modelID;
1449
- if (typeof providerID === "string" && typeof modelID === "string") {
1450
- return { providerID, modelID };
1488
+ // lib/session-boundary.ts
1489
+ var BOUNDARY_QUIET_MS = 2e3;
1490
+ var PROBE_TIMEOUT_MS = 2e3;
1491
+ var BUSY_EVIDENCE_TTL_MS = 10 * 6e4;
1492
+ var MAX_TRACKED_SESSIONS = 500;
1493
+ var SessionBoundaryTracker = class {
1494
+ sessions = /* @__PURE__ */ new Map();
1495
+ listeners = [];
1496
+ probeBusy;
1497
+ logger;
1498
+ now;
1499
+ setTimer;
1500
+ clearTimer;
1501
+ constructor(deps) {
1502
+ this.probeBusy = deps.probeBusy;
1503
+ this.logger = deps.logger;
1504
+ this.now = deps.now ?? Date.now;
1505
+ this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
1506
+ this.clearTimer = deps.clearTimer ?? ((t) => clearTimeout(t));
1507
+ }
1508
+ /** Register an at-rest listener; listeners fire in registration order
1509
+ * (1. deferred drain, 2. heuristic auto prune) and are individually
1510
+ * error-isolated: a throw or rejection is warn-logged and never blocks
1511
+ * later listeners nor escapes into the timer callback. */
1512
+ onAtRest(listener) {
1513
+ this.listeners.push(listener);
1514
+ }
1515
+ /** Primary input: `session.status` event payload status type. */
1516
+ observeStatus(sessionID, statusType) {
1517
+ if (!sessionID) return;
1518
+ const entry = this.entry(sessionID);
1519
+ entry.sawStatus = true;
1520
+ if (statusType === "busy" || statusType === "retry") {
1521
+ this.transition(
1522
+ sessionID,
1523
+ entry,
1524
+ "busy",
1525
+ statusType === "retry" ? "retry-observed" : "busy-observed"
1526
+ );
1527
+ return;
1528
+ }
1529
+ if (statusType === "idle") {
1530
+ this.observeIdle(sessionID, entry, "status");
1451
1531
  }
1452
1532
  }
1453
- return null;
1454
- }
1455
- async function resolveSessionModel(client, sessionID) {
1456
- try {
1457
- const response = await client.session.messages({ path: { id: sessionID } });
1458
- return latestUserModel(response.data ?? response);
1459
- } catch {
1460
- return null;
1533
+ /** First-class input: legacy `session.idle`. Absorbed by dedup on hosts
1534
+ * that dual-publish; the only boundary signal on status-less hosts (T3). */
1535
+ observeLegacyIdle(sessionID) {
1536
+ if (!sessionID) return;
1537
+ this.observeIdle(sessionID, this.entry(sessionID), "legacy-idle");
1538
+ }
1539
+ /** Lifecycle: cancel any pending window and forget the session. */
1540
+ observeDeleted(sessionID) {
1541
+ if (!sessionID) return;
1542
+ const entry = this.sessions.get(sessionID);
1543
+ if (!entry) return;
1544
+ this.cancelWindow(entry);
1545
+ this.sessions.delete(sessionID);
1546
+ this.logger.debug("Boundary session dropped", { sessionId: sessionID, reason: "deleted" });
1547
+ }
1548
+ /** Lifecycle: compaction ended; reset to unknown so the next idle opens a
1549
+ * fresh window. */
1550
+ observeCompacted(sessionID) {
1551
+ if (!sessionID) return;
1552
+ const entry = this.sessions.get(sessionID);
1553
+ if (!entry) return;
1554
+ const from = entry.phase;
1555
+ this.cancelWindow(entry);
1556
+ entry.phase = "unknown";
1557
+ delete entry.busyAt;
1558
+ this.logger.debug("Boundary transition", {
1559
+ sessionId: sessionID,
1560
+ from,
1561
+ to: "unknown",
1562
+ reason: "compacted"
1563
+ });
1564
+ }
1565
+ /** Absorbed busy-evidence cache: `busy` decays to `unknown` after the TTL;
1566
+ * the table is LRU-bounded (pending-rest/at-rest entries have no TTL exit
1567
+ * and rely on the cap). */
1568
+ state(sessionID) {
1569
+ const entry = this.sessions.get(sessionID);
1570
+ if (!entry) return "unknown";
1571
+ if (entry.phase === "busy" && entry.busyAt !== void 0 && this.now() - entry.busyAt > BUSY_EVIDENCE_TTL_MS) {
1572
+ return "unknown";
1573
+ }
1574
+ return entry.phase;
1575
+ }
1576
+ dispose(sessionID) {
1577
+ this.observeDeleted(sessionID);
1578
+ }
1579
+ observeIdle(sessionID, entry, source) {
1580
+ if (entry.phase === "pending-rest") {
1581
+ this.logger.debug("Boundary duplicate idle ignored", { sessionId: sessionID, source });
1582
+ return;
1583
+ }
1584
+ if (entry.phase === "at-rest") {
1585
+ if (source === "legacy-idle" && !entry.sawStatus) {
1586
+ this.armWindow(sessionID, entry, "degrade-rearm");
1587
+ } else {
1588
+ this.logger.debug("Boundary duplicate idle ignored", {
1589
+ sessionId: sessionID,
1590
+ source
1591
+ });
1592
+ }
1593
+ return;
1594
+ }
1595
+ this.armWindow(sessionID, entry, "idle-observed");
1596
+ }
1597
+ armWindow(sessionID, entry, reason) {
1598
+ this.cancelWindow(entry);
1599
+ entry.phase = "pending-rest";
1600
+ entry.timer = this.setTimer(() => {
1601
+ entry.timer = void 0;
1602
+ void this.onWindowExpiry(sessionID, entry);
1603
+ }, BOUNDARY_QUIET_MS);
1604
+ this.refresh(sessionID, entry);
1605
+ this.logger.debug("Boundary quiet window armed", {
1606
+ sessionId: sessionID,
1607
+ reason,
1608
+ generation: entry.generation
1609
+ });
1610
+ }
1611
+ async onWindowExpiry(sessionID, entry) {
1612
+ if (entry.phase !== "pending-rest") return;
1613
+ const generation = entry.generation;
1614
+ let busy;
1615
+ try {
1616
+ busy = await this.probeBusy(sessionID);
1617
+ } catch {
1618
+ busy = null;
1619
+ }
1620
+ if (entry.phase !== "pending-rest" || entry.generation !== generation || this.sessions.get(sessionID) !== entry) {
1621
+ this.logger.debug("Boundary probe result discarded", {
1622
+ sessionId: sessionID,
1623
+ generation
1624
+ });
1625
+ return;
1626
+ }
1627
+ if (busy === true) {
1628
+ this.transition(sessionID, entry, "busy", "probe-busy");
1629
+ return;
1630
+ }
1631
+ const reason = busy === false ? "window-expired" : "probe-failopen";
1632
+ const from = entry.phase;
1633
+ entry.phase = "at-rest";
1634
+ this.refresh(sessionID, entry);
1635
+ this.logger.debug("Boundary transition", {
1636
+ sessionId: sessionID,
1637
+ from,
1638
+ to: "at-rest",
1639
+ reason
1640
+ });
1641
+ this.fireAtRest(sessionID, reason);
1642
+ }
1643
+ transition(sessionID, entry, phase, reason) {
1644
+ const from = entry.phase;
1645
+ this.cancelWindow(entry);
1646
+ entry.phase = phase;
1647
+ if (phase === "busy") entry.busyAt = this.now();
1648
+ else delete entry.busyAt;
1649
+ this.refresh(sessionID, entry);
1650
+ this.logger.debug("Boundary transition", { sessionId: sessionID, from, to: phase, reason });
1651
+ }
1652
+ cancelWindow(entry) {
1653
+ if (entry.timer !== void 0) {
1654
+ this.clearTimer(entry.timer);
1655
+ entry.timer = void 0;
1656
+ }
1657
+ entry.generation += 1;
1658
+ }
1659
+ fireAtRest(sessionID, reason) {
1660
+ for (const listener of this.listeners) {
1661
+ try {
1662
+ const result = listener(sessionID, reason);
1663
+ if (result instanceof Promise) {
1664
+ result.catch((error) => {
1665
+ this.logger.warn("At-rest listener rejected", {
1666
+ sessionId: sessionID,
1667
+ error: error instanceof Error ? error.message : String(error)
1668
+ });
1669
+ });
1670
+ }
1671
+ } catch (error) {
1672
+ this.logger.warn("At-rest listener failed", {
1673
+ sessionId: sessionID,
1674
+ error: error instanceof Error ? error.message : String(error)
1675
+ });
1676
+ }
1677
+ }
1461
1678
  }
1679
+ entry(sessionID) {
1680
+ let entry = this.sessions.get(sessionID);
1681
+ if (!entry) {
1682
+ entry = { phase: "unknown", generation: 0, sawStatus: false };
1683
+ this.sessions.set(sessionID, entry);
1684
+ this.evictIfNeeded(sessionID);
1685
+ return entry;
1686
+ }
1687
+ this.refresh(sessionID, entry);
1688
+ return entry;
1689
+ }
1690
+ refresh(sessionID, entry) {
1691
+ this.sessions.delete(sessionID);
1692
+ this.sessions.set(sessionID, entry);
1693
+ this.evictIfNeeded(sessionID);
1694
+ }
1695
+ evictIfNeeded(kept) {
1696
+ if (this.sessions.size <= MAX_TRACKED_SESSIONS) return;
1697
+ const oldest = this.sessions.keys().next().value;
1698
+ if (oldest === void 0 || oldest === kept) return;
1699
+ const victim = this.sessions.get(oldest);
1700
+ if (victim?.timer !== void 0) this.clearTimer(victim.timer);
1701
+ this.sessions.delete(oldest);
1702
+ }
1703
+ };
1704
+ function retrySeconds(retryAfterMs) {
1705
+ return Math.ceil(retryAfterMs / 1e3);
1706
+ }
1707
+ function eventSessionID(properties) {
1708
+ const direct = properties?.sessionID;
1709
+ if (typeof direct === "string" && direct) return direct;
1710
+ const info = properties?.info?.id;
1711
+ if (typeof info === "string" && info) return info;
1712
+ return void 0;
1462
1713
  }
1463
1714
 
1464
1715
  // lib/hooks.ts
@@ -1498,38 +1749,12 @@ var SIGNAL_LABELS = {
1498
1749
  "idle-gap": "\u957F\u65F6\u95F4\u4E2D\u65AD\u540E\u6062\u590D"
1499
1750
  };
1500
1751
  function createEventHandler(deps) {
1501
- async function triggerAutoPrune(sessionID, signals) {
1502
- const reason = signals.map((signal) => SIGNAL_LABELS[signal]).join("\u3001");
1503
- const model = await resolveSessionModel(deps.client, sessionID);
1504
- if (!model) {
1505
- deps.logger.debug("Auto prune skipped; no session model yet", { sessionId: sessionID });
1506
- return;
1507
- }
1508
- const result = await deps.summarize.summarize({ sessionID, model });
1509
- deps.autoPruner.markPruned(sessionID);
1510
- if (result.status === "succeeded") {
1511
- await showToast(deps.client, "DCP \u81EA\u52A8\u538B\u7F29", `\u68C0\u6D4B\u5230${reason}\uFF0C\u5DF2\u751F\u6210\u65B0\u7684\u8BED\u4E49\u68C0\u67E5\u70B9\u3002`);
1512
- } else {
1513
- await showToast(
1514
- deps.client,
1515
- "DCP \u81EA\u52A8\u538B\u7F29",
1516
- `\u68C0\u6D4B\u5230${reason}\uFF0C\u4F46\u538B\u7F29\u5931\u8D25\uFF1B\u539F\u59CB\u4E0A\u4E0B\u6587\u4FDD\u6301\u4E0D\u53D8\u3002`,
1517
- "warning"
1518
- );
1519
- }
1520
- deps.logger.debug("Auto prune finished", { sessionId: sessionID, status: result.status });
1521
- }
1522
1752
  return async (input) => {
1523
1753
  const event = input.event;
1524
- const sessionID = event.properties?.sessionID;
1525
- if (typeof sessionID !== "string" || !sessionID) return;
1526
1754
  try {
1527
- if (event.type === "session.idle") {
1528
- if (!deps.config.enabled) return;
1529
- const signals = deps.autoPruner.consumePending(sessionID);
1530
- if (signals) await triggerAutoPrune(sessionID, signals);
1531
- return;
1532
- }
1755
+ deps.prune.observeEvent(event.type, event.properties);
1756
+ const sessionID = eventSessionID(event.properties);
1757
+ if (!sessionID) return;
1533
1758
  if (event.type === "session.compacted") {
1534
1759
  deps.autoPruner.markPruned(sessionID);
1535
1760
  return;
@@ -1540,13 +1765,58 @@ function createEventHandler(deps) {
1540
1765
  } catch (error) {
1541
1766
  deps.logger.warn("Event handler failed", {
1542
1767
  type: event.type,
1543
- sessionId: sessionID,
1544
1768
  error: error instanceof Error ? error.message : String(error)
1545
1769
  });
1546
1770
  }
1547
1771
  };
1548
1772
  }
1549
- function createCommandExecuteHandler(client, summarize, logger) {
1773
+ async function triggerAutoPrune(deps, sessionID, signals) {
1774
+ const reason = signals.map((signal) => SIGNAL_LABELS[signal]).join("\u3001");
1775
+ const result = await deps.prune.request({ sessionID, onBusy: "proceed" });
1776
+ const attempted = result.status === "succeeded" || result.status === "failed" || result.status === "cooldown";
1777
+ if (!attempted) {
1778
+ if (result.status === "busy") {
1779
+ await showToast(
1780
+ deps.client,
1781
+ "DCP \u81EA\u52A8\u538B\u7F29",
1782
+ `\u68C0\u6D4B\u5230${reason}\uFF0C\u4F46\u4F1A\u8BDD\u6B63\u5FD9\uFF0C\u672C\u6B21\u5DF2\u8DF3\u8FC7\uFF1B\u539F\u59CB\u4E0A\u4E0B\u6587\u4FDD\u6301\u4E0D\u53D8\u3002`,
1783
+ "warning"
1784
+ );
1785
+ }
1786
+ deps.logger.debug("Auto prune skipped", {
1787
+ sessionId: sessionID,
1788
+ status: result.status
1789
+ });
1790
+ return;
1791
+ }
1792
+ deps.autoPruner.markPruned(sessionID);
1793
+ if (result.status === "succeeded") {
1794
+ await showToast(deps.client, "DCP \u81EA\u52A8\u538B\u7F29", `\u68C0\u6D4B\u5230${reason}\uFF0C\u5DF2\u751F\u6210\u65B0\u7684\u8BED\u4E49\u68C0\u67E5\u70B9\u3002`);
1795
+ } else if (result.status === "cooldown") {
1796
+ await showToast(
1797
+ deps.client,
1798
+ "DCP \u81EA\u52A8\u538B\u7F29",
1799
+ `\u68C0\u6D4B\u5230${reason}\uFF0C\u4F46\u4E0A\u4E00\u6B21\u538B\u7F29\u5931\u8D25\uFF1B${retrySeconds(result.retryAfterMs)} \u79D2\u540E\u53EF\u91CD\u8BD5\u3002`,
1800
+ "warning"
1801
+ );
1802
+ } else {
1803
+ await showToast(
1804
+ deps.client,
1805
+ "DCP \u81EA\u52A8\u538B\u7F29",
1806
+ `\u68C0\u6D4B\u5230${reason}\uFF0C\u4F46\u538B\u7F29\u5931\u8D25\uFF1B\u539F\u59CB\u4E0A\u4E0B\u6587\u4FDD\u6301\u4E0D\u53D8\u3002`,
1807
+ "warning"
1808
+ );
1809
+ }
1810
+ deps.logger.debug("Auto prune finished", { sessionId: sessionID, status: result.status });
1811
+ }
1812
+ function createAtRestAutoPruneListener(deps) {
1813
+ return async (sessionID, _reason) => {
1814
+ if (!deps.config.enabled) return;
1815
+ const signals = deps.autoPruner.consumePending(sessionID);
1816
+ if (signals) await triggerAutoPrune(deps, sessionID, signals);
1817
+ };
1818
+ }
1819
+ function createCommandExecuteHandler(client, prune, logger) {
1550
1820
  return async (input, _output) => {
1551
1821
  if (input.command !== "dcp") return;
1552
1822
  const subcommand = (input.arguments ?? "").trim().split(/\s+/, 1)[0]?.toLowerCase();
@@ -1558,8 +1828,17 @@ function createCommandExecuteHandler(client, summarize, logger) {
1558
1828
  );
1559
1829
  throw new Error("__DCP_HELP_HANDLED__");
1560
1830
  }
1561
- const model = await resolveSessionModel(client, input.sessionID);
1562
- if (!model) {
1831
+ const result = await prune.request({ sessionID: input.sessionID, onBusy: "proceed" });
1832
+ if (result.status === "busy") {
1833
+ await showToast(
1834
+ client,
1835
+ "DCP summarize",
1836
+ "Session is busy; the prune will not interrupt the current turn. Try again once it finishes.",
1837
+ "warning"
1838
+ );
1839
+ throw new Error("__DCP_SUMMARIZE_HANDLED__");
1840
+ }
1841
+ if (result.status === "no-model") {
1563
1842
  await showToast(
1564
1843
  client,
1565
1844
  "DCP summarize",
@@ -1568,14 +1847,13 @@ function createCommandExecuteHandler(client, summarize, logger) {
1568
1847
  );
1569
1848
  throw new Error("__DCP_SUMMARIZE_NO_MODEL__");
1570
1849
  }
1571
- const result = await summarize.summarize({ sessionID: input.sessionID, model });
1572
1850
  if (result.status === "succeeded") {
1573
1851
  await showToast(client, "DCP summarize", "Semantic pruning checkpoint created.");
1574
1852
  } else if (result.status === "cooldown") {
1575
1853
  await showToast(
1576
1854
  client,
1577
1855
  "DCP summarize",
1578
- `Previous attempt failed; retry in ${Math.ceil(result.retryAfterMs / 1e3)}s.`,
1856
+ `Previous attempt failed; retry in ${retrySeconds(result.retryAfterMs)}s.`,
1579
1857
  "warning"
1580
1858
  );
1581
1859
  } else {
@@ -1935,34 +2213,216 @@ var PromptStore = class {
1935
2213
 
1936
2214
  // lib/prune-tool.ts
1937
2215
  import { tool } from "@opencode-ai/plugin";
2216
+
2217
+ // lib/session-model.ts
2218
+ function latestUserModel(messages) {
2219
+ if (!Array.isArray(messages)) return null;
2220
+ for (let index = messages.length - 1; index >= 0; index--) {
2221
+ const info = messages[index]?.info;
2222
+ if (info?.role !== "user") continue;
2223
+ const providerID = info.model?.providerID;
2224
+ const modelID = info.model?.modelID;
2225
+ if (typeof providerID === "string" && typeof modelID === "string") {
2226
+ return { providerID, modelID };
2227
+ }
2228
+ }
2229
+ return null;
2230
+ }
2231
+ async function resolveSessionModel(client, sessionID) {
2232
+ try {
2233
+ const response = await client.session.messages({ path: { id: sessionID } });
2234
+ return latestUserModel(response.data ?? response);
2235
+ } catch {
2236
+ return null;
2237
+ }
2238
+ }
2239
+
2240
+ // lib/prune-service.ts
2241
+ var PruneService = class {
2242
+ deferred = /* @__PURE__ */ new Set();
2243
+ client;
2244
+ summarize;
2245
+ logger;
2246
+ probeTimeoutMs;
2247
+ /** THE single busy/idle event state machine (absorbed the former
2248
+ * SessionActivityTracker — no second busy cache may exist). */
2249
+ boundary;
2250
+ constructor(deps) {
2251
+ this.client = deps.client;
2252
+ this.summarize = deps.summarize;
2253
+ this.logger = deps.logger;
2254
+ this.probeTimeoutMs = deps.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
2255
+ this.boundary = new SessionBoundaryTracker({
2256
+ probeBusy: (sessionID) => this.probeBusy(sessionID),
2257
+ logger: deps.logger,
2258
+ now: deps.now,
2259
+ setTimer: deps.setTimer,
2260
+ clearTimer: deps.clearTimer
2261
+ });
2262
+ this.boundary.onAtRest((sessionID) => {
2263
+ if (!this.deferred.delete(sessionID)) return;
2264
+ void this.drainQueued(sessionID);
2265
+ });
2266
+ }
2267
+ /**
2268
+ * Feed every host event through here. `session.status` and legacy
2269
+ * `session.idle` feed the boundary tracker (quiet window + expiry probe);
2270
+ * the deferral queue drains at the confirmed at-rest classification, and
2271
+ * queued prunes are forgotten once a compaction (or the session itself)
2272
+ * is gone.
2273
+ */
2274
+ observeEvent(type, properties) {
2275
+ const sessionID = eventSessionID(properties);
2276
+ if (type === "session.status") {
2277
+ this.boundary.observeStatus(sessionID, properties?.status?.type);
2278
+ return;
2279
+ }
2280
+ if (type === "session.idle") {
2281
+ this.boundary.observeLegacyIdle(sessionID);
2282
+ return;
2283
+ }
2284
+ if (!sessionID) return;
2285
+ if (type === "session.compacted") {
2286
+ this.deferred.delete(sessionID);
2287
+ this.boundary.observeCompacted(sessionID);
2288
+ return;
2289
+ }
2290
+ if (type === "session.deleted") {
2291
+ this.deferred.delete(sessionID);
2292
+ this.boundary.observeDeleted(sessionID);
2293
+ }
2294
+ }
2295
+ async request(request) {
2296
+ const beforeModel = await this.gate(request);
2297
+ if (beforeModel) return beforeModel;
2298
+ const model = await resolveSessionModel(this.client, request.sessionID);
2299
+ if (!model) return { status: "no-model" };
2300
+ const afterModel = await this.gate(request);
2301
+ if (afterModel) return afterModel;
2302
+ const serverBusy = await this.probeBusy(request.sessionID);
2303
+ if (serverBusy === true) {
2304
+ const fallback = await this.outcomeFor(request, { action: "stand-down" });
2305
+ if (fallback) return fallback;
2306
+ }
2307
+ const result = await this.summarize.summarize({ sessionID: request.sessionID, model });
2308
+ if (result.status === "rejected") {
2309
+ return { status: "busy" };
2310
+ }
2311
+ return result;
2312
+ }
2313
+ /** One admission check; `null` means the request may proceed. */
2314
+ gate(request) {
2315
+ return this.outcomeFor(request, this.admit(request));
2316
+ }
2317
+ /**
2318
+ * Executes a queued prune at the confirmed at-rest boundary. The tool
2319
+ * already promised its caller this prune would run; losing the busy race
2320
+ * must not silently break that promise, so a busy outcome re-queues for
2321
+ * the next at-rest boundary. Every other outcome is terminal: it is
2322
+ * logged and never retried — new prune demand arrives through new
2323
+ * triggers only. Execution is re-guarded on every attempt, so re-queueing
2324
+ * never violates the never-interrupt invariant.
2325
+ */
2326
+ async drainQueued(sessionID) {
2327
+ let outcome;
2328
+ try {
2329
+ outcome = await this.request({ sessionID, onBusy: "proceed" });
2330
+ } catch (error) {
2331
+ this.logger.warn("Queued prune drain failed; the prune was not retried", {
2332
+ sessionId: sessionID,
2333
+ error: error instanceof Error ? error.message : String(error)
2334
+ });
2335
+ return;
2336
+ }
2337
+ if (outcome.status === "busy") {
2338
+ this.deferred.add(sessionID);
2339
+ return;
2340
+ }
2341
+ this.logger.debug("Queued prune drain finished", {
2342
+ sessionId: sessionID,
2343
+ status: outcome.status
2344
+ });
2345
+ }
2346
+ /**
2347
+ * Turns an admission decision into a terminal outcome, or `null` when the
2348
+ * request may proceed. Deferral additionally enqueues the session.
2349
+ */
2350
+ async outcomeFor(request, admission) {
2351
+ if (admission.action === "go") return null;
2352
+ if (admission.action === "stand-down") return { status: "busy" };
2353
+ this.deferred.add(request.sessionID);
2354
+ this.logger.debug("Prune deferred to the next session at-rest boundary", {
2355
+ sessionId: request.sessionID
2356
+ });
2357
+ return { status: "deferred" };
2358
+ }
2359
+ admit(request) {
2360
+ if (request.onBusy === "defer") return { action: "defer" };
2361
+ if (this.boundary.state(request.sessionID) === "busy") return { action: "stand-down" };
2362
+ return { action: "go" };
2363
+ }
2364
+ /**
2365
+ * THE single live busy probe, reused verbatim by the boundary tracker's
2366
+ * expiry check. Bounded by a finite deadline: a never-returning probe
2367
+ * resolves `null` within the timeout (fail-open). Returns `null` when the
2368
+ * answer is unknown (endpoint or SDK method missing, request failed,
2369
+ * timeout) — callers must fail open in that case.
2370
+ */
2371
+ async probeBusy(sessionID) {
2372
+ const statusFn = this.client.session.status;
2373
+ if (typeof statusFn !== "function") return null;
2374
+ const query = (async () => {
2375
+ try {
2376
+ return await statusFn.call(
2377
+ this.client.session
2378
+ );
2379
+ } catch {
2380
+ return null;
2381
+ }
2382
+ })();
2383
+ const response = await Promise.race([
2384
+ query,
2385
+ new Promise((resolve) => setTimeout(() => resolve(null), this.probeTimeoutMs))
2386
+ ]);
2387
+ const map = response?.data ?? response;
2388
+ const info = map?.[sessionID];
2389
+ return info?.type === "busy" || info?.type === "retry";
2390
+ }
2391
+ };
2392
+
2393
+ // lib/prune-tool.ts
1938
2394
  var PRUNE_TOOL_NAME = "dcp_prune";
1939
- var PRUNE_TOOL_DESCRIPTION = `\u7ACB\u5373\u5BF9\u5F53\u524D\u4F1A\u8BDD\u6267\u884C\u8BED\u4E49\u4E0A\u4E0B\u6587\u538B\u7F29\uFF1A\u628A\u65E7\u5BF9\u8BDD\u524D\u7F00\u6298\u53E0\u4E3A\u4E00\u4E2A\u6EDA\u52A8\u68C0\u67E5\u70B9\uFF08\u4FDD\u7559\u7CFB\u7EDF\u7EA7\u89C4\u5219\u3001\u538B\u7F29\u4E2D\u90E8\u5386\u53F2\u3001\u8BE6\u8FF0\u8FDB\u884C\u4E2D\u7684\u4EFB\u52A1\uFF09\uFF0C\u8FD1\u671F\u5C3E\u90E8\u4E0D\u53D7\u5F71\u54CD\u3002
2395
+ var PRUNE_TOOL_DESCRIPTION = `\u628A\u5F53\u524D\u4F1A\u8BDD\u7684\u65E7\u5BF9\u8BDD\u524D\u7F00\u6298\u53E0\u4E3A\u4E00\u4E2A\u6EDA\u52A8\u68C0\u67E5\u70B9\uFF08\u4FDD\u7559\u7CFB\u7EDF\u7EA7\u89C4\u5219\u3001\u538B\u7F29\u4E2D\u90E8\u5386\u53F2\u3001\u8BE6\u8FF0\u8FDB\u884C\u4E2D\u7684\u4EFB\u52A1\uFF09\uFF0C\u8FD1\u671F\u5C3E\u90E8\u4E0D\u53D7\u5F71\u54CD\u3002
1940
2396
 
1941
- \u6EE1\u8DB3\u4EFB\u4E00\u542F\u53D1\u5F0F\u6761\u4EF6\u65F6\uFF0C\u5FC5\u987B\u7ACB\u5373\u8C03\u7528\u672C\u5DE5\u5177\uFF1A
2397
+ \u4EC5\u5728\u8FD9\u4E9B\u60C5\u51B5\u4E0B\u8C03\u7528\uFF1A
1942
2398
  - \u5BF9\u8BDD\u8BDD\u9898\u53D1\u751F\u660E\u663E\u53D8\u66F4\uFF1A\u5F00\u59CB\u5904\u7406\u65B0\u7684\u95EE\u9898\u57DF\u3001\u5207\u6362\u5230\u53E6\u4E00\u4E2A\u6A21\u5757/\u4ED3\u5E93/\u4EFB\u52A1\uFF1B
1943
- - \u5F53\u524D\u4EFB\u52A1\u521A\u6536\u5C3E\u5B8C\u6210\uFF0C\u5373\u5C06\u5F00\u542F\u4E0B\u4E00\u9879\u5DE5\u4F5C\uFF1B
1944
- - \u5BF9\u8BDD\u8F6E\u6570\u6216\u4E0A\u4E0B\u6587\u660E\u663E\u53D8\u957F\uFF0C\u65E9\u671F\u7EC6\u8282\u5DF2\u4E0D\u9700\u8981\u9010\u5B57\u4FDD\u7559\u3002
2399
+ - \u7528\u6237\u660E\u786E\u8981\u6C42\u538B\u7F29\u4E0A\u4E0B\u6587\u3002
1945
2400
 
1946
- \u8C03\u7528\u662F\u5B89\u5168\u7684\uFF1A\u5E76\u53D1\u8BF7\u6C42\u4F1A\u81EA\u52A8\u5408\u5E76\uFF0C\u5931\u8D25\u4E0D\u4F1A\u7834\u574F\u73B0\u6709\u4E0A\u4E0B\u6587\u3002\u4E0D\u8981\u4E3A\u540C\u4E00\u8BDD\u9898\u53CD\u590D\u8FDE\u7EED\u8C03\u7528\u3002`;
2401
+ \u540C\u4E00\u4EFB\u52A1\u5185\u7684\u591A\u8F6E\u8FFD\u95EE\u3001\u53C2\u6570\u5FAE\u8C03\u3001\u5EF6\u7EED\u5F53\u524D\u5DE5\u4F5C\uFF0C\u90FD\u4E0D\u8981\u8C03\u7528\u3002\u8C03\u7528\u4E0D\u4F1A\u6253\u65AD\u5F53\u524D\u5DE5\u4F5C\uFF1A\u538B\u7F29\u4F1A\u6392\u961F\uFF0C\u5E76\u5728\u4E0B\u4E00\u4E2A\u786E\u8BA4\u7684\u9759\u606F\u8FB9\u754C\uFF08\u77ED\u6682\u9759\u9ED8\u7A97\u53E3\u52A0\u5B9E\u65F6\u72B6\u6001\u68C0\u67E5\uFF09\u5C1D\u8BD5\u6267\u884C\uFF1B\u5E76\u53D1\u8BF7\u6C42\u81EA\u52A8\u5408\u5E76\uFF0C\u5931\u8D25\u4E0D\u4F1A\u7834\u574F\u73B0\u6709\u4E0A\u4E0B\u6587\u3002\u4E0D\u8981\u4E3A\u540C\u4E00\u8BDD\u9898\u53CD\u590D\u8C03\u7528\u3002`;
1947
2402
  function createPruneTool(deps) {
1948
2403
  return tool({
1949
2404
  description: PRUNE_TOOL_DESCRIPTION,
1950
2405
  args: {},
1951
2406
  execute: async (_args, context) => {
1952
2407
  const sessionID = context.sessionID;
1953
- const model = await resolveSessionModel(deps.client, sessionID);
1954
- if (!model) {
1955
- return "DCP\uFF1A\u4F1A\u8BDD\u4E2D\u8FD8\u6CA1\u6709\u53EF\u7528\u7684\u6A21\u578B\u4FE1\u606F\uFF0C\u65E0\u6CD5\u6267\u884C\u538B\u7F29\u3002";
1956
- }
1957
- const result = await deps.summarize.summarize({ sessionID, model });
2408
+ const result = await deps.prune.request({ sessionID, onBusy: "defer" });
1958
2409
  if (result.status === "succeeded") {
1959
2410
  deps.logger.debug("Prune tool triggered native compaction", {
1960
2411
  sessionId: sessionID
1961
2412
  });
1962
2413
  return "DCP\uFF1A\u8BED\u4E49\u538B\u7F29\u5B8C\u6210\uFF0C\u65E7\u4E0A\u4E0B\u6587\u5DF2\u6298\u53E0\u4E3A\u65B0\u68C0\u67E5\u70B9\u3002";
1963
2414
  }
2415
+ if (result.status === "deferred") {
2416
+ return "DCP\uFF1A\u4F1A\u8BDD\u4ECD\u5728\u5DE5\u4F5C\u4E2D\uFF0C\u538B\u7F29\u5DF2\u6392\u961F\uFF0C\u5C06\u5728\u4E0B\u4E00\u4E2A\u786E\u8BA4\u7684\u9759\u606F\u8FB9\u754C\u5C1D\u8BD5\u81EA\u52A8\u6267\u884C\uFF1B\u5F53\u524D\u4E0A\u4E0B\u6587\u4E0D\u53D7\u5F71\u54CD\u3002";
2417
+ }
2418
+ if (result.status === "busy") {
2419
+ return "DCP\uFF1A\u4F1A\u8BDD\u6B63\u5FD9\uFF0C\u4E3A\u907F\u514D\u6253\u65AD\u5F53\u524D\u5DE5\u4F5C\uFF0C\u672C\u6B21\u672A\u6267\u884C\u538B\u7F29\u3002";
2420
+ }
1964
2421
  if (result.status === "cooldown") {
1965
- return `DCP\uFF1A\u4E0A\u4E00\u6B21\u538B\u7F29\u5931\u8D25\uFF0C${Math.ceil(result.retryAfterMs / 1e3)} \u79D2\u540E\u624D\u80FD\u91CD\u8BD5\u3002`;
2422
+ return `DCP\uFF1A\u4E0A\u4E00\u6B21\u538B\u7F29\u5931\u8D25\uFF0C${retrySeconds(result.retryAfterMs)} \u79D2\u540E\u624D\u80FD\u91CD\u8BD5\u3002`;
2423
+ }
2424
+ if (result.status === "no-model") {
2425
+ return "DCP\uFF1A\u4F1A\u8BDD\u4E2D\u8FD8\u6CA1\u6709\u53EF\u7528\u7684\u6A21\u578B\u4FE1\u606F\uFF0C\u65E0\u6CD5\u6267\u884C\u538B\u7F29\u3002";
1966
2426
  }
1967
2427
  return `DCP\uFF1A\u538B\u7F29\u5931\u8D25\uFF08${result.error}\uFF09\uFF0C\u539F\u59CB\u4E0A\u4E0B\u6587\u4FDD\u6301\u4E0D\u53D8\u3002`;
1968
2428
  }
@@ -1979,6 +2439,16 @@ function errorMessage(error) {
1979
2439
  return "Unknown native compaction error";
1980
2440
  }
1981
2441
  }
2442
+ function isBusyRejection(error) {
2443
+ if (error instanceof Error && /\bbusy\b/i.test(error.message)) return true;
2444
+ if (typeof error === "string" && /\bbusy\b/i.test(error)) return true;
2445
+ const structured = error;
2446
+ if (!structured || typeof structured !== "object") return false;
2447
+ if (structured.status === 409 || structured.statusCode === 409 || structured.code === 409) {
2448
+ return true;
2449
+ }
2450
+ return typeof structured.name === "string" && /busy/i.test(structured.name);
2451
+ }
1982
2452
  var SummarizeCoordinator = class {
1983
2453
  constructor(client, logger, options) {
1984
2454
  this.client = client;
@@ -2014,12 +2484,19 @@ var SummarizeCoordinator = class {
2014
2484
  path: { id: request.sessionID },
2015
2485
  body: request.model
2016
2486
  });
2017
- if (response?.error || response?.data !== true) {
2018
- throw new Error(errorMessage(response?.error ?? "Native summarize returned false"));
2487
+ const nativeError = response?.error;
2488
+ if (nativeError && isBusyRejection(nativeError)) {
2489
+ return { status: "rejected", reason: "busy" };
2490
+ }
2491
+ if (nativeError || response?.data !== true) {
2492
+ throw new Error(errorMessage(nativeError ?? "Native summarize returned false"));
2019
2493
  }
2020
2494
  this.failedAt.delete(request.sessionID);
2021
2495
  return { status: "succeeded" };
2022
2496
  } catch (error) {
2497
+ if (isBusyRejection(error)) {
2498
+ return { status: "rejected", reason: "busy" };
2499
+ }
2023
2500
  this.failedAt.set(request.sessionID, this.now());
2024
2501
  const message = errorMessage(error);
2025
2502
  await this.logger.warn("Native summarize failed; context remains unchanged", {
@@ -2180,7 +2657,19 @@ var server = (async (ctx) => {
2180
2657
  const summarize = new SummarizeCoordinator(ctx.client, logger, {
2181
2658
  failureCooldownMs: config.summarize.failureCooldownMs
2182
2659
  });
2660
+ const prune = new PruneService({ client: ctx.client, summarize, logger });
2183
2661
  const autoPruner = new AutoPruner(config.autoPrune);
2662
+ if (config.autoPrune.enabled) {
2663
+ prune.boundary.onAtRest(
2664
+ createAtRestAutoPruneListener({
2665
+ client: ctx.client,
2666
+ prune,
2667
+ autoPruner,
2668
+ config: config.autoPrune,
2669
+ logger
2670
+ })
2671
+ );
2672
+ }
2184
2673
  logger.info("DCP initialized", {
2185
2674
  commands: config.commands.enabled,
2186
2675
  autoPrune: config.autoPrune.enabled,
@@ -2191,20 +2680,23 @@ var server = (async (ctx) => {
2191
2680
  return {
2192
2681
  "experimental.session.compacting": createSessionCompactingHandler(prompts, logger),
2193
2682
  ...config.autoPrune.enabled && {
2194
- "chat.message": createChatMessageHandler(autoPruner),
2683
+ "chat.message": createChatMessageHandler(autoPruner)
2684
+ },
2685
+ // The event feed drives both the boundary classification (status/idle
2686
+ // observations) and the tool's deferred prunes, so it stays registered
2687
+ // whenever either surface is on.
2688
+ ...(config.autoPrune.enabled || config.tool.enabled) && {
2195
2689
  event: createEventHandler({
2196
- client: ctx.client,
2197
- summarize,
2690
+ prune,
2198
2691
  autoPruner,
2199
- config: config.autoPrune,
2200
2692
  logger
2201
2693
  })
2202
2694
  },
2203
2695
  ...config.tool.enabled && {
2204
- tool: { [PRUNE_TOOL_NAME]: createPruneTool({ client: ctx.client, summarize, logger }) }
2696
+ tool: { [PRUNE_TOOL_NAME]: createPruneTool({ prune, logger }) }
2205
2697
  },
2206
2698
  ...config.commands.enabled && {
2207
- "command.execute.before": createCommandExecuteHandler(ctx.client, summarize, logger),
2699
+ "command.execute.before": createCommandExecuteHandler(ctx.client, prune, logger),
2208
2700
  config: async (opencodeConfig) => {
2209
2701
  opencodeConfig.command ??= {};
2210
2702
  opencodeConfig.command.dcp = {