@basou/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1343,6 +1343,7 @@ import {
1343
1343
  importSessionFromJson,
1344
1344
  readManifest as readManifest3,
1345
1345
  readSessionYaml,
1346
+ reimportPreservingId,
1346
1347
  resolveRepositoryRoot as resolveRepositoryRoot6,
1347
1348
  SessionImportPayloadSchema
1348
1349
  } from "@basou/core";
@@ -1418,10 +1419,15 @@ async function doRunImportClaudeCode(options, ctx) {
1418
1419
  const externalId = basename(file, ".jsonl");
1419
1420
  return {
1420
1421
  externalId,
1421
- toPayload: async () => claudeTranscriptToImportPayload(await readJsonlRecords(file), {
1422
- workspaceId: manifest.workspace.id,
1423
- externalId
1424
- })
1422
+ sourcePath: file,
1423
+ toPayload: async () => {
1424
+ const { records, sizeBytes } = await readJsonlRecords(file);
1425
+ return claudeTranscriptToImportPayload(records, {
1426
+ workspaceId: manifest.workspace.id,
1427
+ externalId,
1428
+ sourceSizeBytes: sizeBytes
1429
+ });
1430
+ }
1425
1431
  };
1426
1432
  });
1427
1433
  await importDerivedSessions(paths, manifest, options, CLAUDE_IMPORT_SOURCE, candidates);
@@ -1439,10 +1445,15 @@ async function doRunImportCodex(options, ctx) {
1439
1445
  const rollouts = await discoverCodexRollouts(sessionsRoot, projectPaths, options);
1440
1446
  const candidates = rollouts.map(({ file, externalId }) => ({
1441
1447
  externalId,
1442
- toPayload: async () => codexRolloutToImportPayload(await readJsonlRecords(file), {
1443
- workspaceId: manifest.workspace.id,
1444
- externalId
1445
- })
1448
+ sourcePath: file,
1449
+ toPayload: async () => {
1450
+ const { records, sizeBytes } = await readJsonlRecords(file);
1451
+ return codexRolloutToImportPayload(records, {
1452
+ workspaceId: manifest.workspace.id,
1453
+ externalId,
1454
+ sourceSizeBytes: sizeBytes
1455
+ });
1456
+ }
1446
1457
  }));
1447
1458
  await importDerivedSessions(paths, manifest, options, CODEX_IMPORT_SOURCE, candidates);
1448
1459
  }
@@ -1466,41 +1477,76 @@ async function importDerivedSessions(paths, manifest, options, sourceKind, candi
1466
1477
  const existingByExternalId = await loadExistingByExternalId(paths, sourceKind);
1467
1478
  const seenThisRun = /* @__PURE__ */ new Set();
1468
1479
  const results = [];
1469
- let skippedNoAction = 0;
1470
- let skippedExisting = 0;
1471
- let replaced = 0;
1480
+ const counts = {
1481
+ skippedNoAction: 0,
1482
+ skippedExisting: 0,
1483
+ replaced: 0,
1484
+ reimported: 0,
1485
+ skippedLegacy: 0,
1486
+ skippedDecreased: 0,
1487
+ skippedDuplicate: 0
1488
+ };
1472
1489
  let sanitizedPaths = 0;
1473
- for (const { externalId, toPayload } of candidates) {
1490
+ const validate = (payload) => {
1491
+ if (payload === null) return null;
1492
+ const parsed = SessionImportPayloadSchema.safeParse(payload);
1493
+ if (!parsed.success) {
1494
+ throw new Error("Invalid import payload", { cause: parsed.error });
1495
+ }
1496
+ if (parsed.data.schema_version !== "0.1.0") {
1497
+ throw new Error(`Unsupported import schema_version: ${parsed.data.schema_version}`);
1498
+ }
1499
+ return parsed.data;
1500
+ };
1501
+ for (const { externalId, sourcePath, toPayload } of candidates) {
1474
1502
  if (seenThisRun.has(externalId)) {
1475
- skippedExisting++;
1503
+ counts.skippedExisting++;
1476
1504
  continue;
1477
1505
  }
1478
- const priorSessionIds = existingByExternalId.get(externalId) ?? [];
1479
- if (priorSessionIds.length > 0 && options.force !== true) {
1480
- skippedExisting++;
1506
+ const priors = existingByExternalId.get(externalId) ?? [];
1507
+ if (priors.length > 0 && options.force !== true) {
1508
+ const prior = await classifyReimport(priors, sourcePath, externalId, counts);
1509
+ if (prior === null) continue;
1510
+ const payload2 = validate(await toPayload());
1511
+ if (payload2 === null) {
1512
+ counts.skippedNoAction++;
1513
+ continue;
1514
+ }
1515
+ const readSize = payload2.session.source.source_size_bytes;
1516
+ if (prior.sourceSizeBytes !== void 0 && readSize !== void 0 && readSize <= prior.sourceSizeBytes) {
1517
+ console.error(
1518
+ `Import: ${externalId} source changed during read (now ${readSize} <= ${prior.sourceSizeBytes} bytes); re-import skipped`
1519
+ );
1520
+ counts.skippedDecreased++;
1521
+ continue;
1522
+ }
1523
+ const outcome = await reimportPreservingId(paths, manifest, prior.sessionId, payload2, {
1524
+ dryRun: options.dryRun === true
1525
+ });
1526
+ if (outcome.status === "skipped") {
1527
+ const detail = outcome.reason === "prior_events_unreadable" ? "prior events.jsonl has unreadable lines" : "source changed in a non-append way (derived events would be dropped)";
1528
+ console.error(`Import: ${externalId} ${detail}; re-import skipped`);
1529
+ counts.skippedNoAction++;
1530
+ continue;
1531
+ }
1532
+ counts.reimported++;
1533
+ seenThisRun.add(externalId);
1481
1534
  continue;
1482
1535
  }
1483
- const payload = await toPayload();
1536
+ const payload = validate(await toPayload());
1484
1537
  if (payload === null) {
1485
- skippedNoAction++;
1538
+ counts.skippedNoAction++;
1486
1539
  continue;
1487
1540
  }
1488
- const parsed = SessionImportPayloadSchema.safeParse(payload);
1489
- if (!parsed.success) {
1490
- throw new Error("Invalid import payload", { cause: parsed.error });
1491
- }
1492
- if (parsed.data.schema_version !== "0.1.0") {
1493
- throw new Error(`Unsupported import schema_version: ${parsed.data.schema_version}`);
1494
- }
1495
- if (priorSessionIds.length > 0 && options.force === true) {
1541
+ if (priors.length > 0 && options.force === true) {
1496
1542
  if (options.dryRun !== true) {
1497
- for (const sid of priorSessionIds) {
1498
- await rm(join3(paths.sessions, sid), { recursive: true, force: true });
1543
+ for (const { sessionId } of priors) {
1544
+ await rm(join3(paths.sessions, sessionId), { recursive: true, force: true });
1499
1545
  }
1500
1546
  }
1501
- replaced++;
1547
+ counts.replaced++;
1502
1548
  }
1503
- const result = await importSessionFromJson(paths, manifest, parsed.data, {
1549
+ const result = await importSessionFromJson(paths, manifest, payload, {
1504
1550
  dryRun: options.dryRun === true
1505
1551
  });
1506
1552
  results.push(result);
@@ -1510,17 +1556,52 @@ async function importDerivedSessions(paths, manifest, options, sourceKind, candi
1510
1556
  if (sanitizedPaths > 0) {
1511
1557
  console.error(`Imported sessions: ${sanitizedPaths} path(s) sanitized`);
1512
1558
  }
1513
- printImportResult(options, results, { skippedNoAction, skippedExisting, replaced });
1559
+ printImportResult(options, results, counts);
1560
+ }
1561
+ async function classifyReimport(priors, sourcePath, externalId, counts) {
1562
+ if (priors.length > 1) {
1563
+ console.error(
1564
+ `Import: ${externalId} has ${priors.length} prior sessions; re-import skipped (use --force)`
1565
+ );
1566
+ counts.skippedDuplicate++;
1567
+ return null;
1568
+ }
1569
+ const prior = priors[0];
1570
+ if (prior === void 0) {
1571
+ counts.skippedExisting++;
1572
+ return null;
1573
+ }
1574
+ const currentSize = await statSize(sourcePath);
1575
+ if (currentSize === void 0) {
1576
+ counts.skippedExisting++;
1577
+ return null;
1578
+ }
1579
+ if (prior.sourceSizeBytes === void 0) {
1580
+ counts.skippedLegacy++;
1581
+ return null;
1582
+ }
1583
+ if (currentSize === prior.sourceSizeBytes) {
1584
+ counts.skippedExisting++;
1585
+ return null;
1586
+ }
1587
+ if (currentSize < prior.sourceSizeBytes) {
1588
+ console.error(
1589
+ `Import: ${externalId} source shrank (${currentSize} < ${prior.sourceSizeBytes} bytes); re-import skipped (use --force to replace)`
1590
+ );
1591
+ counts.skippedDecreased++;
1592
+ return null;
1593
+ }
1594
+ return prior;
1514
1595
  }
1515
1596
  function encodeProjectDir(projectPath) {
1516
1597
  return projectPath.replaceAll("/", "-");
1517
1598
  }
1518
1599
  async function loadExistingByExternalId(paths, sourceKind) {
1519
1600
  const byExternalId = /* @__PURE__ */ new Map();
1520
- const add = (externalId, sessionId) => {
1601
+ const add = (externalId, prior) => {
1521
1602
  const list = byExternalId.get(externalId);
1522
- if (list === void 0) byExternalId.set(externalId, [sessionId]);
1523
- else list.push(sessionId);
1603
+ if (list === void 0) byExternalId.set(externalId, [prior]);
1604
+ else list.push(prior);
1524
1605
  };
1525
1606
  let sessionIds;
1526
1607
  try {
@@ -1536,14 +1617,16 @@ async function loadExistingByExternalId(paths, sourceKind) {
1536
1617
  continue;
1537
1618
  }
1538
1619
  if (session.session.source.kind !== sourceKind) continue;
1620
+ const sourceSizeBytes = session.session.source.source_size_bytes;
1621
+ const prior = sourceSizeBytes !== void 0 ? { sessionId, sourceSizeBytes } : { sessionId };
1539
1622
  const ext = session.session.source.external_id;
1540
1623
  if (typeof ext === "string" && ext.length > 0) {
1541
- add(ext, sessionId);
1624
+ add(ext, prior);
1542
1625
  continue;
1543
1626
  }
1544
1627
  const label = session.session.label;
1545
1628
  const match = typeof label === "string" ? label.match(/^claude-code import (\S+)$/) : null;
1546
- if (match?.[1] !== void 0) add(match[1], sessionId);
1629
+ if (match?.[1] !== void 0) add(match[1], prior);
1547
1630
  }
1548
1631
  return byExternalId;
1549
1632
  }
@@ -1589,6 +1672,14 @@ async function pathExists(file) {
1589
1672
  throw error;
1590
1673
  }
1591
1674
  }
1675
+ async function statSize(file) {
1676
+ try {
1677
+ return (await stat(file)).size;
1678
+ } catch (error) {
1679
+ if (findErrorCode5(error, "ENOENT")) return void 0;
1680
+ throw error;
1681
+ }
1682
+ }
1592
1683
  async function discoverCodexRollouts(sessionsRoot, projectPaths, options) {
1593
1684
  const projectSet = new Set(projectPaths);
1594
1685
  const files = await findRolloutFiles(sessionsRoot);
@@ -1667,9 +1758,9 @@ async function readFirstLine(file) {
1667
1758
  }
1668
1759
  }
1669
1760
  async function readJsonlRecords(file) {
1670
- let body;
1761
+ let buffer;
1671
1762
  try {
1672
- body = await readFile(file, "utf8");
1763
+ buffer = await readFile(file);
1673
1764
  } catch (error) {
1674
1765
  if (findErrorCode5(error, "ENOENT")) {
1675
1766
  throw new Error("Source log not found", { cause: error });
@@ -1680,7 +1771,7 @@ async function readJsonlRecords(file) {
1680
1771
  throw new Error("Failed to read source log", { cause: error });
1681
1772
  }
1682
1773
  const records = [];
1683
- for (const line of body.split("\n")) {
1774
+ for (const line of buffer.toString("utf8").split("\n")) {
1684
1775
  const trimmed = line.trim();
1685
1776
  if (trimmed.length === 0) continue;
1686
1777
  try {
@@ -1691,7 +1782,7 @@ async function readJsonlRecords(file) {
1691
1782
  } catch {
1692
1783
  }
1693
1784
  }
1694
- return records;
1785
+ return { records, sizeBytes: buffer.length };
1695
1786
  }
1696
1787
  function isObject(value) {
1697
1788
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -1699,7 +1790,15 @@ function isObject(value) {
1699
1790
  function printImportResult(options, results, counts) {
1700
1791
  const isDry = options.dryRun === true;
1701
1792
  const eventTotal = results.reduce((sum, r) => sum + r.eventCount, 0);
1702
- const { skippedNoAction, skippedExisting, replaced } = counts;
1793
+ const {
1794
+ skippedNoAction,
1795
+ skippedExisting,
1796
+ replaced,
1797
+ reimported,
1798
+ skippedLegacy,
1799
+ skippedDecreased,
1800
+ skippedDuplicate
1801
+ } = counts;
1703
1802
  if (options.json === true) {
1704
1803
  console.log(
1705
1804
  JSON.stringify({
@@ -1711,8 +1810,12 @@ function printImportResult(options, results, counts) {
1711
1810
  })),
1712
1811
  imported_count: results.length,
1713
1812
  replaced_count: replaced,
1813
+ reimported_count: reimported,
1714
1814
  skipped_no_action: skippedNoAction,
1715
1815
  skipped_already_imported: skippedExisting,
1816
+ skipped_legacy_untracked: skippedLegacy,
1817
+ skipped_decreased: skippedDecreased,
1818
+ skipped_duplicate: skippedDuplicate,
1716
1819
  event_total: eventTotal,
1717
1820
  dry_run: isDry
1718
1821
  })
@@ -1722,20 +1825,36 @@ function printImportResult(options, results, counts) {
1722
1825
  const skipParts = [];
1723
1826
  if (skippedNoAction > 0) skipParts.push(`${skippedNoAction} with no actions`);
1724
1827
  if (skippedExisting > 0) skipParts.push(`${skippedExisting} already imported`);
1828
+ if (skippedLegacy > 0) skipParts.push(`${skippedLegacy} legacy (untracked size)`);
1829
+ if (skippedDecreased > 0) skipParts.push(`${skippedDecreased} shrank`);
1830
+ if (skippedDuplicate > 0) skipParts.push(`${skippedDuplicate} duplicated`);
1725
1831
  const skipSuffix = skipParts.length > 0 ? `; skipped ${skipParts.join(", ")}` : "";
1726
1832
  const eventsPart = replaced > 0 ? `${eventTotal} events, ${replaced} replaced` : `${eventTotal} events`;
1727
- if (results.length === 0) {
1833
+ if (isDry) {
1834
+ const parts = [];
1835
+ if (results.length > 0) parts.push(`import ${results.length} session(s) (${eventsPart})`);
1836
+ if (reimported > 0) parts.push(`re-import ${reimported} changed session(s)`);
1837
+ const head = parts.length > 0 ? `Dry run: would ${parts.join(", ")}` : "Dry run: no changes";
1838
+ console.log(`${head}${skipSuffix}`);
1839
+ return;
1840
+ }
1841
+ if (results.length === 0 && reimported === 0) {
1728
1842
  console.log(
1729
1843
  skipParts.length > 0 ? `No new sessions imported (skipped ${skipParts.join(", ")})` : "No transcripts found to import"
1730
1844
  );
1731
1845
  return;
1732
1846
  }
1733
- if (isDry) {
1734
- console.log(`Dry run: would import ${results.length} session(s) (${eventsPart})${skipSuffix}`);
1735
- return;
1847
+ const segments = [];
1848
+ if (results.length > 0) {
1849
+ const single = results.length === 1 && results[0] !== void 0 ? ` (${shortId2(results[0].sessionId)})` : "";
1850
+ segments.push(`Imported ${results.length} session(s)${single} (${eventsPart})`);
1851
+ }
1852
+ if (reimported > 0) {
1853
+ segments.push(
1854
+ `${results.length > 0 ? "re-imported" : "Re-imported"} ${reimported} changed session(s)`
1855
+ );
1736
1856
  }
1737
- const single = results.length === 1 && results[0] !== void 0 ? ` (${shortId2(results[0].sessionId)})` : "";
1738
- console.log(`Imported ${results.length} session(s)${single} (${eventsPart})${skipSuffix}`);
1857
+ console.log(`${segments.join(", ")}${skipSuffix}`);
1739
1858
  }
1740
1859
  function shortId2(id) {
1741
1860
  if (id.startsWith(SES_PREFIX2)) {
@@ -1908,8 +2027,10 @@ async function runImport(adapter, fn) {
1908
2027
  status: "ran",
1909
2028
  importedCount: readCount(json.imported_count),
1910
2029
  replacedCount: readCount(json.replaced_count),
2030
+ reimportedCount: readCount(json.reimported_count),
1911
2031
  skippedNoAction: readCount(json.skipped_no_action),
1912
2032
  skippedAlreadyImported: readCount(json.skipped_already_imported),
2033
+ skippedLegacyUntracked: readCount(json.skipped_legacy_untracked),
1913
2034
  eventTotal: readCount(json.event_total),
1914
2035
  dryRun: json.dry_run === true
1915
2036
  };
@@ -2030,11 +2151,13 @@ function scansEqual(a, b) {
2030
2151
  }
2031
2152
  return true;
2032
2153
  }
2033
- function importedCount(outcome) {
2034
- return outcome.status === "ran" ? outcome.importedCount : 0;
2154
+ function changedCount(outcome) {
2155
+ return outcome.status === "ran" ? outcome.importedCount + outcome.reimportedCount + outcome.replacedCount : 0;
2035
2156
  }
2036
2157
  function describeOutcome(outcome) {
2037
- return outcome.status === "ran" ? `${outcome.adapter} +${outcome.importedCount}` : `${outcome.adapter} skipped`;
2158
+ if (outcome.status !== "ran") return `${outcome.adapter} skipped`;
2159
+ const reimported = outcome.reimportedCount > 0 ? ` ~${outcome.reimportedCount}` : "";
2160
+ return `${outcome.adapter} +${outcome.importedCount}${reimported}`;
2038
2161
  }
2039
2162
  function hms(date) {
2040
2163
  return date.toISOString().slice(11, 19);
@@ -2042,7 +2165,7 @@ function hms(date) {
2042
2165
  async function runImports(deps) {
2043
2166
  const claude = await importClaudeCode(deps.importOptions, deps.ctx);
2044
2167
  const codex = await importCodex(deps.importOptions, deps.ctx);
2045
- return { claude, codex, imported: importedCount(claude) + importedCount(codex) };
2168
+ return { claude, codex, changed: changedCount(claude) + changedCount(codex) };
2046
2169
  }
2047
2170
  async function regenerate(deps) {
2048
2171
  const nowIso = deps.now().toISOString();
@@ -2074,8 +2197,8 @@ async function runRefreshWatch(deps) {
2074
2197
  try {
2075
2198
  const current = await scanSourceLogs(roots);
2076
2199
  if (scansEqual(current, lastScan) && !scansEqual(current, importedScan)) {
2077
- const { claude, codex, imported } = await runImports(deps);
2078
- if (imported > 0) pendingRegen = true;
2200
+ const { claude, codex, changed } = await runImports(deps);
2201
+ if (changed > 0) pendingRegen = true;
2079
2202
  if (pendingRegen) {
2080
2203
  const sessions = await regenerate(deps);
2081
2204
  pendingRegen = false;
@@ -2214,9 +2337,11 @@ function describeImport(outcome) {
2214
2337
  }
2215
2338
  const verb = outcome.dryRun ? "would import" : "imported";
2216
2339
  const parts = [`${outcome.importedCount} session(s)`, `${outcome.eventTotal} events`];
2340
+ if (outcome.reimportedCount > 0) parts.push(`${outcome.reimportedCount} re-imported`);
2217
2341
  if (outcome.replacedCount > 0) parts.push(`${outcome.replacedCount} replaced`);
2218
2342
  if (outcome.skippedAlreadyImported > 0)
2219
2343
  parts.push(`${outcome.skippedAlreadyImported} already imported`);
2344
+ if (outcome.skippedLegacyUntracked > 0) parts.push(`${outcome.skippedLegacyUntracked} legacy`);
2220
2345
  return `${outcome.adapter}: ${verb} ${parts.join(", ")}`;
2221
2346
  }
2222
2347
  function printRefreshSummary(result) {