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/CHANGELOG.md +13 -0
- package/README.md +18 -13
- package/dist/index.d.mts +18 -65
- package/dist/index.mjs +643 -75
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/skills/opencode-lossless/SKILL.md +31 -26
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:
|
|
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:
|
|
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
|
|
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, `${
|
|
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(
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
1685
|
-
const
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
1695
|
-
mtimeMs
|
|
2035
|
+
...found
|
|
1696
2036
|
});
|
|
1697
|
-
}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1794
|
-
|
|
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
|
|
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,
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
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)
|