reasonix 0.0.5 → 0.2.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/cli/index.js CHANGED
@@ -783,6 +783,93 @@ function signature2(call) {
783
783
  return `${call.function?.name ?? ""}::${call.function?.arguments ?? ""}`;
784
784
  }
785
785
 
786
+ // src/session.ts
787
+ import {
788
+ appendFileSync,
789
+ chmodSync,
790
+ existsSync,
791
+ mkdirSync,
792
+ readFileSync,
793
+ readdirSync,
794
+ statSync,
795
+ unlinkSync
796
+ } from "fs";
797
+ import { homedir } from "os";
798
+ import { dirname, join } from "path";
799
+ function sessionsDir() {
800
+ return join(homedir(), ".reasonix", "sessions");
801
+ }
802
+ function sessionPath(name) {
803
+ return join(sessionsDir(), `${sanitizeName(name)}.jsonl`);
804
+ }
805
+ function sanitizeName(name) {
806
+ const cleaned = name.replace(/[^\w\-\u4e00-\u9fa5]/g, "_").slice(0, 64);
807
+ return cleaned || "default";
808
+ }
809
+ function loadSessionMessages(name) {
810
+ const path = sessionPath(name);
811
+ if (!existsSync(path)) return [];
812
+ try {
813
+ const raw = readFileSync(path, "utf8");
814
+ const out = [];
815
+ for (const line of raw.split(/\r?\n/)) {
816
+ const trimmed = line.trim();
817
+ if (!trimmed) continue;
818
+ try {
819
+ const msg = JSON.parse(trimmed);
820
+ if (msg && typeof msg === "object" && "role" in msg) out.push(msg);
821
+ } catch {
822
+ }
823
+ }
824
+ return out;
825
+ } catch {
826
+ return [];
827
+ }
828
+ }
829
+ function appendSessionMessage(name, message) {
830
+ const path = sessionPath(name);
831
+ mkdirSync(dirname(path), { recursive: true });
832
+ appendFileSync(path, `${JSON.stringify(message)}
833
+ `, "utf8");
834
+ try {
835
+ chmodSync(path, 384);
836
+ } catch {
837
+ }
838
+ }
839
+ function listSessions() {
840
+ const dir = sessionsDir();
841
+ if (!existsSync(dir)) return [];
842
+ try {
843
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
844
+ return files.map((file) => {
845
+ const path = join(dir, file);
846
+ const stat = statSync(path);
847
+ const name = file.replace(/\.jsonl$/, "");
848
+ const messageCount = countLines(path);
849
+ return { name, path, size: stat.size, messageCount, mtime: stat.mtime };
850
+ }).sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
851
+ } catch {
852
+ return [];
853
+ }
854
+ }
855
+ function deleteSession(name) {
856
+ const path = sessionPath(name);
857
+ try {
858
+ unlinkSync(path);
859
+ return true;
860
+ } catch {
861
+ return false;
862
+ }
863
+ }
864
+ function countLines(path) {
865
+ try {
866
+ const raw = readFileSync(path, "utf8");
867
+ return raw.split(/\r?\n/).filter((l) => l.trim()).length;
868
+ } catch {
869
+ return 0;
870
+ }
871
+ }
872
+
786
873
  // src/telemetry.ts
787
874
  var DEEPSEEK_PRICING = {
788
875
  "deepseek-chat": { inputCacheHit: 0.07, inputCacheMiss: 0.27, output: 1.1 },
@@ -939,6 +1026,9 @@ var CacheFirstLoop = class {
939
1026
  harvestOptions;
940
1027
  branchEnabled;
941
1028
  branchOptions;
1029
+ sessionName;
1030
+ /** Number of messages that were pre-loaded from the session file. */
1031
+ resumedMessageCount;
942
1032
  _turn = 0;
943
1033
  _streamPreference;
944
1034
  constructor(opts) {
@@ -962,6 +1052,23 @@ var CacheFirstLoop = class {
962
1052
  this.stream = this.branchEnabled ? false : this._streamPreference;
963
1053
  const allowedNames = /* @__PURE__ */ new Set([...this.prefix.toolSpecs.map((s) => s.function.name)]);
964
1054
  this.repair = new ToolCallRepair({ allowedToolNames: allowedNames });
1055
+ this.sessionName = opts.session ?? null;
1056
+ if (this.sessionName) {
1057
+ const prior = loadSessionMessages(this.sessionName);
1058
+ for (const msg of prior) this.log.append(msg);
1059
+ this.resumedMessageCount = prior.length;
1060
+ } else {
1061
+ this.resumedMessageCount = 0;
1062
+ }
1063
+ }
1064
+ appendAndPersist(message) {
1065
+ this.log.append(message);
1066
+ if (this.sessionName) {
1067
+ try {
1068
+ appendSessionMessage(this.sessionName, message);
1069
+ } catch {
1070
+ }
1071
+ }
965
1072
  }
966
1073
  /**
967
1074
  * Reconfigure model/harvest/branch/stream mid-session. The loop's log,
@@ -1149,7 +1256,7 @@ var CacheFirstLoop = class {
1149
1256
  }
1150
1257
  const turnStats = this.stats.record(this._turn, this.model, usage ?? new Usage());
1151
1258
  if (pendingUser !== null) {
1152
- this.log.append({ role: "user", content: pendingUser });
1259
+ this.appendAndPersist({ role: "user", content: pendingUser });
1153
1260
  pendingUser = null;
1154
1261
  }
1155
1262
  this.scratch.reasoning = reasoningContent || null;
@@ -1158,7 +1265,7 @@ var CacheFirstLoop = class {
1158
1265
  toolCalls,
1159
1266
  reasoningContent || null
1160
1267
  );
1161
- this.log.append(this.assistantMessage(assistantContent, repairedCalls));
1268
+ this.appendAndPersist(this.assistantMessage(assistantContent, repairedCalls));
1162
1269
  yield {
1163
1270
  turn: this._turn,
1164
1271
  role: "assistant_final",
@@ -1176,13 +1283,19 @@ var CacheFirstLoop = class {
1176
1283
  const name = call.function?.name ?? "";
1177
1284
  const args = call.function?.arguments ?? "{}";
1178
1285
  const result = await this.tools.dispatch(name, args);
1179
- this.log.append({
1286
+ this.appendAndPersist({
1180
1287
  role: "tool",
1181
1288
  tool_call_id: call.id ?? "",
1182
1289
  name,
1183
1290
  content: result
1184
1291
  });
1185
- yield { turn: this._turn, role: "tool", content: result, toolName: name };
1292
+ yield {
1293
+ turn: this._turn,
1294
+ role: "tool",
1295
+ content: result,
1296
+ toolName: name,
1297
+ toolArgs: args
1298
+ };
1186
1299
  }
1187
1300
  }
1188
1301
  yield { turn: this._turn, role: "done", content: "[max_tool_iters reached]" };
@@ -1212,12 +1325,12 @@ function summarizeBranch(chosen, samples) {
1212
1325
  }
1213
1326
 
1214
1327
  // src/env.ts
1215
- import { readFileSync } from "fs";
1328
+ import { readFileSync as readFileSync2 } from "fs";
1216
1329
  import { resolve } from "path";
1217
1330
  function loadDotenv(path = ".env") {
1218
1331
  let raw;
1219
1332
  try {
1220
- raw = readFileSync(resolve(process.cwd(), path), "utf8");
1333
+ raw = readFileSync2(resolve(process.cwd(), path), "utf8");
1221
1334
  } catch {
1222
1335
  return;
1223
1336
  }
@@ -1235,16 +1348,439 @@ function loadDotenv(path = ".env") {
1235
1348
  }
1236
1349
  }
1237
1350
 
1351
+ // src/transcript.ts
1352
+ import { createWriteStream, readFileSync as readFileSync3 } from "fs";
1353
+ function recordFromLoopEvent(ev, extra) {
1354
+ const rec = {
1355
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1356
+ turn: ev.turn,
1357
+ role: ev.role,
1358
+ content: ev.content
1359
+ };
1360
+ if (ev.toolName !== void 0) rec.tool = ev.toolName;
1361
+ if (ev.toolArgs !== void 0) rec.args = ev.toolArgs;
1362
+ if (ev.error !== void 0) rec.error = ev.error;
1363
+ if (ev.stats) {
1364
+ rec.usage = {
1365
+ prompt_tokens: ev.stats.usage.promptTokens,
1366
+ completion_tokens: ev.stats.usage.completionTokens,
1367
+ total_tokens: ev.stats.usage.totalTokens,
1368
+ prompt_cache_hit_tokens: ev.stats.usage.promptCacheHitTokens,
1369
+ prompt_cache_miss_tokens: ev.stats.usage.promptCacheMissTokens
1370
+ };
1371
+ rec.cost = ev.stats.cost;
1372
+ rec.model = ev.stats.model;
1373
+ rec.prefixHash = extra.prefixHash;
1374
+ } else if (ev.role === "assistant_final") {
1375
+ rec.model = extra.model;
1376
+ rec.prefixHash = extra.prefixHash;
1377
+ }
1378
+ return rec;
1379
+ }
1380
+ function writeRecord(stream, record) {
1381
+ stream.write(`${JSON.stringify(record)}
1382
+ `);
1383
+ }
1384
+ function writeMeta(stream, meta) {
1385
+ const line = { role: "_meta", meta };
1386
+ stream.write(`${JSON.stringify(line)}
1387
+ `);
1388
+ }
1389
+ function openTranscriptFile(path, meta) {
1390
+ const stream = createWriteStream(path, { flags: "a" });
1391
+ writeMeta(stream, meta);
1392
+ return stream;
1393
+ }
1394
+ function readTranscript(path) {
1395
+ const raw = readFileSync3(path, "utf8");
1396
+ return parseTranscript(raw);
1397
+ }
1398
+ function parseTranscript(raw) {
1399
+ const out = { meta: null, records: [] };
1400
+ for (const line of raw.split(/\r?\n/)) {
1401
+ const trimmed = line.trim();
1402
+ if (!trimmed) continue;
1403
+ let obj;
1404
+ try {
1405
+ obj = JSON.parse(trimmed);
1406
+ } catch {
1407
+ continue;
1408
+ }
1409
+ if (!obj || typeof obj !== "object") continue;
1410
+ const rec = obj;
1411
+ if (rec.role === "_meta" && rec.meta && typeof rec.meta === "object") {
1412
+ out.meta = rec.meta;
1413
+ continue;
1414
+ }
1415
+ if (typeof rec.ts === "string" && typeof rec.turn === "number" && typeof rec.role === "string" && typeof rec.content === "string") {
1416
+ out.records.push(rec);
1417
+ }
1418
+ }
1419
+ return out;
1420
+ }
1421
+
1422
+ // src/replay.ts
1423
+ function replayFromFile(path) {
1424
+ const parsed = readTranscript(path);
1425
+ return { parsed, stats: computeReplayStats(parsed.records) };
1426
+ }
1427
+ function computeReplayStats(records) {
1428
+ const turns = [];
1429
+ const models = /* @__PURE__ */ new Set();
1430
+ const prefixHashes = /* @__PURE__ */ new Set();
1431
+ let userTurns = 0;
1432
+ let toolCalls = 0;
1433
+ for (const rec of records) {
1434
+ if (rec.role === "user") userTurns++;
1435
+ else if (rec.role === "tool") toolCalls++;
1436
+ else if (rec.role === "assistant_final") {
1437
+ if (rec.model) models.add(rec.model);
1438
+ if (rec.prefixHash) prefixHashes.add(rec.prefixHash);
1439
+ if (rec.usage && rec.model) {
1440
+ const u = new Usage(
1441
+ rec.usage.prompt_tokens ?? 0,
1442
+ rec.usage.completion_tokens ?? 0,
1443
+ rec.usage.total_tokens ?? 0,
1444
+ rec.usage.prompt_cache_hit_tokens ?? 0,
1445
+ rec.usage.prompt_cache_miss_tokens ?? 0
1446
+ );
1447
+ turns.push({
1448
+ turn: rec.turn,
1449
+ model: rec.model,
1450
+ usage: u,
1451
+ // `rec.cost` wins when present — honors whatever the writer computed
1452
+ // even if pricing tables have since changed. Only recompute when
1453
+ // the transcript didn't record it (old format).
1454
+ cost: rec.cost ?? costUsd(rec.model, u),
1455
+ cacheHitRatio: u.cacheHitRatio
1456
+ });
1457
+ }
1458
+ }
1459
+ }
1460
+ return {
1461
+ perTurn: turns,
1462
+ models: [...models],
1463
+ prefixHashes: [...prefixHashes],
1464
+ userTurns,
1465
+ toolCalls,
1466
+ ...summarizeTurns(turns)
1467
+ };
1468
+ }
1469
+ function summarizeTurns(turns) {
1470
+ const totalCost = turns.reduce((s, t) => s + t.cost, 0);
1471
+ const totalClaude = turns.reduce((s, t) => s + claudeEquivalentCost(t.usage), 0);
1472
+ let hit = 0;
1473
+ let miss = 0;
1474
+ for (const t of turns) {
1475
+ hit += t.usage.promptCacheHitTokens;
1476
+ miss += t.usage.promptCacheMissTokens;
1477
+ }
1478
+ const cacheHitRatio = hit + miss > 0 ? hit / (hit + miss) : 0;
1479
+ const savingsVsClaude = totalClaude > 0 ? 1 - totalCost / totalClaude : 0;
1480
+ return {
1481
+ turns: turns.length,
1482
+ totalCostUsd: round2(totalCost, 6),
1483
+ claudeEquivalentUsd: round2(totalClaude, 6),
1484
+ savingsVsClaudePct: round2(savingsVsClaude * 100, 2),
1485
+ cacheHitRatio: round2(cacheHitRatio, 4)
1486
+ };
1487
+ }
1488
+ function round2(n, digits) {
1489
+ const f = 10 ** digits;
1490
+ return Math.round(n * f) / f;
1491
+ }
1492
+
1493
+ // src/diff.ts
1494
+ function diffTranscripts(a, b) {
1495
+ const aSide = {
1496
+ label: a.label,
1497
+ meta: a.parsed.meta,
1498
+ records: a.parsed.records,
1499
+ stats: computeReplayStats(a.parsed.records)
1500
+ };
1501
+ const bSide = {
1502
+ label: b.label,
1503
+ meta: b.parsed.meta,
1504
+ records: b.parsed.records,
1505
+ stats: computeReplayStats(b.parsed.records)
1506
+ };
1507
+ const aByTurn = groupByTurn(a.parsed.records);
1508
+ const bByTurn = groupByTurn(b.parsed.records);
1509
+ const turns = [.../* @__PURE__ */ new Set([...aByTurn.keys(), ...bByTurn.keys()])].sort((x, y) => x - y);
1510
+ const pairs = [];
1511
+ let firstDivergenceTurn = null;
1512
+ for (const turn of turns) {
1513
+ const aGroup = aByTurn.get(turn) ?? { assistant: void 0, tools: [] };
1514
+ const bGroup = bByTurn.get(turn) ?? { assistant: void 0, tools: [] };
1515
+ const aAssistant = aGroup.assistant;
1516
+ const bAssistant = bGroup.assistant;
1517
+ const aTools = aGroup.tools;
1518
+ const bTools = bGroup.tools;
1519
+ let kind;
1520
+ let divergenceNote;
1521
+ if (!aAssistant && bAssistant) kind = "only_in_b";
1522
+ else if (aAssistant && !bAssistant) kind = "only_in_a";
1523
+ else if (!aAssistant && !bAssistant)
1524
+ kind = "diverge";
1525
+ else {
1526
+ divergenceNote = classifyDivergence(aAssistant, bAssistant, aTools, bTools);
1527
+ kind = divergenceNote ? "diverge" : "match";
1528
+ }
1529
+ if (kind !== "match" && firstDivergenceTurn === null) firstDivergenceTurn = turn;
1530
+ pairs.push({ turn, aAssistant, bAssistant, aTools, bTools, kind, divergenceNote });
1531
+ }
1532
+ return { a: aSide, b: bSide, pairs, firstDivergenceTurn };
1533
+ }
1534
+ function classifyDivergence(a, b, aTools, bTools) {
1535
+ const aNames = aTools.map((t) => t.tool ?? "").sort();
1536
+ const bNames = bTools.map((t) => t.tool ?? "").sort();
1537
+ if (aNames.join(",") !== bNames.join(",")) {
1538
+ return `tool calls differ: A=[${aNames.join(",") || "\u2014"}] B=[${bNames.join(",") || "\u2014"}]`;
1539
+ }
1540
+ for (let i = 0; i < aTools.length; i++) {
1541
+ const at = aTools[i];
1542
+ const bt = bTools[i];
1543
+ if (at.tool !== bt.tool) continue;
1544
+ if ((at.args ?? "") !== (bt.args ?? "")) {
1545
+ return `"${at.tool}" args differ`;
1546
+ }
1547
+ }
1548
+ const simRatio = similarity(a.content, b.content);
1549
+ if (simRatio < 0.75) return `text similarity ${(simRatio * 100).toFixed(0)}%`;
1550
+ return void 0;
1551
+ }
1552
+ function similarity(a, b) {
1553
+ if (a === b) return 1;
1554
+ if (!a && !b) return 1;
1555
+ if (!a || !b) return 0;
1556
+ const maxLen = Math.max(a.length, b.length);
1557
+ if (maxLen > 2e3) return tokenOverlap(a, b);
1558
+ const dist = levenshtein(a, b);
1559
+ return 1 - dist / maxLen;
1560
+ }
1561
+ function tokenOverlap(a, b) {
1562
+ const ta = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
1563
+ const tb = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
1564
+ if (ta.size === 0 && tb.size === 0) return 1;
1565
+ let shared = 0;
1566
+ for (const t of ta) if (tb.has(t)) shared++;
1567
+ return 2 * shared / (ta.size + tb.size);
1568
+ }
1569
+ function levenshtein(a, b) {
1570
+ const m = a.length;
1571
+ const n = b.length;
1572
+ if (m === 0) return n;
1573
+ if (n === 0) return m;
1574
+ let prev = new Array(n + 1);
1575
+ let curr = new Array(n + 1);
1576
+ for (let j = 0; j <= n; j++) prev[j] = j;
1577
+ for (let i = 1; i <= m; i++) {
1578
+ curr[0] = i;
1579
+ for (let j = 1; j <= n; j++) {
1580
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1581
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
1582
+ }
1583
+ [prev, curr] = [curr, prev];
1584
+ }
1585
+ return prev[n];
1586
+ }
1587
+ function groupByTurn(records) {
1588
+ const out = /* @__PURE__ */ new Map();
1589
+ for (const rec of records) {
1590
+ if (rec.role === "user") continue;
1591
+ const g = out.get(rec.turn) ?? { tools: [] };
1592
+ if (rec.role === "assistant_final") g.assistant = rec;
1593
+ else if (rec.role === "tool") g.tools.push(rec);
1594
+ out.set(rec.turn, g);
1595
+ }
1596
+ return out;
1597
+ }
1598
+ function renderSummaryTable(report, _opts = {}) {
1599
+ const a = report.a;
1600
+ const b = report.b;
1601
+ const lines = [];
1602
+ lines.push("Comparing:");
1603
+ lines.push(` A ${a.label}`);
1604
+ lines.push(` B ${b.label}`);
1605
+ lines.push("");
1606
+ lines.push(row(["", "A", "B", "\u0394"], [20, 14, 14, 14]));
1607
+ lines.push(
1608
+ row(["\u2500".repeat(20), "\u2500".repeat(14), "\u2500".repeat(14), "\u2500".repeat(14)], [20, 14, 14, 14])
1609
+ );
1610
+ lines.push(statRow("model calls", a.stats.turns, b.stats.turns));
1611
+ lines.push(statRow("user turns", a.stats.userTurns, b.stats.userTurns));
1612
+ lines.push(statRow("tool calls", a.stats.toolCalls, b.stats.toolCalls));
1613
+ lines.push(
1614
+ row(
1615
+ [
1616
+ "cache hit",
1617
+ `${pct(a.stats.cacheHitRatio)}`,
1618
+ `${pct(b.stats.cacheHitRatio)}`,
1619
+ signPct(b.stats.cacheHitRatio - a.stats.cacheHitRatio)
1620
+ ],
1621
+ [20, 14, 14, 14]
1622
+ )
1623
+ );
1624
+ lines.push(
1625
+ row(
1626
+ [
1627
+ "cost (USD)",
1628
+ `$${a.stats.totalCostUsd.toFixed(6)}`,
1629
+ `$${b.stats.totalCostUsd.toFixed(6)}`,
1630
+ costDelta(a.stats.totalCostUsd, b.stats.totalCostUsd)
1631
+ ],
1632
+ [20, 14, 14, 14]
1633
+ )
1634
+ );
1635
+ lines.push(statRow("prefix hashes", a.stats.prefixHashes.length, b.stats.prefixHashes.length));
1636
+ lines.push("");
1637
+ const aPrefixStable = a.stats.prefixHashes.length <= 1;
1638
+ const bPrefixStable = b.stats.prefixHashes.length <= 1;
1639
+ if (aPrefixStable !== bPrefixStable) {
1640
+ const stable = aPrefixStable ? "A" : "B";
1641
+ const churn = aPrefixStable ? "B" : "A";
1642
+ const churnCount = aPrefixStable ? b.stats.prefixHashes.length : a.stats.prefixHashes.length;
1643
+ lines.push(
1644
+ `prefix stability: ${stable} stayed byte-stable across ${Math.max(
1645
+ a.stats.turns,
1646
+ b.stats.turns
1647
+ )} turns; ${churn} churned ${churnCount} distinct prefixes.`
1648
+ );
1649
+ lines.push("");
1650
+ } else if (a.stats.prefixHashes[0] && a.stats.prefixHashes[0] === b.stats.prefixHashes[0]) {
1651
+ lines.push(
1652
+ `prefix: A and B share the same prefix hash (${a.stats.prefixHashes[0].slice(0, 12)}\u2026) \u2014 cache delta is attributable to log stability, not prompt change.`
1653
+ );
1654
+ lines.push("");
1655
+ }
1656
+ if (report.firstDivergenceTurn !== null) {
1657
+ const p = report.pairs.find((p2) => p2.turn === report.firstDivergenceTurn);
1658
+ lines.push(
1659
+ `first divergence: turn ${report.firstDivergenceTurn} \u2014 ${p?.divergenceNote ?? "?"}`
1660
+ );
1661
+ if (p?.aAssistant) lines.push(` A \u2192 ${truncate(p.aAssistant.content, 100)}`);
1662
+ if (p?.bAssistant) lines.push(` B \u2192 ${truncate(p.bAssistant.content, 100)}`);
1663
+ } else {
1664
+ lines.push("no material divergence detected (texts within similarity threshold).");
1665
+ }
1666
+ return lines.join("\n");
1667
+ }
1668
+ function renderMarkdown(report) {
1669
+ const a = report.a;
1670
+ const b = report.b;
1671
+ const out = [];
1672
+ out.push(`# Transcript diff: ${a.label} vs ${b.label}`);
1673
+ out.push("");
1674
+ if (a.meta || b.meta) {
1675
+ out.push("## Meta");
1676
+ out.push("");
1677
+ out.push(`| | ${a.label} | ${b.label} |`);
1678
+ out.push("|---|---|---|");
1679
+ out.push(`| source | ${a.meta?.source ?? "\u2014"} | ${b.meta?.source ?? "\u2014"} |`);
1680
+ out.push(`| model | ${a.meta?.model ?? "\u2014"} | ${b.meta?.model ?? "\u2014"} |`);
1681
+ out.push(`| task | ${a.meta?.task ?? "\u2014"} | ${b.meta?.task ?? "\u2014"} |`);
1682
+ out.push(`| startedAt | ${a.meta?.startedAt ?? "\u2014"} | ${b.meta?.startedAt ?? "\u2014"} |`);
1683
+ out.push("");
1684
+ }
1685
+ out.push("## Summary");
1686
+ out.push("");
1687
+ out.push(`| metric | ${a.label} | ${b.label} | delta |`);
1688
+ out.push("|---|---:|---:|---:|");
1689
+ out.push(
1690
+ `| model calls | ${a.stats.turns} | ${b.stats.turns} | ${signed(b.stats.turns - a.stats.turns)} |`
1691
+ );
1692
+ out.push(
1693
+ `| user turns | ${a.stats.userTurns} | ${b.stats.userTurns} | ${signed(b.stats.userTurns - a.stats.userTurns)} |`
1694
+ );
1695
+ out.push(
1696
+ `| tool calls | ${a.stats.toolCalls} | ${b.stats.toolCalls} | ${signed(b.stats.toolCalls - a.stats.toolCalls)} |`
1697
+ );
1698
+ out.push(
1699
+ `| cache hit | ${pct(a.stats.cacheHitRatio)} | ${pct(b.stats.cacheHitRatio)} | **${signPct(b.stats.cacheHitRatio - a.stats.cacheHitRatio)}** |`
1700
+ );
1701
+ out.push(
1702
+ `| cost (USD) | $${a.stats.totalCostUsd.toFixed(6)} | $${b.stats.totalCostUsd.toFixed(6)} | ${costDelta(a.stats.totalCostUsd, b.stats.totalCostUsd)} |`
1703
+ );
1704
+ out.push(
1705
+ `| prefix hashes | ${a.stats.prefixHashes.length} | ${b.stats.prefixHashes.length} | \u2014 |`
1706
+ );
1707
+ out.push("");
1708
+ out.push("## Turn-by-turn");
1709
+ out.push("");
1710
+ out.push(`| turn | kind | ${a.label} tool calls | ${b.label} tool calls | note |`);
1711
+ out.push("|---:|:---:|---|---|---|");
1712
+ for (const p of report.pairs) {
1713
+ const aTools = p.aTools.map((t) => t.tool).filter(Boolean).join(", ") || "\u2014";
1714
+ const bTools = p.bTools.map((t) => t.tool).filter(Boolean).join(", ") || "\u2014";
1715
+ out.push(`| ${p.turn} | ${p.kind} | ${aTools} | ${bTools} | ${p.divergenceNote ?? ""} |`);
1716
+ }
1717
+ out.push("");
1718
+ if (report.firstDivergenceTurn !== null) {
1719
+ const p = report.pairs.find((x) => x.turn === report.firstDivergenceTurn);
1720
+ out.push(`## First divergence (turn ${report.firstDivergenceTurn})`);
1721
+ out.push("");
1722
+ out.push(p?.divergenceNote ?? "");
1723
+ out.push("");
1724
+ if (p?.aAssistant) {
1725
+ out.push(`**${a.label}:**`);
1726
+ out.push("");
1727
+ out.push("```");
1728
+ out.push(p.aAssistant.content);
1729
+ out.push("```");
1730
+ out.push("");
1731
+ }
1732
+ if (p?.bAssistant) {
1733
+ out.push(`**${b.label}:**`);
1734
+ out.push("");
1735
+ out.push("```");
1736
+ out.push(p.bAssistant.content);
1737
+ out.push("```");
1738
+ out.push("");
1739
+ }
1740
+ }
1741
+ return out.join("\n");
1742
+ }
1743
+ function row(cols, widths) {
1744
+ return cols.map((c, i) => padRight(c, widths[i] ?? c.length)).join(" ");
1745
+ }
1746
+ function statRow(label, av, bv) {
1747
+ return row([label, `${av}`, `${bv}`, signed(bv - av)], [20, 14, 14, 14]);
1748
+ }
1749
+ function padRight(s, w) {
1750
+ return s.length >= w ? s : s + " ".repeat(w - s.length);
1751
+ }
1752
+ function signed(n) {
1753
+ if (n === 0) return "0";
1754
+ return `${n > 0 ? "+" : ""}${n}`;
1755
+ }
1756
+ function signPct(diff) {
1757
+ if (diff === 0) return "0pp";
1758
+ const s = (diff * 100).toFixed(1);
1759
+ return `${diff > 0 ? "+" : ""}${s}pp`;
1760
+ }
1761
+ function pct(x) {
1762
+ return `${(x * 100).toFixed(1)}%`;
1763
+ }
1764
+ function costDelta(a, b) {
1765
+ if (a === 0 && b === 0) return "\u2014";
1766
+ if (a === 0) return "new";
1767
+ const pctChange = (b - a) / a * 100;
1768
+ return `${pctChange > 0 ? "+" : ""}${pctChange.toFixed(1)}%`;
1769
+ }
1770
+ function truncate(s, n) {
1771
+ return s.length > n ? `${s.slice(0, n)}\u2026` : s;
1772
+ }
1773
+
1238
1774
  // src/config.ts
1239
- import { chmodSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
1240
- import { homedir } from "os";
1241
- import { dirname, join } from "path";
1775
+ import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync } from "fs";
1776
+ import { homedir as homedir2 } from "os";
1777
+ import { dirname as dirname2, join as join2 } from "path";
1242
1778
  function defaultConfigPath() {
1243
- return join(homedir(), ".reasonix", "config.json");
1779
+ return join2(homedir2(), ".reasonix", "config.json");
1244
1780
  }
1245
1781
  function readConfig(path = defaultConfigPath()) {
1246
1782
  try {
1247
- const raw = readFileSync2(path, "utf8");
1783
+ const raw = readFileSync4(path, "utf8");
1248
1784
  const parsed = JSON.parse(raw);
1249
1785
  if (parsed && typeof parsed === "object") return parsed;
1250
1786
  } catch {
@@ -1252,10 +1788,10 @@ function readConfig(path = defaultConfigPath()) {
1252
1788
  return {};
1253
1789
  }
1254
1790
  function writeConfig(cfg, path = defaultConfigPath()) {
1255
- mkdirSync(dirname(path), { recursive: true });
1791
+ mkdirSync2(dirname2(path), { recursive: true });
1256
1792
  writeFileSync(path, JSON.stringify(cfg, null, 2), "utf8");
1257
1793
  try {
1258
- chmodSync(path, 384);
1794
+ chmodSync2(path, 384);
1259
1795
  } catch {
1260
1796
  }
1261
1797
  }
@@ -1279,14 +1815,13 @@ function redactKey(key) {
1279
1815
  }
1280
1816
 
1281
1817
  // src/index.ts
1282
- var VERSION = "0.0.1";
1818
+ var VERSION = "0.2.0";
1283
1819
 
1284
1820
  // src/cli/commands/chat.tsx
1285
1821
  import { render } from "ink";
1286
1822
  import React7, { useState as useState4 } from "react";
1287
1823
 
1288
1824
  // src/cli/ui/App.tsx
1289
- import { createWriteStream } from "fs";
1290
1825
  import { Box as Box5, Static, Text as Text5, useApp } from "ink";
1291
1826
  import React5, { useCallback, useEffect as useEffect2, useMemo, useRef, useState as useState2 } from "react";
1292
1827
 
@@ -1494,7 +2029,7 @@ var EventRow = React2.memo(function EventRow2({ event }) {
1494
2029
  return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "green" }, "assistant")), event.branch ? /* @__PURE__ */ React2.createElement(BranchBlock, { branch: event.branch }) : null, event.reasoning ? /* @__PURE__ */ React2.createElement(ReasoningBlock, { reasoning: event.reasoning }) : null, !isPlanStateEmpty(event.planState) ? /* @__PURE__ */ React2.createElement(PlanStateBlock, { planState: event.planState }) : null, event.text ? /* @__PURE__ */ React2.createElement(Markdown, { text: event.text }) : /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "(no content)"), event.stats ? /* @__PURE__ */ React2.createElement(StatsLine, { stats: event.stats }) : null, event.repair ? /* @__PURE__ */ React2.createElement(Text2, { color: "magenta" }, event.repair) : null);
1495
2030
  }
1496
2031
  if (event.role === "tool") {
1497
- return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, { color: "yellow" }, `tool<${event.toolName ?? "?"}> \u2192`), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, " ", truncate(event.text, 400)));
2032
+ return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, { color: "yellow" }, `tool<${event.toolName ?? "?"}> \u2192`), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, " ", truncate2(event.text, 400)));
1498
2033
  }
1499
2034
  if (event.role === "error") {
1500
2035
  return /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, { color: "red", bold: true }, "error", " "), /* @__PURE__ */ React2.createElement(Text2, { color: "red" }, event.text));
@@ -1543,8 +2078,8 @@ function StreamingAssistant({ event }) {
1543
2078
  if (p.completed === 0) {
1544
2079
  return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "green" }, "assistant", " "), /* @__PURE__ */ React2.createElement(Text2, { color: "blue" }, "\u{1F500} launching ", p.total, " parallel samples (R1 thinking in parallel)\u2026", " "), /* @__PURE__ */ React2.createElement(Elapsed, null)), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, " ", "spread across T=0.0/0.5/1.0 \xB7 typical wait 30-90s for reasoner"));
1545
2080
  }
1546
- const pct = Math.round(p.completed / p.total * 100);
1547
- return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "green" }, "assistant", " "), /* @__PURE__ */ React2.createElement(Text2, { color: "blue" }, "\u{1F500} branching ", p.completed, "/", p.total, " (", pct, "%)", " "), /* @__PURE__ */ React2.createElement(Elapsed, null)), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, " latest #", p.latestIndex, " T=", p.latestTemperature.toFixed(1), " u=", p.latestUncertainties, p.completed < p.total ? " \xB7 waiting for other samples\u2026" : " \xB7 selecting winner\u2026"));
2081
+ const pct2 = Math.round(p.completed / p.total * 100);
2082
+ return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", marginTop: 1 }, /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "green" }, "assistant", " "), /* @__PURE__ */ React2.createElement(Text2, { color: "blue" }, "\u{1F500} branching ", p.completed, "/", p.total, " (", pct2, "%)", " "), /* @__PURE__ */ React2.createElement(Elapsed, null)), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, " latest #", p.latestIndex, " T=", p.latestTemperature.toFixed(1), " u=", p.latestUncertainties, p.completed < p.total ? " \xB7 waiting for other samples\u2026" : " \xB7 selecting winner\u2026"));
1548
2083
  }
1549
2084
  const tail = lastLine(event.text, 140);
1550
2085
  const reasoningTail = event.reasoning ? lastLine(event.reasoning, 120) : "";
@@ -1559,7 +2094,7 @@ function StatsLine({ stats }) {
1559
2094
  const hit = (stats.cacheHitRatio * 100).toFixed(1);
1560
2095
  return /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, " \u21B3 cache ", hit, "% \xB7 tokens ", stats.usage.promptTokens, "\u2192", stats.usage.completionTokens, " \xB7 $", stats.cost.toFixed(6));
1561
2096
  }
1562
- function truncate(s, max) {
2097
+ function truncate2(s, max) {
1563
2098
  return s.length <= max ? s : `${s.slice(0, max)}\u2026 (+${s.length - max} chars)`;
1564
2099
  }
1565
2100
 
@@ -1629,15 +2164,51 @@ function handleSlash(cmd, args, loop) {
1629
2164
  " /model <id> deepseek-chat or deepseek-reasoner",
1630
2165
  " /harvest [on|off] Pillar 2: structured plan-state extraction",
1631
2166
  " /branch <N|off> run N parallel samples (N>=2), pick most confident",
1632
- " /clear clear displayed history (log is kept)",
2167
+ " /sessions list saved sessions (current is marked with \u25B8)",
2168
+ " /forget delete the current session from disk",
2169
+ " /clear clear displayed history (log + session kept)",
1633
2170
  " /exit quit",
1634
2171
  "",
1635
2172
  "Presets:",
1636
2173
  " fast deepseek-chat no harvest no branch ~1\xA2/100turns \u2190 default",
1637
2174
  " smart reasoner harvest ~10x cost, slower",
1638
- " max reasoner harvest branch 3 ~30x cost, slowest"
2175
+ " max reasoner harvest branch 3 ~30x cost, slowest",
2176
+ "",
2177
+ "Sessions (auto-enabled by default, named 'default'):",
2178
+ " reasonix chat --session <name> use a different named session",
2179
+ " reasonix chat --no-session disable persistence for this run"
1639
2180
  ].join("\n")
1640
2181
  };
2182
+ case "sessions": {
2183
+ const items = listSessions();
2184
+ if (items.length === 0) {
2185
+ return {
2186
+ info: "no saved sessions yet \u2014 chat normally and your messages will be saved automatically"
2187
+ };
2188
+ }
2189
+ const lines = ["Saved sessions:"];
2190
+ for (const s of items) {
2191
+ const sizeKb = (s.size / 1024).toFixed(1);
2192
+ const when = s.mtime.toISOString().replace("T", " ").slice(0, 16);
2193
+ const marker = s.name === loop.sessionName ? "\u25B8" : " ";
2194
+ lines.push(
2195
+ ` ${marker} ${s.name.padEnd(22)} ${String(s.messageCount).padStart(5)} msgs ${sizeKb.padStart(7)} KB ${when}`
2196
+ );
2197
+ }
2198
+ lines.push("");
2199
+ lines.push("Resume with: reasonix chat --session <name>");
2200
+ return { info: lines.join("\n") };
2201
+ }
2202
+ case "forget": {
2203
+ if (!loop.sessionName) {
2204
+ return { info: "not in a session \u2014 nothing to forget" };
2205
+ }
2206
+ const name = loop.sessionName;
2207
+ const ok = deleteSession(name);
2208
+ return {
2209
+ info: ok ? `\u25B8 deleted session "${name}" \u2014 current screen still shows the conversation, but next launch starts fresh` : `could not delete session "${name}" (already gone?)`
2210
+ };
2211
+ }
1641
2212
  case "status": {
1642
2213
  const branchBudget = loop.branchOptions.budget ?? 1;
1643
2214
  return {
@@ -1697,7 +2268,7 @@ function handleSlash(cmd, args, loop) {
1697
2268
 
1698
2269
  // src/cli/ui/App.tsx
1699
2270
  var FLUSH_INTERVAL_MS = 60;
1700
- function App({ model, system, transcript, harvest: harvest2, branch }) {
2271
+ function App({ model, system, transcript, harvest: harvest2, branch, session }) {
1701
2272
  const { exit } = useApp();
1702
2273
  const [historical, setHistorical] = useState2([]);
1703
2274
  const [streaming, setStreaming] = useState2(null);
@@ -1712,7 +2283,12 @@ function App({ model, system, transcript, harvest: harvest2, branch }) {
1712
2283
  });
1713
2284
  const transcriptRef = useRef(null);
1714
2285
  if (transcript && !transcriptRef.current) {
1715
- transcriptRef.current = createWriteStream(transcript, { flags: "a" });
2286
+ transcriptRef.current = openTranscriptFile(transcript, {
2287
+ version: 1,
2288
+ source: "reasonix chat",
2289
+ model,
2290
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
2291
+ });
1716
2292
  }
1717
2293
  useEffect2(() => {
1718
2294
  return () => {
@@ -1724,23 +2300,52 @@ function App({ model, system, transcript, harvest: harvest2, branch }) {
1724
2300
  if (loopRef.current) return loopRef.current;
1725
2301
  const client = new DeepSeekClient();
1726
2302
  const prefix = new ImmutablePrefix({ system });
1727
- const l = new CacheFirstLoop({ client, prefix, model, harvest: harvest2, branch });
2303
+ const l = new CacheFirstLoop({ client, prefix, model, harvest: harvest2, branch, session });
1728
2304
  loopRef.current = l;
1729
2305
  return l;
1730
- }, [model, system, harvest2, branch]);
2306
+ }, [model, system, harvest2, branch, session]);
2307
+ const sessionBannerShown = useRef(false);
2308
+ useEffect2(() => {
2309
+ if (sessionBannerShown.current) return;
2310
+ sessionBannerShown.current = true;
2311
+ if (!session) {
2312
+ setHistorical((prev) => [
2313
+ ...prev,
2314
+ {
2315
+ id: `sys-session-${Date.now()}`,
2316
+ role: "info",
2317
+ text: "\u25B8 ephemeral chat (no session persistence) \u2014 drop --no-session to enable"
2318
+ }
2319
+ ]);
2320
+ } else if (loop.resumedMessageCount > 0) {
2321
+ setHistorical((prev) => [
2322
+ ...prev,
2323
+ {
2324
+ id: `sys-resume-${Date.now()}`,
2325
+ role: "info",
2326
+ text: `\u25B8 resumed session "${session}" with ${loop.resumedMessageCount} prior messages \xB7 /forget to start over \xB7 /sessions to list`
2327
+ }
2328
+ ]);
2329
+ } else {
2330
+ setHistorical((prev) => [
2331
+ ...prev,
2332
+ {
2333
+ id: `sys-newsession-${Date.now()}`,
2334
+ role: "info",
2335
+ text: `\u25B8 session "${session}" (new) \u2014 auto-saved as you chat \xB7 /forget to delete \xB7 /sessions to list`
2336
+ }
2337
+ ]);
2338
+ }
2339
+ }, [session, loop]);
1731
2340
  const prefixHash = loop.prefix.fingerprint;
1732
- const writeTranscript = useCallback((ev) => {
1733
- transcriptRef.current?.write(
1734
- `${JSON.stringify({
1735
- ts: (/* @__PURE__ */ new Date()).toISOString(),
1736
- turn: ev.turn,
1737
- role: ev.role,
1738
- content: ev.content,
1739
- tool: ev.toolName
1740
- })}
1741
- `
1742
- );
1743
- }, []);
2341
+ const writeTranscript = useCallback(
2342
+ (ev) => {
2343
+ const stream = transcriptRef.current;
2344
+ if (!stream) return;
2345
+ writeRecord(stream, recordFromLoopEvent(ev, { model, prefixHash }));
2346
+ },
2347
+ [model, prefixHash]
2348
+ );
1744
2349
  const handleSubmit = useCallback(
1745
2350
  async (raw) => {
1746
2351
  const text = raw.trim();
@@ -1873,7 +2478,7 @@ function App({ model, system, transcript, harvest: harvest2, branch }) {
1873
2478
  ), /* @__PURE__ */ React5.createElement(Static, { items: historical }, (item) => /* @__PURE__ */ React5.createElement(EventRow, { key: item.id, event: item })), streaming ? /* @__PURE__ */ React5.createElement(Box5, { marginY: 1 }, /* @__PURE__ */ React5.createElement(EventRow, { event: streaming })) : null, /* @__PURE__ */ React5.createElement(PromptInput, { value: input, onChange: setInput, onSubmit: handleSubmit, disabled: busy }), /* @__PURE__ */ React5.createElement(CommandStrip, null));
1874
2479
  }
1875
2480
  function CommandStrip() {
1876
- return /* @__PURE__ */ React5.createElement(Box5, { paddingX: 2 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "/help \xB7 /preset ", "<fast|smart|max>", " \xB7 /model \xB7 /harvest \xB7 /branch \xB7 /clear \xB7 /exit"));
2481
+ return /* @__PURE__ */ React5.createElement(Box5, { paddingX: 2 }, /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "/help \xB7 /preset ", "<fast|smart|max>", " \xB7 /sessions \xB7 /model \xB7 /harvest \xB7 /branch \xB7 /clear \xB7 /exit"));
1877
2482
  }
1878
2483
  function describeRepair(repair) {
1879
2484
  const parts = [];
@@ -1944,7 +2549,8 @@ function Root({ initialKey, ...appProps }) {
1944
2549
  system: appProps.system,
1945
2550
  transcript: appProps.transcript,
1946
2551
  harvest: appProps.harvest,
1947
- branch: appProps.branch
2552
+ branch: appProps.branch,
2553
+ session: appProps.session
1948
2554
  }
1949
2555
  );
1950
2556
  }
@@ -1957,6 +2563,93 @@ async function chatCommand(opts) {
1957
2563
  await waitUntilExit();
1958
2564
  }
1959
2565
 
2566
+ // src/cli/commands/diff.ts
2567
+ import { writeFileSync as writeFileSync2 } from "fs";
2568
+ import { basename } from "path";
2569
+ function diffCommand(opts) {
2570
+ const aParsed = readTranscript(opts.a);
2571
+ const bParsed = readTranscript(opts.b);
2572
+ const report = diffTranscripts(
2573
+ { label: opts.labelA ?? basename(opts.a), parsed: aParsed },
2574
+ { label: opts.labelB ?? basename(opts.b), parsed: bParsed }
2575
+ );
2576
+ console.log(renderSummaryTable(report));
2577
+ if (opts.mdPath) {
2578
+ const md = renderMarkdown(report);
2579
+ writeFileSync2(opts.mdPath, md, "utf8");
2580
+ console.log(`
2581
+ markdown report written to ${opts.mdPath}`);
2582
+ }
2583
+ }
2584
+
2585
+ // src/cli/commands/replay.ts
2586
+ function replayCommand(opts) {
2587
+ const { parsed, stats } = replayFromFile(opts.path);
2588
+ if (parsed.meta) {
2589
+ const m = parsed.meta;
2590
+ const bits = [`source=${m.source}`];
2591
+ if (m.model) bits.push(`model=${m.model}`);
2592
+ if (m.task) bits.push(`task=${m.task}`);
2593
+ if (m.mode) bits.push(`mode=${m.mode}`);
2594
+ if (m.repeat !== void 0) bits.push(`repeat=${m.repeat}`);
2595
+ bits.push(`started=${m.startedAt}`);
2596
+ console.log(`[meta] ${bits.join(" ")}`);
2597
+ console.log("");
2598
+ }
2599
+ const records = sliceRecords(parsed.records, opts);
2600
+ for (const rec of records) {
2601
+ renderRecord(rec);
2602
+ }
2603
+ console.log("");
2604
+ console.log("\u2500\u2500 summary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
2605
+ console.log(`model calls: ${stats.turns}`);
2606
+ console.log(`user turns: ${stats.userTurns}`);
2607
+ console.log(`tool calls: ${stats.toolCalls}`);
2608
+ console.log(`cache hit: ${(stats.cacheHitRatio * 100).toFixed(1)}%`);
2609
+ console.log(`cost: $${stats.totalCostUsd.toFixed(6)}`);
2610
+ console.log(`claude equivalent: $${stats.claudeEquivalentUsd.toFixed(6)}`);
2611
+ console.log(`savings vs claude: ${stats.savingsVsClaudePct.toFixed(1)}%`);
2612
+ console.log(`models: ${stats.models.join(", ") || "\u2014"}`);
2613
+ console.log(`prefix hashes: ${stats.prefixHashes.length} distinct`);
2614
+ if (stats.prefixHashes.length === 1) {
2615
+ console.log(` (byte-stable prefix: ${stats.prefixHashes[0]?.slice(0, 16)}\u2026)`);
2616
+ } else if (stats.prefixHashes.length > 1) {
2617
+ console.log(" (prefix churned \u2014 cache-hostile session)");
2618
+ }
2619
+ }
2620
+ function sliceRecords(records, opts) {
2621
+ if (opts.head !== void 0 && opts.head > 0) return records.slice(0, opts.head);
2622
+ if (opts.tail !== void 0 && opts.tail > 0) return records.slice(-opts.tail);
2623
+ return records;
2624
+ }
2625
+ function renderRecord(rec) {
2626
+ const turn = `[t${rec.turn}]`;
2627
+ if (rec.role === "user") {
2628
+ console.log(`${turn} USER: ${oneLine(rec.content)}`);
2629
+ } else if (rec.role === "assistant_final") {
2630
+ const cost = rec.cost !== void 0 ? ` $${rec.cost.toFixed(6)}` : "";
2631
+ const cache = rec.usage && (rec.usage.prompt_cache_hit_tokens !== void 0 || rec.usage.prompt_cache_miss_tokens !== void 0) ? (() => {
2632
+ const hit = rec.usage.prompt_cache_hit_tokens ?? 0;
2633
+ const miss = rec.usage.prompt_cache_miss_tokens ?? 0;
2634
+ const total = hit + miss;
2635
+ return total > 0 ? ` cache=${(hit / total * 100).toFixed(1)}%` : "";
2636
+ })() : "";
2637
+ console.log(`${turn} AGENT:${cost}${cache} ${oneLine(rec.content)}`);
2638
+ } else if (rec.role === "tool") {
2639
+ const args = rec.args ? ` args=${oneLine(rec.args, 80)}` : "";
2640
+ console.log(`${turn} TOOL ${rec.tool ?? "?"}:${args} \u2192 ${oneLine(rec.content, 120)}`);
2641
+ } else if (rec.role === "error") {
2642
+ console.log(`${turn} ERROR: ${rec.error ?? rec.content}`);
2643
+ } else if (rec.role === "done") {
2644
+ } else {
2645
+ console.log(`${turn} ${rec.role}: ${oneLine(rec.content)}`);
2646
+ }
2647
+ }
2648
+ function oneLine(s, max = 200) {
2649
+ const collapsed = s.replace(/\s+/g, " ").trim();
2650
+ return collapsed.length > max ? `${collapsed.slice(0, max)}\u2026` : collapsed;
2651
+ }
2652
+
1960
2653
  // src/cli/commands/run.ts
1961
2654
  import { stdin, stdout } from "process";
1962
2655
  import { createInterface } from "readline/promises";
@@ -2017,13 +2710,13 @@ async function runCommand(opts) {
2017
2710
  }
2018
2711
 
2019
2712
  // src/cli/commands/stats.ts
2020
- import { existsSync, readFileSync as readFileSync3 } from "fs";
2713
+ import { existsSync as existsSync2, readFileSync as readFileSync5 } from "fs";
2021
2714
  function statsCommand(opts) {
2022
- if (!existsSync(opts.transcript)) {
2715
+ if (!existsSync2(opts.transcript)) {
2023
2716
  console.error(`no such transcript: ${opts.transcript}`);
2024
2717
  process.exit(1);
2025
2718
  }
2026
- const lines = readFileSync3(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
2719
+ const lines = readFileSync5(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
2027
2720
  let assistantTurns = 0;
2028
2721
  let toolCalls = 0;
2029
2722
  let lastTurn = 0;
@@ -2058,13 +2751,25 @@ program.command("chat").description("Interactive Ink TUI with live cache/cost pa
2058
2751
  "--branch <n>",
2059
2752
  "Self-consistency: run N parallel samples per turn and pick the most confident (disables streaming; enables harvest)",
2060
2753
  (v) => Number.parseInt(v, 10)
2061
- ).action(async (opts) => {
2754
+ ).option(
2755
+ "--session <name>",
2756
+ "Use a named session (default: 'default'). Resume the same session next time."
2757
+ ).option("--no-session", "Disable session persistence for this run (ephemeral chat)").action(async (opts) => {
2758
+ let session;
2759
+ if (opts.session === false) {
2760
+ session = void 0;
2761
+ } else if (typeof opts.session === "string" && opts.session.length > 0) {
2762
+ session = opts.session;
2763
+ } else {
2764
+ session = "default";
2765
+ }
2062
2766
  await chatCommand({
2063
2767
  model: opts.model,
2064
2768
  system: opts.system,
2065
2769
  transcript: opts.transcript,
2066
2770
  harvest: !!opts.harvest,
2067
- branch: Number.isFinite(opts.branch) && opts.branch > 1 ? opts.branch : void 0
2771
+ branch: Number.isFinite(opts.branch) && opts.branch > 1 ? opts.branch : void 0,
2772
+ session
2068
2773
  });
2069
2774
  });
2070
2775
  program.command("run <task>").description("Run a single task non-interactively, streaming output.").option("-m, --model <id>", "DeepSeek model id", "deepseek-chat").option("-s, --system <prompt>", "System prompt", DEFAULT_SYSTEM).action(async (task, opts) => {
@@ -2073,6 +2778,18 @@ program.command("run <task>").description("Run a single task non-interactively,
2073
2778
  program.command("stats <transcript>").description("Summarize a JSONL transcript produced by `reasonix chat --transcript`.").action((transcript) => {
2074
2779
  statsCommand({ transcript });
2075
2780
  });
2781
+ program.command("replay <transcript>").description(
2782
+ "Pretty-print a transcript + rebuild its session summary (cost, cache, prefix stability). No API calls."
2783
+ ).option("--head <n>", "Show only the first N records", (v) => Number.parseInt(v, 10)).option("--tail <n>", "Show only the last N records", (v) => Number.parseInt(v, 10)).action((transcript, opts) => {
2784
+ replayCommand({
2785
+ path: transcript,
2786
+ head: Number.isFinite(opts.head) ? opts.head : void 0,
2787
+ tail: Number.isFinite(opts.tail) ? opts.tail : void 0
2788
+ });
2789
+ });
2790
+ program.command("diff <a> <b>").description("Compare two transcripts: aggregate deltas + first divergence.").option("--md <path>", "Also write a markdown report (blog-ready) to this path").option("--label-a <label>", "Display label for transcript A (default: filename)").option("--label-b <label>", "Display label for transcript B (default: filename)").action((a, b, opts) => {
2791
+ diffCommand({ a, b, mdPath: opts.md, labelA: opts.labelA, labelB: opts.labelB });
2792
+ });
2076
2793
  program.command("version").description("Print Reasonix version.").action(versionCommand);
2077
2794
  program.parseAsync(process.argv).catch((err) => {
2078
2795
  console.error(err);