opencode-lossless 0.1.2 → 0.1.3

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.mjs CHANGED
@@ -1,9 +1,9 @@
1
+ import { appendFileSync, chmodSync, existsSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, posix, resolve, win32 } from "node:path";
1
3
  import { fileURLToPath } from "node:url";
2
- import { DEFAULT_RETRIEVAL_TOKENS, HEAL_SOURCE_MAX_TOKENS, LcmStore, MAX_RETRIEVAL_TOKENS, MIN_RETRIEVAL_TOKENS, NO_STORE, RECALL_SCOPES, SCHEMA_VERSION, SUMMARIZER_CALL_TOKENS, SUMMARIZER_HEAL_TOKENS, SUMMARIZER_PASS_TOKENS, SidecarStores, appendMetricRow, applyContextPolicy, buildDiagnoseBundle, buildSynthetic, checkIntegrity, contextChars, createWindowLookup, cutIndexFor, cutMayReplace, depthStats, describeAnswer, effectiveThresholds, expandAnswer, expandQueryOutcome, exportTranscript, grepAnswer, grepView, importTranscript, ingestEntries, makeLlm, makeRedactConfigAdapter, mergeZone, modelKey, modelKey as modelKey$1, nextAction, normalizeRole, packageVersionNear, parseExport, readMetricRows, renderSummaries, renderableFrontier, repairIntegrity, runCompaction, runHeal, sanitizeSidecarId, shouldKickBoundary, storeGeneration, summarizerConfig, tailKeyOf, tokensFromChars } from "lossless-core";
4
+ import { DEFAULT_RETRIEVAL_TOKENS, HEAL_SOURCE_MAX_TOKENS, LcmStore, MAX_RETRIEVAL_TOKENS, MIN_RETRIEVAL_TOKENS, NO_STORE, RECALL_SCOPES, SCHEMA_VERSION, SUMMARIZER_CALL_TOKENS, SUMMARIZER_HEAL_TOKENS, SUMMARIZER_PASS_TOKENS, SidecarStores, StaleStoreError, appendMetricRow, applyContextPolicy, buildDiagnoseBundle, buildSynthetic, checkIntegrity, contextChars, createWindowLookup, cutIndexFor, cutMayReplace, depthStats, describeAnswer, describeView, effectiveThresholds, expandAnswer, expandQueryOutcome, expandView, exportTranscript, grepAnswer, grepView, historyManifest, importTranscript, ingestEntries, makeLlm, makeRedactConfigAdapter, mergeZone, modelKey, modelKey as modelKey$1, nextAction, normalizeRole, packageVersionNear, parseExport, readMetricRows, renderSummaries, renderableFrontier, repairIntegrity, runCompaction, runHeal, sanitizeSidecarId, shouldKickBoundary, storeGeneration, summarizerConfig, tailKeyOf, tokensFromChars } from "lossless-core";
3
5
  import { createHash, randomUUID } from "node:crypto";
4
- import { appendFileSync, chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
6
  import { homedir } from "node:os";
6
- import { basename, join, resolve } from "node:path";
7
7
  // src/command.ts
8
8
  /** The `lossless-status` command: report what the sidecar holds for this
9
9
  * session. A command cannot return text to the user directly, so the status
@@ -770,10 +770,11 @@ async function runLosslessPass(deps, sessionID, entries, opts = {}) {
770
770
  }, llm, {
771
771
  redact: makeRedactConfigAdapter({}),
772
772
  session: sessionID,
773
- stage
773
+ stage,
774
+ ...opts.summarizer?.mechanicalTools === void 0 ? {} : { mechanicalTools: opts.summarizer.mechanicalTools }
774
775
  });
775
776
  if (outcome.kind === "stored") {
776
- if (tally.answers === 0) throw new Error("lcm: the summarizer did not answer, so nothing was stored");
777
+ if (tally.answers === 0 && !isAllMechanical(outcome)) throw new Error("lcm: the summarizer did not answer, so nothing was stored");
777
778
  store.commitRun(token);
778
779
  record({
779
780
  outcome: "stored",
@@ -806,6 +807,17 @@ async function runLosslessPass(deps, sessionID, entries, opts = {}) {
806
807
  store.abandonRun(token);
807
808
  }
808
809
  }
810
+ /** Every stored node came from the configured mechanical path, so zero
811
+ * model answers is the expected shape, not a failed summarizer. Level-3
812
+ * fallback leaves carry mechanical quality but are not counted in the
813
+ * engine totals, so they still throw. */
814
+ function isAllMechanical(outcome) {
815
+ if (outcome.kind !== "stored") return false;
816
+ const leaves = outcome.leafSummaryIds.length;
817
+ const condensed = outcome.condensedSummaryIds.length;
818
+ if (leaves + condensed === 0) return false;
819
+ return leaves === (outcome.mechanicalLeaves ?? 0) && condensed === (outcome.mechanicalCondensed ?? 0);
820
+ }
809
821
  function messageOf$3(error) {
810
822
  return (error instanceof Error ? error.message : String(error)).slice(0, 300);
811
823
  }
@@ -925,7 +937,10 @@ function createCheckpointWriter(deps) {
925
937
  targetTokens: deps.options.summaryTokens,
926
938
  stage: "blocking",
927
939
  ...previousText === void 0 ? {} : { previousSummary: previousText },
928
- summarizer: summarizerConfig(deps.options)
940
+ summarizer: {
941
+ ...summarizerConfig(deps.options),
942
+ ...deps.options.mechanicalTools === void 0 ? {} : { mechanicalTools: deps.options.mechanicalTools }
943
+ }
929
944
  });
930
945
  } catch (error) {
931
946
  fail("pass-failed", error);
@@ -1070,7 +1085,10 @@ function createContextProjector(deps) {
1070
1085
  targetTokens: deps.options.summaryTokens,
1071
1086
  stage: "async",
1072
1087
  ...previousSummary === void 0 ? {} : { previousSummary },
1073
- summarizer: summarizerConfig(deps.options)
1088
+ summarizer: {
1089
+ ...summarizerConfig(deps.options),
1090
+ ...deps.options.mechanicalTools === void 0 ? {} : { mechanicalTools: deps.options.mechanicalTools }
1091
+ }
1074
1092
  }).then(() => void 0).catch(() => {
1075
1093
  const boundary = state.pendingSpan?.lastEntryId ?? span.lastEntryId;
1076
1094
  const current = state.failedBoundaries.get(boundary);
@@ -1364,12 +1382,253 @@ function syntheticMessage(text) {
1364
1382
  }]
1365
1383
  };
1366
1384
  }
1385
+ // src/default-sidecar-dir.ts
1386
+ /** This path stays relative. It resolves from the server working directory. */
1387
+ const FALLBACK_SIDECAR_DIR = "./.lossless";
1388
+ function nonBlank(value) {
1389
+ if (value === void 0) return void 0;
1390
+ const trimmed = value.trim();
1391
+ return trimmed.length === 0 ? void 0 : trimmed;
1392
+ }
1393
+ function isAbsoluteForPlatform(path, platform) {
1394
+ return platform === "win32" ? win32.isAbsolute(path) : posix.isAbsolute(path);
1395
+ }
1396
+ /** Pure resolver for the default sidecar directory. Only absolute env dirs win; anything else falls through toward home. */
1397
+ function resolveDefaultSidecarDir(input) {
1398
+ const env = input.env;
1399
+ const xdg = nonBlank(env["XDG_DATA_HOME"]);
1400
+ if (xdg !== void 0 && isAbsoluteForPlatform(xdg, input.platform)) return {
1401
+ dir: join(xdg, "opencode", "lossless"),
1402
+ source: "default:XDG_DATA_HOME"
1403
+ };
1404
+ const home = nonBlank(input.homedir);
1405
+ if (home !== void 0) {
1406
+ if (input.platform === "win32") {
1407
+ const local = nonBlank(env["LOCALAPPDATA"]);
1408
+ if (local !== void 0 && isAbsoluteForPlatform(local, input.platform)) return {
1409
+ dir: join(local, "opencode", "lossless"),
1410
+ source: "default:LOCALAPPDATA"
1411
+ };
1412
+ const roaming = nonBlank(env["APPDATA"]);
1413
+ if (roaming !== void 0 && isAbsoluteForPlatform(roaming, input.platform)) return {
1414
+ dir: join(roaming, "opencode", "lossless"),
1415
+ source: "default:APPDATA"
1416
+ };
1417
+ return {
1418
+ dir: join(home, "AppData", "Local", "opencode", "lossless"),
1419
+ source: "default:home"
1420
+ };
1421
+ }
1422
+ return {
1423
+ dir: join(home, ".local", "share", "opencode", "lossless"),
1424
+ source: "default:home"
1425
+ };
1426
+ }
1427
+ return {
1428
+ dir: FALLBACK_SIDECAR_DIR,
1429
+ source: "fallback"
1430
+ };
1431
+ }
1432
+ /** Resolve the default from the live process. Never throws: anything odd falls back to {@link FALLBACK_SIDECAR_DIR}. */
1433
+ function defaultSidecarDir() {
1434
+ try {
1435
+ return resolveDefaultSidecarDir({
1436
+ env: process.env,
1437
+ homedir: homedir(),
1438
+ platform: process.platform
1439
+ });
1440
+ } catch {
1441
+ return {
1442
+ dir: FALLBACK_SIDECAR_DIR,
1443
+ source: "fallback"
1444
+ };
1445
+ }
1446
+ }
1447
+ // src/sidecar-dump.ts
1448
+ /** Max bytes one dump prints; callers copy the file when they need more. */
1449
+ const DUMP_MAX_BYTES = 16384;
1450
+ /** Bytes kept from each described summary; the tail names the cut. */
1451
+ const DUMP_MAX_SUMMARY_BYTES = 1200;
1452
+ /** Read-only dump of one session sidecar plus its metrics. Opens the file
1453
+ * with `queryOnly`, so SQLite refuses writes, and closes it before returning.
1454
+ * An older generation reports a named skip instead of migrating. */
1455
+ function dumpSidecar(options) {
1456
+ if (!existsSync(options.path)) return {
1457
+ status: "skipped",
1458
+ reason: "missing",
1459
+ detail: `no sidecar at ${basename(options.path)}`
1460
+ };
1461
+ let store;
1462
+ try {
1463
+ store = new LcmStore(options.path, { queryOnly: true });
1464
+ } catch (error) {
1465
+ if (error instanceof StaleStoreError) return {
1466
+ status: "skipped",
1467
+ reason: "older-generation",
1468
+ detail: `generation ${error.generation} is older than this build. Run migrate first. No rows were changed.`
1469
+ };
1470
+ return {
1471
+ status: "skipped",
1472
+ reason: "unreadable",
1473
+ detail: error instanceof Error ? error.message.slice(0, 200) : String(error)
1474
+ };
1475
+ }
1476
+ try {
1477
+ return {
1478
+ status: "dumped",
1479
+ text: buildDump(store, options)
1480
+ };
1481
+ } catch (error) {
1482
+ return {
1483
+ status: "skipped",
1484
+ reason: "unreadable",
1485
+ detail: error instanceof Error ? error.message.slice(0, 200) : String(error)
1486
+ };
1487
+ } finally {
1488
+ try {
1489
+ store.close();
1490
+ } catch {}
1491
+ }
1492
+ }
1493
+ function buildDump(store, options) {
1494
+ const includeRemoved = options.includeRemoved === true;
1495
+ const stats = store.stats();
1496
+ const removed = stats.removedMessages > 0 ? ` (${stats.removedMessages} removed, retained)` : "";
1497
+ const head = [
1498
+ `LCM sidecar dump: ${options.sessionId}`,
1499
+ `store: ${basename(options.path)}, generation ${store.generation}, ${stats.messages} messages${removed}, ${stats.summaries} summaries, ${(stats.dbBytes / 1024).toFixed(0)} KB`,
1500
+ `history:`,
1501
+ historyManifest(store, 20)
1502
+ ];
1503
+ const summaries = store.allSummaries().slice(0, 10);
1504
+ const totalSummaries = store.allSummaries().length;
1505
+ const sections = [...head];
1506
+ if (summaries.length > 0) {
1507
+ sections.push(`summaries: ${summaries.length} shown of ${totalSummaries}`);
1508
+ for (const summary of summaries) {
1509
+ const view = describeView(store, options.redact, summary.id);
1510
+ sections.push(view.ok ? capSummaryText(view.text) : `Summary #${summary.id}: unreadable.`);
1511
+ }
1512
+ const first = summaries[0];
1513
+ const coveredIds = store.coveredMessageIds(first.id);
1514
+ const coveredMsgs = store.messagesByIds(coveredIds);
1515
+ const coveredIdSet = new Set(coveredMsgs.map((m) => m.entryId));
1516
+ const removedIds = new Set(coveredMsgs.filter((m) => m.removedAt !== void 0).map((m) => m.entryId));
1517
+ const visibleMsgs = includeRemoved ? coveredMsgs : coveredMsgs.filter((m) => m.removedAt === void 0);
1518
+ const list = expandView(store, options.redact, {
1519
+ mode: "list",
1520
+ id: first.id,
1521
+ offset: 0,
1522
+ limit: 3,
1523
+ maxChars: 400
1524
+ });
1525
+ sections.push(`messages (summary #${first.id}):`, list.ok ? shapeSampleList(list.text, first.id, coveredIdSet, removedIds, includeRemoved, visibleMsgs.length) : "No messages.");
1526
+ for (const message of visibleMsgs.slice(0, 3)) {
1527
+ const window = expandView(store, options.redact, {
1528
+ mode: "message",
1529
+ entryId: message.entryId,
1530
+ charOffset: 0,
1531
+ maxChars: 400
1532
+ });
1533
+ sections.push(window.ok ? window.text : `No stored message ${message.entryId}.`);
1534
+ }
1535
+ } else sections.push("summaries: none stored yet.");
1536
+ if (options.sampleQuery !== void 0 && options.sampleQuery.length > 0) {
1537
+ const found = grepView(store, options.redact, {
1538
+ query: options.sampleQuery,
1539
+ limit: 3,
1540
+ includeRemoved
1541
+ });
1542
+ sections.push(`search ${JSON.stringify(options.sampleQuery)}${includeRemoved ? " (including removed)" : ""}:`, found.text);
1543
+ }
1544
+ const rows = (options.metricRows ?? []).filter((row) => row.session === options.sessionId);
1545
+ const shown = rows.slice(Math.max(0, rows.length - 20));
1546
+ sections.push(`metrics: ${rows.length} row(s) for this session, ${shown.length} shown`);
1547
+ for (const row of shown) sections.push(` ${JSON.stringify(sanitizeRow(row, options.redact))}`);
1548
+ sections.push(`omitted: summary text past ${DUMP_MAX_SUMMARY_BYTES} bytes each, message bodies past 400 chars per sample, summaries past 10, metric rows past 20. Removed rows carry [removed]. Read them with include_removed.`);
1549
+ const text = options.redact(sections.join("\n"));
1550
+ if (Buffer.byteLength(text, "utf8") <= 16384) return text;
1551
+ return clip(text);
1552
+ }
1553
+ /** Reshape the sampled list for the dump: removed rows stay out by default
1554
+ * and carry a [removed] label when --include-removed keeps them. Untouched
1555
+ * when no covered row left the active path. */
1556
+ function shapeSampleList(text, summaryId, coveredIds, removedIds, includeRemoved, visibleTotal) {
1557
+ if (removedIds.size === 0) return text;
1558
+ const groups = [];
1559
+ for (const chunk of text.split("\n\n").slice(1)) {
1560
+ const at = chunk.indexOf("\n");
1561
+ const first = at === -1 ? chunk : chunk.slice(0, at);
1562
+ const id = /^\[([^\]]+)\] \((?:user|assistant|toolResult|custom)\)$/.exec(first)?.[1];
1563
+ if (id !== void 0 && coveredIds.has(id)) groups.push({
1564
+ id,
1565
+ chunks: [chunk]
1566
+ });
1567
+ else if (groups.length > 0) groups[groups.length - 1].chunks.push(chunk);
1568
+ else groups.push({
1569
+ id: void 0,
1570
+ chunks: [chunk]
1571
+ });
1572
+ }
1573
+ const kept = [];
1574
+ let shown = 0;
1575
+ for (const group of groups) {
1576
+ const removed = group.id !== void 0 && removedIds.has(group.id);
1577
+ if (removed && !includeRemoved) continue;
1578
+ if (group.id !== void 0) shown++;
1579
+ kept.push(group.chunks.map((chunk, index) => index === 0 && removed ? labelRemoved(chunk) : chunk).join("\n\n"));
1580
+ }
1581
+ return [`Summary #${summaryId} → ${visibleTotal} original messages (showing ${shown} from 0)${visibleTotal > shown ? ", use offset to page" : ""}`, ...kept].join("\n\n");
1582
+ }
1583
+ /** Mark one kept list row as off the active path, grepView style. */
1584
+ function labelRemoved(chunk) {
1585
+ const at = chunk.indexOf("\n");
1586
+ return at === -1 ? `${chunk} [removed]` : `${chunk.slice(0, at)} [removed]${chunk.slice(at)}`;
1587
+ }
1588
+ /** Keep one described summary bounded so large summaries cannot clip the tail. */
1589
+ function capSummaryText(text) {
1590
+ if (Buffer.byteLength(text, "utf8") <= 1200) return text;
1591
+ let used = 0;
1592
+ let end = 0;
1593
+ for (const chunk of text) {
1594
+ const cost = Buffer.byteLength(chunk, "utf8");
1595
+ if (used + cost > 1200) break;
1596
+ used += cost;
1597
+ end += chunk.length;
1598
+ }
1599
+ const more = Buffer.byteLength(text, "utf8") - used;
1600
+ return `${text.slice(0, end)}\n[lcm:more ${more} bytes]`;
1601
+ }
1602
+ /** Redact string seams in one metric row; keys and stub pointers stay intact. */
1603
+ function sanitizeRow(row, redact) {
1604
+ const out = {};
1605
+ for (const [key, value] of Object.entries(row)) out[key] = sanitizeValue(value, redact);
1606
+ return out;
1607
+ }
1608
+ function sanitizeValue(value, redact) {
1609
+ if (typeof value === "string") return redact(value);
1610
+ if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, redact));
1611
+ if (typeof value === "object" && value !== null) {
1612
+ const out = {};
1613
+ for (const [key, item] of Object.entries(value)) out[key] = sanitizeValue(item, redact);
1614
+ return out;
1615
+ }
1616
+ return value;
1617
+ }
1618
+ function clip(text) {
1619
+ const marker = `... truncated at the ${DUMP_MAX_BYTES} byte limit`;
1620
+ const budget = DUMP_MAX_BYTES - Buffer.byteLength(marker, "utf8") - 1;
1621
+ let kept = "";
1622
+ let used = 0;
1623
+ for (const line of text.split("\n")) {
1624
+ const cost = Buffer.byteLength(line, "utf8") + 1;
1625
+ if (used + cost > budget) break;
1626
+ kept += (kept.length === 0 ? "" : "\n") + line;
1627
+ used += cost;
1628
+ }
1629
+ return `${kept}\n${marker}`;
1630
+ }
1367
1631
  // src/lifecycle.ts
1368
- /** Store lifecycle commands for the OpenCode adapter: backup, export, import,
1369
- * diagnose, migrate, heal, and a manual gc. They all read the same per-session
1370
- * sidecars the recall tools use, so they are registered with or without
1371
- * `assembly`. The cockpit is not here in V2 scope: the host exposes a
1372
- * TUI extension point, so a separate TUI plugin could reuse the same command core later. */
1373
1632
  const STAMP = () => (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1374
1633
  function argsOf(text) {
1375
1634
  const tokens = text.trim().split(/\s+/).filter(Boolean);
@@ -1392,6 +1651,47 @@ function chmodFile(path) {
1392
1651
  chmodSync(path, 384);
1393
1652
  } catch {}
1394
1653
  }
1654
+ function writeDumpTarget(target, text) {
1655
+ const dir = dirname(target);
1656
+ const base = basename(target);
1657
+ let tmp = "";
1658
+ for (let attempt = 0; attempt < 5; attempt++) {
1659
+ const candidate = join(dir, `.${base}.${process.pid}.${Date.now()}.${Math.floor(Math.random() * 1e9)}.tmp`);
1660
+ try {
1661
+ writeFileSync(candidate, text, {
1662
+ encoding: "utf8",
1663
+ mode: 384,
1664
+ flag: "wx"
1665
+ });
1666
+ tmp = candidate;
1667
+ break;
1668
+ } catch (error) {
1669
+ if (error?.code === "EEXIST") continue;
1670
+ try {
1671
+ unlinkSync(candidate);
1672
+ } catch {}
1673
+ throw error;
1674
+ }
1675
+ }
1676
+ if (tmp === "") throw new Error(`LCM dump: cannot create temp file beside ${target}.`);
1677
+ try {
1678
+ chmodFile(tmp);
1679
+ renameSync(tmp, target);
1680
+ } catch (error) {
1681
+ try {
1682
+ unlinkSync(tmp);
1683
+ } catch {}
1684
+ throw error;
1685
+ }
1686
+ }
1687
+ function isAbsolutePath(value) {
1688
+ return value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
1689
+ }
1690
+ function maskSidecarDir(dir, home) {
1691
+ if (dir === home || dir.startsWith(`${home}/`)) return `~${dir.slice(home.length)}`;
1692
+ if (isAbsolutePath(dir)) return "<path>";
1693
+ return dir;
1694
+ }
1395
1695
  function isRecord(value) {
1396
1696
  return typeof value === "object" && value !== null;
1397
1697
  }
@@ -1491,7 +1791,14 @@ function diagnoseCommand(deps) {
1491
1791
  repairs = [`repair failed: ${error instanceof Error ? error.message : String(error)}`];
1492
1792
  }
1493
1793
  const stats = store.stats();
1494
- const integrity = checkIntegrity(store);
1794
+ const livePaths = deps.sidecars.ids().map((id) => deps.sidecars.pathFor(id));
1795
+ const currentPath = deps.sidecars.pathFor(input.sessionID);
1796
+ if (!livePaths.includes(currentPath)) livePaths.push(currentPath);
1797
+ const integrity = checkIntegrity(store, {
1798
+ sidecarDir: deps.sidecars.dir,
1799
+ liveSidecarPaths: livePaths,
1800
+ sidecarExtension: deps.sidecars.extension
1801
+ });
1495
1802
  let model;
1496
1803
  try {
1497
1804
  model = await deps.model?.(input.sessionID);
@@ -1567,6 +1874,7 @@ function diagnoseCommand(deps) {
1567
1874
  home: homedir()
1568
1875
  };
1569
1876
  const bundle = buildDiagnoseBundle(inputBundle);
1877
+ const body = `sidecar: ${maskSidecarDir(deps.sidecars.dir, inputBundle.home)} (${deps.options.sidecarSource ?? "explicit"})\n${bundle}`;
1570
1878
  if (args.includes("--save") && !valueOf(args, "--save")) {
1571
1879
  await deps.prompt(input.sessionID, "LCM diagnose --save: name the file to write, e.g. `/lossless-diagnose --save lossless-diagnose.txt`.");
1572
1880
  return;
@@ -1574,15 +1882,15 @@ function diagnoseCommand(deps) {
1574
1882
  const save = valueOf(args, "--save");
1575
1883
  if (save !== void 0) {
1576
1884
  const target = resolve(save);
1577
- writeFileSync(target, `${bundle}\n`, {
1885
+ writeFileSync(target, `${body}\n`, {
1578
1886
  encoding: "utf8",
1579
1887
  mode: 384
1580
1888
  });
1581
1889
  chmodFile(target);
1582
- await deps.prompt(input.sessionID, `LCM diagnose: wrote ${Buffer.byteLength(bundle, "utf8")} bytes to ${target} (mode 0600).`);
1890
+ await deps.prompt(input.sessionID, `LCM diagnose: wrote ${Buffer.byteLength(body, "utf8")} bytes to ${target} (mode 0600).`);
1583
1891
  return;
1584
1892
  }
1585
- await deps.prompt(input.sessionID, bundle);
1893
+ await deps.prompt(input.sessionID, body);
1586
1894
  } catch (error) {
1587
1895
  await deps.prompt(input.sessionID, `LCM diagnose failed: ${error instanceof Error ? error.message : String(error)}`);
1588
1896
  }
@@ -1647,13 +1955,39 @@ function migrateCommand(deps) {
1647
1955
  }
1648
1956
  };
1649
1957
  }
1958
+ const GC_GRACE_MS = 36e5;
1959
+ const GC_YOUNG_MS = 864e5;
1960
+ function maxSidecarMtime(path) {
1961
+ try {
1962
+ const stat = statSync(path);
1963
+ let mtimeMs = stat.mtimeMs;
1964
+ for (const suffix of ["-wal", "-shm"]) try {
1965
+ mtimeMs = Math.max(mtimeMs, statSync(`${path}${suffix}`).mtimeMs);
1966
+ } catch {}
1967
+ return {
1968
+ stat,
1969
+ mtimeMs
1970
+ };
1971
+ } catch {
1972
+ return;
1973
+ }
1974
+ }
1975
+ function gcYoungWarning(ids) {
1976
+ if (ids.length === 0) return void 0;
1977
+ const shown = ids.slice(0, 5).join(", ");
1978
+ return `Note: ${ids.length} sidecar(s) younger than 24 hour(s): ${shown}${ids.length > 5 ? ", ..." : ""}. Confirm no other opencode process is using them before --apply.`;
1979
+ }
1980
+ function gcSkippedLine(ids) {
1981
+ return `Skipped ${ids.length} sidecar(s) active within the last 60 minute(s). Pass --session <id> to target one explicitly: ${ids.join(", ")}.`;
1982
+ }
1650
1983
  function gcCommand(deps) {
1651
1984
  return {
1652
1985
  name: "lossless-gc",
1653
- description: "List old sidecars, or with --apply export and delete them. Never touches this session.",
1986
+ description: "List old sidecars, or with --apply export and delete them. It never touches this session.",
1654
1987
  execute: async (input) => {
1655
1988
  const args = argsOf(input.prompt.text);
1656
1989
  const apply = args.includes("--apply");
1990
+ const force = args.includes("--force");
1657
1991
  if (args.includes("--session") && valueOf(args, "--session") === void 0) {
1658
1992
  await deps.prompt(input.sessionID, "LCM gc: --session takes a session id.");
1659
1993
  return;
@@ -1663,10 +1997,14 @@ function gcCommand(deps) {
1663
1997
  return;
1664
1998
  }
1665
1999
  const sessionId = valueOf(args, "--session");
2000
+ if (force && sessionId === void 0) {
2001
+ await deps.prompt(input.sessionID, "LCM gc: --force needs --session <id>.");
2002
+ return;
2003
+ }
1666
2004
  const live = new Set(deps.sidecars.ids());
1667
2005
  const liveStems = new Set([...live].map((id) => sanitizeSidecarId(id)));
1668
- if (sessionId !== void 0 && live.has(sessionId)) {
1669
- await deps.prompt(input.sessionID, `LCM gc: session ${sessionId} is open in this process; gc never deletes an open sidecar.`);
2006
+ if (sessionId !== void 0 && (live.has(sessionId) || liveStems.has(sanitizeSidecarId(sessionId)))) {
2007
+ await deps.prompt(input.sessionID, `LCM gc: session ${sessionId} is open in this process. The command never deletes an open sidecar.`);
1670
2008
  return;
1671
2009
  }
1672
2010
  const rawDays = valueOf(args, "--older-than") ?? "30";
@@ -1675,43 +2013,174 @@ function gcCommand(deps) {
1675
2013
  await deps.prompt(input.sessionID, "LCM gc: --older-than takes a number of days.");
1676
2014
  return;
1677
2015
  }
2016
+ if (days <= 0 && sessionId === void 0) {
2017
+ await deps.prompt(input.sessionID, "LCM gc: --older-than 0 needs --session <id>. Without --session it would match every other sidecar, including ones live in another process.");
2018
+ return;
2019
+ }
1678
2020
  const refs = deps.sidecars.list(input.sessionID).filter((ref) => !liveStems.has(ref.id));
1679
- const selected = sessionId === void 0 ? refs : refs.filter((ref) => ref.id === sessionId);
2021
+ const sanitizedSession = sessionId === void 0 ? void 0 : sanitizeSidecarId(sessionId);
2022
+ const selected = sanitizedSession === void 0 ? refs : refs.filter((ref) => ref.id === sanitizedSession);
1680
2023
  if (sessionId !== void 0 && selected.length === 0) {
1681
- await deps.prompt(input.sessionID, `LCM gc: no sidecar for session ${sessionId} (the current session is never a candidate).`);
2024
+ await deps.prompt(input.sessionID, `LCM gc: no sidecar for session ${sessionId}. The current session is never a candidate.`);
1682
2025
  return;
1683
2026
  }
1684
- const cutoff = Date.now() - days * 864e5;
1685
- const candidates = [];
1686
- for (const ref of selected) try {
1687
- const stat = statSync(ref.path);
1688
- let mtimeMs = stat.mtimeMs;
1689
- for (const suffix of ["-wal", "-shm"]) try {
1690
- mtimeMs = Math.max(mtimeMs, statSync(`${ref.path}${suffix}`).mtimeMs);
1691
- } catch {}
1692
- if (sessionId !== void 0 || mtimeMs <= cutoff) candidates.push({
2027
+ const now = Date.now();
2028
+ const ageMsOf = (mtimeMs) => Math.max(0, now - mtimeMs);
2029
+ const cutoff = now - days * 864e5;
2030
+ const entries = [];
2031
+ for (const ref of selected) {
2032
+ const found = maxSidecarMtime(ref.path);
2033
+ if (found !== void 0) entries.push({
1693
2034
  ref,
1694
- stat,
1695
- mtimeMs
2035
+ ...found
1696
2036
  });
1697
- } catch {}
2037
+ }
2038
+ if (sessionId !== void 0) {
2039
+ if (entries.length === 0) {
2040
+ await deps.prompt(input.sessionID, `LCM gc: nothing eligible for session ${sessionId} (sidecar unreadable).`);
2041
+ return;
2042
+ }
2043
+ const target = entries[0];
2044
+ if (!force && ageMsOf(target.mtimeMs) < GC_GRACE_MS) {
2045
+ deps.record({
2046
+ kind: "gc",
2047
+ action: "skipped",
2048
+ session: target.ref.id,
2049
+ reason: "recency-grace"
2050
+ });
2051
+ const minutes = Math.floor(ageMsOf(target.mtimeMs) / 6e4);
2052
+ await deps.prompt(input.sessionID, `LCM gc: session ${sessionId} was active ${minutes} minute(s) ago. It may be live in another process. Re-run with --session ${sessionId} --force to delete it anyway.`);
2053
+ return;
2054
+ }
2055
+ const candidates = entries;
2056
+ const lines = candidates.map(({ ref, stat, mtimeMs }) => {
2057
+ const ageDays = Math.max(0, (Date.now() - mtimeMs) / 864e5);
2058
+ return ` ${ref.id}: ${(stat.size / 1024).toFixed(0)} KB, ${ageDays.toFixed(1)} day(s) old`;
2059
+ });
2060
+ const young = gcYoungWarning(candidates.filter(({ mtimeMs }) => ageMsOf(mtimeMs) < GC_YOUNG_MS).map(({ ref }) => ref.id));
2061
+ if (!apply) {
2062
+ await deps.prompt(input.sessionID, `LCM gc dry-run: ${candidates.length} sidecar(s) removable:\n${lines.join("\n")}${young === void 0 ? "" : `\n${young}`}\nRun \`/lossless-gc --apply\` to export then delete them. Backups are never touched.`);
2063
+ return;
2064
+ }
2065
+ const fresh = maxSidecarMtime(target.ref.path);
2066
+ if (fresh !== void 0 && !force && Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS) {
2067
+ deps.record({
2068
+ kind: "gc",
2069
+ action: "skipped",
2070
+ session: target.ref.id,
2071
+ reason: "recency-grace"
2072
+ });
2073
+ const minutes = Math.floor(Math.max(0, Date.now() - fresh.mtimeMs) / 6e4);
2074
+ await deps.prompt(input.sessionID, `LCM gc: session ${sessionId} was active ${minutes} minute(s) ago. It may be live in another process. Re-run with --session ${sessionId} --force to delete it anyway.`);
2075
+ return;
2076
+ }
2077
+ const exportTarget = join(deps.sidecars.dir, `lossless-export-gc-${STAMP()}.jsonl`);
2078
+ let removed = 0;
2079
+ let exported = 0;
2080
+ let failed = 0;
2081
+ for (const { ref } of candidates) {
2082
+ let sizeBytes = target.stat.size;
2083
+ try {
2084
+ const fresh = maxSidecarMtime(ref.path);
2085
+ if (fresh === void 0) throw new Error("sidecar unreadable before delete");
2086
+ sizeBytes = fresh.stat.size;
2087
+ if (!force && Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS) {
2088
+ deps.record({
2089
+ kind: "gc",
2090
+ action: "skipped",
2091
+ session: ref.id,
2092
+ reason: "recency-grace"
2093
+ });
2094
+ const minutes = Math.floor(Math.max(0, Date.now() - fresh.mtimeMs) / 6e4);
2095
+ await deps.prompt(input.sessionID, `LCM gc: session ${sessionId} was active ${minutes} minute(s) ago. It may be live in another process. Re-run with --session ${sessionId} --force to delete it anyway.`);
2096
+ return;
2097
+ }
2098
+ const store = new LcmStore(ref.path, { queryOnly: true });
2099
+ let rows = "";
2100
+ try {
2101
+ rows = exportTranscript(store).toJSONL();
2102
+ } finally {
2103
+ store.close();
2104
+ }
2105
+ if (rows.length > 0) {
2106
+ appendFileSync(exportTarget, `${rows}\n`, {
2107
+ encoding: "utf8",
2108
+ mode: 384
2109
+ });
2110
+ exported++;
2111
+ }
2112
+ deps.sidecars.drop(ref.id);
2113
+ removed++;
2114
+ deps.record({
2115
+ kind: "gc",
2116
+ action: "deleted",
2117
+ session: ref.id,
2118
+ sizeBytes,
2119
+ exportedTo: exportTarget
2120
+ });
2121
+ } catch (error) {
2122
+ failed++;
2123
+ deps.record({
2124
+ kind: "gc",
2125
+ action: "failed",
2126
+ session: ref.id,
2127
+ error: error instanceof Error ? error.message.slice(0, 300) : String(error)
2128
+ });
2129
+ }
2130
+ }
2131
+ if (exported > 0) chmodFile(exportTarget);
2132
+ await deps.prompt(input.sessionID, `LCM gc: removed ${removed} of ${candidates.length} sidecar(s). ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `. ${failed} skipped. Migrate or inspect first`}.${young === void 0 ? "" : ` ${young}`}`);
2133
+ return;
2134
+ }
2135
+ const candidates = entries.filter(({ mtimeMs }) => mtimeMs <= cutoff && ageMsOf(mtimeMs) >= GC_GRACE_MS);
2136
+ const skipped = entries.filter(({ mtimeMs }) => ageMsOf(mtimeMs) < GC_GRACE_MS);
2137
+ const skippedIds = skipped.map(({ ref }) => ref.id);
2138
+ for (const { ref } of skipped) deps.record({
2139
+ kind: "gc",
2140
+ action: "skipped",
2141
+ session: ref.id,
2142
+ reason: "recency-grace"
2143
+ });
1698
2144
  if (candidates.length === 0) {
1699
- await deps.prompt(input.sessionID, `LCM gc: nothing eligible (${refs.length} other sidecar(s), none older than ${days} day(s)).`);
2145
+ await deps.prompt(input.sessionID, `LCM gc: nothing eligible. ${refs.length} other sidecar(s), none older than ${days} day(s).${skippedIds.length === 0 ? "" : ` ${gcSkippedLine(skippedIds)}`}`);
1700
2146
  return;
1701
2147
  }
1702
2148
  const lines = candidates.map(({ ref, stat, mtimeMs }) => {
1703
2149
  const ageDays = Math.max(0, (Date.now() - mtimeMs) / 864e5);
1704
2150
  return ` ${ref.id}: ${(stat.size / 1024).toFixed(0)} KB, ${ageDays.toFixed(1)} day(s) old`;
1705
2151
  });
2152
+ const young = gcYoungWarning(candidates.filter(({ mtimeMs }) => ageMsOf(mtimeMs) < GC_YOUNG_MS).map(({ ref }) => ref.id));
1706
2153
  if (!apply) {
1707
- await deps.prompt(input.sessionID, `LCM gc dry-run: ${candidates.length} sidecar(s) removable:\n${lines.join("\n")}\nRun \`/lossless-gc --apply\` to export then delete them. Backups are never touched.`);
2154
+ await deps.prompt(input.sessionID, `LCM gc dry-run: ${candidates.length} sidecar(s) removable:\n${lines.join("\n")}${young === void 0 ? "" : `\n${young}`}${skipped.length === 0 ? "" : `\n${gcSkippedLine(skippedIds)}`}\nRun \`/lossless-gc --apply\` to export then delete them. Backups are never touched.`);
1708
2155
  return;
1709
2156
  }
1710
2157
  const exportTarget = join(deps.sidecars.dir, `lossless-export-gc-${STAMP()}.jsonl`);
1711
2158
  let removed = 0;
1712
2159
  let exported = 0;
1713
2160
  let failed = 0;
1714
- for (const { ref, stat } of candidates) try {
2161
+ const restatSkipped = [];
2162
+ for (const { ref } of candidates) try {
2163
+ const fresh = maxSidecarMtime(ref.path);
2164
+ if (fresh === void 0) {
2165
+ failed++;
2166
+ deps.record({
2167
+ kind: "gc",
2168
+ action: "failed",
2169
+ session: ref.id,
2170
+ error: "sidecar unreadable before delete; skipped"
2171
+ });
2172
+ continue;
2173
+ }
2174
+ if (Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS) {
2175
+ restatSkipped.push(ref.id);
2176
+ deps.record({
2177
+ kind: "gc",
2178
+ action: "skipped",
2179
+ session: ref.id,
2180
+ reason: "recency-grace"
2181
+ });
2182
+ continue;
2183
+ }
1715
2184
  const store = new LcmStore(ref.path, { queryOnly: true });
1716
2185
  let rows = "";
1717
2186
  try {
@@ -1732,7 +2201,7 @@ function gcCommand(deps) {
1732
2201
  kind: "gc",
1733
2202
  action: "deleted",
1734
2203
  session: ref.id,
1735
- sizeBytes: stat.size,
2204
+ sizeBytes: fresh.stat.size,
1736
2205
  exportedTo: exportTarget
1737
2206
  });
1738
2207
  } catch (error) {
@@ -1745,7 +2214,8 @@ function gcCommand(deps) {
1745
2214
  });
1746
2215
  }
1747
2216
  if (exported > 0) chmodFile(exportTarget);
1748
- await deps.prompt(input.sessionID, `LCM gc: removed ${removed} of ${candidates.length} sidecar(s); ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `; ${failed} skipped (migrate or inspect first)`}.`);
2217
+ const allSkipped = [...skippedIds, ...restatSkipped];
2218
+ await deps.prompt(input.sessionID, `LCM gc: removed ${removed} of ${candidates.length} sidecar(s). ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `. ${failed} skipped. Migrate or inspect first`}.${young === void 0 ? "" : ` ${young}`}${allSkipped.length === 0 ? "" : ` ${gcSkippedLine(allSkipped)}`}`);
1749
2219
  }
1750
2220
  };
1751
2221
  }
@@ -1790,8 +2260,46 @@ function healCommand(deps) {
1790
2260
  }
1791
2261
  };
1792
2262
  }
1793
- /** The compact result a command prompt carries. No summary or message text
1794
- * leaves the store; the counts are the whole answer. */
2263
+ function dumpCommand(deps) {
2264
+ const redact = makeRedactConfigAdapter(deps.options);
2265
+ return {
2266
+ name: "lossless-dump",
2267
+ description: "Print a read-only dump of this session's sidecar. It holds history, sampled summaries and messages, and metrics.",
2268
+ execute: async (input) => {
2269
+ const args = argsOf(input.prompt.text);
2270
+ const includeRemoved = args.includes("--include-removed");
2271
+ if (args.includes("--save") && valueOf(args, "--save") === void 0) {
2272
+ await deps.prompt(input.sessionID, "LCM dump --save: name the file to write, e.g. `/lossless-dump --save lossless-dump.txt`.");
2273
+ return;
2274
+ }
2275
+ const save = valueOf(args, "--save");
2276
+ const query = args.filter((arg) => !arg.startsWith("--") && arg !== save).join(" ");
2277
+ const result = dumpSidecar({
2278
+ path: deps.sidecars.pathFor(input.sessionID),
2279
+ sessionId: input.sessionID,
2280
+ redact,
2281
+ ...includeRemoved ? { includeRemoved: true } : {},
2282
+ metricRows: readMetricRows(deps.sidecars.dir).filter((row) => isRecord(row)),
2283
+ ...query.length > 0 ? { sampleQuery: query } : {}
2284
+ });
2285
+ if (result.status === "skipped") {
2286
+ await deps.prompt(input.sessionID, `LCM dump skipped: ${result.reason} (${result.detail}).`);
2287
+ return;
2288
+ }
2289
+ if (save !== void 0) {
2290
+ try {
2291
+ const target = resolve(save);
2292
+ writeDumpTarget(target, `${result.text}\n`);
2293
+ await deps.prompt(input.sessionID, `LCM dump: wrote ${Buffer.byteLength(result.text, "utf8")} bytes to ${target} (mode 0600).`);
2294
+ } catch (error) {
2295
+ await deps.prompt(input.sessionID, `LCM dump failed: ${error instanceof Error ? error.message : String(error)}`);
2296
+ }
2297
+ return;
2298
+ }
2299
+ await deps.prompt(input.sessionID, result.text);
2300
+ }
2301
+ };
2302
+ }
1795
2303
  function healReport(outcome) {
1796
2304
  if (outcome.dryRun) return outcome.counts.candidates === 0 ? "LCM heal dry-run: no truncated summaries or stale ancestors; nothing to repair." : `LCM heal dry-run: ${outcome.counts.candidates} candidate(s), ${outcome.counts.tainted} tainted, ${outcome.sourceTokens} source tokens; run \`/lossless-heal --apply\` to repair.`;
1797
2305
  const stop = outcome.stop === "done" ? "" : ` (stopped: ${outcome.stop})`;
@@ -1836,6 +2344,10 @@ function searchPastSessions(deps, callingSessionId, query, opts = {}) {
1836
2344
  const view = grepView(store, deps.redact, {
1837
2345
  query,
1838
2346
  limit: Math.min(10, hitLimit - hits),
2347
+ ...opts.role === void 0 ? {} : { role: opts.role },
2348
+ ...opts.dateStart === void 0 ? {} : { dateStart: opts.dateStart },
2349
+ ...opts.dateEnd === void 0 ? {} : { dateEnd: opts.dateEnd },
2350
+ ...opts.toolName === void 0 ? {} : { toolName: opts.toolName },
1839
2351
  expandTool: "lcm_expand"
1840
2352
  });
1841
2353
  if (!view.ok) return finish(view.text, {
@@ -1941,6 +2453,28 @@ function registerRecallTools(editor, deps) {
1941
2453
  type: "integer",
1942
2454
  description: "Hits to return"
1943
2455
  },
2456
+ role: {
2457
+ type: "string",
2458
+ enum: [
2459
+ "user",
2460
+ "assistant",
2461
+ "toolResult",
2462
+ "custom"
2463
+ ],
2464
+ description: "Keep only rows stored with this role"
2465
+ },
2466
+ date_start: {
2467
+ type: "number",
2468
+ description: "Keep only rows at or after this timestamp"
2469
+ },
2470
+ date_end: {
2471
+ type: "number",
2472
+ description: "Keep only rows at or before this timestamp"
2473
+ },
2474
+ tool_name: {
2475
+ type: "string",
2476
+ description: "Keep only tool-result rows for this tool. It matches [tool:Name] markers."
2477
+ },
1944
2478
  scope: {
1945
2479
  type: "string",
1946
2480
  enum: [...RECALL_SCOPES],
@@ -1968,7 +2502,7 @@ function registerRecallTools(editor, deps) {
1968
2502
  editor.add({
1969
2503
  name: "lcm_expand",
1970
2504
  options: { codemode: false },
1971
- description: "Read the original messages a summary covers, or one named entry. Without entry_id, list `limit` messages from message index `offset`. With entry_id, read that one message from `char_offset` for up to `max_chars` characters; continue from the char_offset the result reports. `id` is the summary to list, or to check that the entry is covered; omit it to read an entry that no summary covers yet. Pass `session` with the id from an [lcm:session <id>] hit to read that past session's sidecar; the text is labelled as a past session's history and the read is read-only.",
2505
+ description: "Read the original messages a summary covers, or one named entry. Without entry_id, list `limit` messages from message index `offset`, or centered on `anchor` when it names an entry id. With entry_id, read that one message from `char_offset` for up to `max_chars` characters; continue from the char_offset the result reports. `id` is the summary to list, or to check that the entry is covered; omit it to read an entry that no summary covers yet. Pass `session` with the id from an [lcm:session <id>] hit to read that past session's sidecar; the text is labelled as a past session's history and the read is read-only.",
1972
2506
  input: {
1973
2507
  type: "object",
1974
2508
  properties: {
@@ -1980,6 +2514,10 @@ function registerRecallTools(editor, deps) {
1980
2514
  type: "string",
1981
2515
  description: "Read this one message by entry id"
1982
2516
  },
2517
+ anchor: {
2518
+ type: "string",
2519
+ description: "Center the listing window on this entry id (with id, without entry_id)"
2520
+ },
1983
2521
  session: {
1984
2522
  type: "string",
1985
2523
  description: "The past session id from an [lcm:session <id>] hit. Its sidecar is read read-only and the text is labelled as a past session's history."
@@ -2095,11 +2633,17 @@ function toCoreDeps(deps) {
2095
2633
  record: deps.record,
2096
2634
  sessionOf: deps.sessionOf,
2097
2635
  isReader: deps.isReader,
2098
- searchPast: (session, query, scope, limit) => {
2636
+ searchPast: (session, query, scope, limit, filters) => {
2099
2637
  const result = searchPastSessions({
2100
2638
  past,
2101
2639
  redact: deps.redact
2102
- }, session, query, typeof limit === "number" ? { limit } : {});
2640
+ }, session, query, {
2641
+ ...typeof limit === "number" ? { limit } : {},
2642
+ ...filters?.role === void 0 ? {} : { role: filters.role },
2643
+ ...filters?.dateStart === void 0 ? {} : { dateStart: filters.dateStart },
2644
+ ...filters?.dateEnd === void 0 ? {} : { dateEnd: filters.dateEnd },
2645
+ ...filters?.toolName === void 0 ? {} : { toolName: filters.toolName }
2646
+ });
2103
2647
  return {
2104
2648
  text: result.text,
2105
2649
  hits: result.hits,
@@ -2518,36 +3062,22 @@ var Sidecars = class extends SidecarStores {
2518
3062
  };
2519
3063
  // src/plugin.ts
2520
3064
  const PLUGIN_ID = "opencode-lossless";
2521
- const DEFAULT_SIDECAR_DIR = "./.lossless";
2522
3065
  const DEFAULT_SWAP_RATIO = .7;
2523
3066
  const DEFAULT_RECUT_RATIO = .85;
2524
3067
  const DEFAULT_KEEP_RECENT_TOKENS = 2e4;
2525
3068
  const DEFAULT_SUMMARY_TOKENS = 1500;
2526
3069
  const DEFAULT_CONTEXT_WINDOW = 128e3;
2527
- /** The extension's own version, from the manifest that ships it. */
2528
3070
  function pluginVersion() {
2529
3071
  return packageVersionNear(fileURLToPath(import.meta.url), "opencode-lossless") ?? "unknown (no package.json)";
2530
3072
  }
2531
3073
  function pluginLabel() {
2532
3074
  return `opencode-lossless ${pluginVersion()}`;
2533
3075
  }
2534
- /** Wire lossless memory into an OpenCode v2 plugin context: mirror prompts,
2535
- * assistant text, tool traffic, and compaction boundaries into per-session
2536
- * sidecars, and register the recall tools plus the `/lossless-status`,
2537
- * `/lossless-report`, `/lossless-pass`, and `/lossless-heal` commands. With
2538
- * `assembly` (the default), also register the context projection and the blocking checkpoint
2539
- * (`session.compaction`); `/lossless-pass` runs one DAG pass over the session's
2540
- * stored span on demand. The host stops summarizing for itself once the sidecar
2541
- * covers the aged span: it stores the DAG frontier as its checkpoint, and the
2542
- * next request's projection continues from it. The sidecar keeps every address
2543
- * the reader can name. Resolves to the cleanup that unregisters everything and
2544
- * closes the sidecars. The context is the local structural subset
2545
- * (`./host.ts`), so the real host context flows in without the bundle naming
2546
- * the host SDK. `rawOptions` is validated at runtime; a malformed value
2547
- * degrades to defaults rather than throwing into the host load. */
3076
+ /** Wire lossless memory into an OpenCode v2 plugin context: mirror traffic into per-session sidecars and register recall tools plus lifecycle commands. Resolves to the cleanup that unregisters everything and closes the sidecars. */
2548
3077
  async function setupOpencodeLossless(ctx, rawOptions) {
2549
3078
  const options = readOptions(rawOptions);
2550
3079
  if (options.enabled === false) return async () => void 0;
3080
+ logSidecarDir(options.sidecarDir, options.sidecarSource);
2551
3081
  const redact = makeRedactConfigAdapter(options);
2552
3082
  const sidecars = new Sidecars(options.sidecarDir);
2553
3083
  const registrations = [];
@@ -2639,13 +3169,15 @@ async function setupOpencodeLossless(ctx, rawOptions) {
2639
3169
  sidecars,
2640
3170
  options: {
2641
3171
  assembly: options.assembly,
3172
+ sidecarSource: options.sidecarSource,
2642
3173
  swapAtRatio: options.swapAtRatio,
2643
3174
  recutAtRatio: options.recutAtRatio,
2644
3175
  keepRecentTokens: options.keepRecentTokens,
2645
3176
  ...options.smartZone === void 0 ? {} : { smartZone: options.smartZone },
2646
3177
  ...summarizerConfig(options),
2647
3178
  summarizerTokensPerHeal: options.summarizerTokensPerHeal,
2648
- ...options.zones === void 0 ? {} : { zones: options.zones }
3179
+ ...options.zones === void 0 ? {} : { zones: options.zones },
3180
+ ...options.mechanicalTools === void 0 ? {} : { mechanicalTools: options.mechanicalTools }
2649
3181
  },
2650
3182
  version: pluginLabel,
2651
3183
  host: () => "opencode v2",
@@ -2711,6 +3243,7 @@ async function setupOpencodeLossless(ctx, rawOptions) {
2711
3243
  exportCommand,
2712
3244
  importCommand,
2713
3245
  diagnoseCommand,
3246
+ dumpCommand,
2714
3247
  migrateCommand,
2715
3248
  gcCommand,
2716
3249
  healCommand
@@ -2758,7 +3291,10 @@ async function setupOpencodeLossless(ctx, rawOptions) {
2758
3291
  }, sessionID, mapped.entries, {
2759
3292
  targetTokens: options.summaryTokens,
2760
3293
  stage: "direct",
2761
- summarizer: summarizerConfig(options)
3294
+ summarizer: {
3295
+ ...summarizerConfig(options),
3296
+ ...options.mechanicalTools === void 0 ? {} : { mechanicalTools: options.mechanicalTools }
3297
+ }
2762
3298
  });
2763
3299
  },
2764
3300
  prompt: (sessionID, text) => ctx.session.prompt({
@@ -2835,9 +3371,12 @@ function readOptions(raw) {
2835
3371
  const recutAtRatio = readRatio(record.recutAtRatio) ?? DEFAULT_RECUT_RATIO;
2836
3372
  const zones = readZones(record.zones);
2837
3373
  const healSourceTokens = readHealSourceTokens(record.healSourceTokens);
3374
+ const mechanicalTools = readMechanicalTools(record.mechanicalTools);
3375
+ const sidecar = readSidecarDir(record.sidecarDir);
2838
3376
  return {
2839
3377
  enabled: typeof record.enabled === "boolean" ? record.enabled : true,
2840
- sidecarDir: typeof record.sidecarDir === "string" ? record.sidecarDir : DEFAULT_SIDECAR_DIR,
3378
+ sidecarDir: sidecar.dir,
3379
+ sidecarSource: sidecar.source,
2841
3380
  assembly: record.assembly !== false,
2842
3381
  swapAtRatio,
2843
3382
  recutAtRatio,
@@ -2851,15 +3390,55 @@ function readOptions(raw) {
2851
3390
  summarizerTokensPerHeal: positiveInteger(record.summarizerTokensPerHeal) ?? SUMMARIZER_HEAL_TOKENS,
2852
3391
  ...summarizer === void 0 ? {} : { summarizer },
2853
3392
  ...smartZone === void 0 ? {} : { smartZone },
2854
- ...zones === void 0 ? {} : { zones }
3393
+ ...zones === void 0 ? {} : { zones },
3394
+ ...mechanicalTools === void 0 ? {} : { mechanicalTools }
2855
3395
  };
2856
3396
  }
3397
+ function readSidecarDir(raw) {
3398
+ if (typeof raw === "string" && raw.trim().length > 0) return {
3399
+ dir: raw,
3400
+ source: "explicit"
3401
+ };
3402
+ const resolved = defaultSidecarDir();
3403
+ return {
3404
+ dir: resolved.dir,
3405
+ source: resolved.source
3406
+ };
3407
+ }
3408
+ function logSidecarDir(dir, source) {
3409
+ try {
3410
+ console.log(`LCM sidecar dir: ${dir} (${source})`);
3411
+ } catch {}
3412
+ if (source === "explicit" || source === "fallback") return;
3413
+ try {
3414
+ if (resolve("./.lossless") === resolve(dir)) return;
3415
+ const entries = readdirSync(FALLBACK_SIDECAR_DIR);
3416
+ if (entries.length === 0) return;
3417
+ console.warn(`LCM sidecar: legacy ${resolve(FALLBACK_SIDECAR_DIR)} holds ${entries.length} file(s) and is not read. Sidecars now live in ${dir} (${source}). Nothing was moved. To keep the old dir set sidecarDir to "./.lossless". See /lossless-migrate.`);
3418
+ } catch {}
3419
+ }
2857
3420
  function readSummarizer(raw) {
2858
3421
  if (typeof raw === "string") return raw.trim().length === 0 ? void 0 : raw;
2859
3422
  if (!Array.isArray(raw)) return void 0;
2860
3423
  const chain = raw.filter((entry) => typeof entry === "string");
2861
3424
  return chain.length === 0 ? void 0 : chain;
2862
3425
  }
3426
+ function readMechanicalTools(raw) {
3427
+ const list = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : void 0;
3428
+ if (list === void 0) return void 0;
3429
+ const seen = /* @__PURE__ */ new Set();
3430
+ const out = [];
3431
+ for (const entry of list) {
3432
+ if (typeof entry !== "string") continue;
3433
+ const name = entry.trim();
3434
+ if (name.length === 0) continue;
3435
+ const key = name.toLowerCase();
3436
+ if (seen.has(key)) continue;
3437
+ seen.add(key);
3438
+ out.push(name);
3439
+ }
3440
+ return out.length === 0 ? void 0 : out;
3441
+ }
2863
3442
  function positiveNumber(raw) {
2864
3443
  return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : void 0;
2865
3444
  }
@@ -2867,15 +3446,11 @@ function positiveInteger(raw) {
2867
3446
  const value = positiveNumber(raw);
2868
3447
  return value !== void 0 && Number.isInteger(value) ? value : void 0;
2869
3448
  }
2870
- /** A source window above the hard ceiling is a malformed override, not a
2871
- * request for a prompt the summarizer call cap cannot carry. Falling back to
2872
- * the derived default is safer than clamping a typo into a paid call. */
3449
+ /** Above the call cap a value is a typo, not a paid call. Fall back instead of clamping. */
2873
3450
  function readHealSourceTokens(raw) {
2874
3451
  const value = positiveInteger(raw);
2875
3452
  return value !== void 0 && value <= HEAL_SOURCE_MAX_TOKENS ? value : void 0;
2876
3453
  }
2877
- /** A share of the window: above zero and at most one. Anything else keeps the
2878
- * default rather than throwing, the way the other options read. */
2879
3454
  function readRatio(raw) {
2880
3455
  const value = positiveNumber(raw);
2881
3456
  return value !== void 0 && value <= 1 ? value : void 0;
@@ -2902,9 +3477,6 @@ function readZones(raw) {
2902
3477
  }
2903
3478
  return Object.keys(zones).length === 0 ? void 0 : zones;
2904
3479
  }
2905
- /** The model the session last ran under, read from the stored messages so the
2906
- * pass needs no extra host call. A model switch or a compaction carries one,
2907
- * and an assistant message carries the one it answered with. */
2908
3480
  function sessionModel(messages) {
2909
3481
  for (let index = messages.length - 1; index >= 0; index--) {
2910
3482
  const model = messages[index]?.model;
@@ -2920,10 +3492,6 @@ function sessionModel(messages) {
2920
3492
  function errorText(error) {
2921
3493
  return error instanceof Error ? error.message : String(error);
2922
3494
  }
2923
- /** The plugin definition the host loads. Options come from the config entry,
2924
- * so the default export reads them late, at setup time. The setup parameter
2925
- * is the local context subset; the host's richer context satisfies it, which
2926
- * `test/host-compat.test.ts` pins against the real host types. */
2927
3495
  const definition = {
2928
3496
  id: PLUGIN_ID,
2929
3497
  setup: (ctx) => setupOpencodeLossless(ctx, ctx.options)