@pentoshi/clai 3.8.49 → 3.8.51

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.
Files changed (59) hide show
  1. package/dist/agent/responder-context.js +1 -1
  2. package/dist/agent/responder-context.js.map +1 -1
  3. package/dist/agent/runner.js +1 -1
  4. package/dist/agent/runner.js.map +1 -1
  5. package/dist/agent/tool-call-parser.js +2 -1
  6. package/dist/agent/tool-call-parser.js.map +1 -1
  7. package/dist/app/adapters/current-jobs-adapter.js +4 -0
  8. package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
  9. package/dist/app/controllers/job-controller.d.ts +4 -0
  10. package/dist/app/controllers/job-controller.js +12 -0
  11. package/dist/app/controllers/job-controller.js.map +1 -1
  12. package/dist/app/controllers/plan-controller.d.ts +1 -0
  13. package/dist/app/controllers/plan-controller.js +15 -0
  14. package/dist/app/controllers/plan-controller.js.map +1 -1
  15. package/dist/app/controllers/session-controller.d.ts +2 -0
  16. package/dist/app/controllers/session-controller.js +32 -10
  17. package/dist/app/controllers/session-controller.js.map +1 -1
  18. package/dist/app/controllers/session-responder.d.ts +19 -23
  19. package/dist/app/controllers/session-responder.js +157 -262
  20. package/dist/app/controllers/session-responder.js.map +1 -1
  21. package/dist/app/ports/jobs-port.d.ts +4 -0
  22. package/dist/index.js +3 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/repl.js +14 -68
  25. package/dist/repl.js.map +1 -1
  26. package/dist/store/history-index.d.ts +43 -0
  27. package/dist/store/history-index.js +197 -0
  28. package/dist/store/history-index.js.map +1 -0
  29. package/dist/store/history.d.ts +6 -5
  30. package/dist/store/history.js +246 -205
  31. package/dist/store/history.js.map +1 -1
  32. package/dist/store/plan.d.ts +1 -1
  33. package/dist/store/plan.js +17 -8
  34. package/dist/store/plan.js.map +1 -1
  35. package/dist/store/responder-settlement.d.ts +3 -0
  36. package/dist/store/responder-settlement.js +60 -0
  37. package/dist/store/responder-settlement.js.map +1 -0
  38. package/dist/tools/definitions.js +16 -20
  39. package/dist/tools/definitions.js.map +1 -1
  40. package/dist/tools/jobs.d.ts +25 -6
  41. package/dist/tools/jobs.js +251 -51
  42. package/dist/tools/jobs.js.map +1 -1
  43. package/dist/tools/registry.js +18 -23
  44. package/dist/tools/registry.js.map +1 -1
  45. package/dist/tui-v2/app/commands/picker-commands.js +9 -10
  46. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  47. package/dist/tui-v2/bootstrap/composition-root.js +9 -0
  48. package/dist/tui-v2/bootstrap/composition-root.js.map +1 -1
  49. package/dist/tui-v2/components/jobs/jobs-panel.js +49 -26
  50. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  51. package/dist/tui-v2/components/status/status-line.d.ts +2 -0
  52. package/dist/tui-v2/components/status/status-line.js +23 -2
  53. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  54. package/dist/tui-v2/state/transcript-hydrate.d.ts +8 -2
  55. package/dist/tui-v2/state/transcript-hydrate.js +99 -9
  56. package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
  57. package/dist/version.generated.d.ts +2 -2
  58. package/dist/version.generated.js +2 -2
  59. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
- import { appendFile, copyFile, mkdir, readdir, open, readFile, rm, stat, utimes, writeFile, rename, } from "node:fs/promises";
2
+ import { appendFile, copyFile, mkdir, readdir, open, readFile, rm, stat, utimes, } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { isInternalChatMessage, } from "../types.js";
5
5
  import { detectModelImageMediaType, MAX_IMAGE_BYTES, } from "../attachments/image-content.js";
@@ -9,6 +9,7 @@ import { safeCwd } from "../os/cwd.js";
9
9
  import { fixOwner, fixOwnerSync, handlePermissionError, safeExists } from "../os/permissions.js";
10
10
  import { getHistoryDir } from "./paths.js";
11
11
  import { getActiveSessionWorkspace } from "./session-workspace.js";
12
+ import { findHistoryRecordStreaming, historySummary, readIndexedHistoryRecord, readValidatedHistoryIndex, rebuildHistoryIndex, writeIndexedJsonl, } from "./history-index.js";
12
13
  /** Live paths so CLAI_DATA_DIR / CLAI_HISTORY_DIR always apply (and tests work). */
13
14
  function historyDirPath() {
14
15
  return getHistoryDir();
@@ -19,6 +20,9 @@ function dbFilePath() {
19
20
  function jsonlFilePath() {
20
21
  return join(historyDirPath(), "history.jsonl");
21
22
  }
23
+ function jsonlIndexFilePath() {
24
+ return join(historyDirPath(), "history.index.json");
25
+ }
22
26
  function jsonlLockFilePath() {
23
27
  return join(historyDirPath(), "history.jsonl.lock");
24
28
  }
@@ -86,6 +90,9 @@ async function loadDatabase() {
86
90
  writer_generation TEXT,
87
91
  revision INTEGER NOT NULL DEFAULT 0,
88
92
  cwd TEXT NOT NULL,
93
+ message_count INTEGER NOT NULL DEFAULT 0,
94
+ item_count INTEGER NOT NULL DEFAULT 0,
95
+ has_images INTEGER NOT NULL DEFAULT 0,
89
96
  messages_json TEXT NOT NULL
90
97
  );
91
98
  CREATE TABLE IF NOT EXISTS tool_calls (
@@ -111,6 +118,15 @@ async function loadDatabase() {
111
118
  if (!sessionColumns.some((column) => column.name === "revision")) {
112
119
  cachedDb.exec("ALTER TABLE sessions ADD COLUMN revision INTEGER NOT NULL DEFAULT 0;");
113
120
  }
121
+ if (!sessionColumns.some((column) => column.name === "message_count")) {
122
+ cachedDb.exec("ALTER TABLE sessions ADD COLUMN message_count INTEGER NOT NULL DEFAULT 0;");
123
+ }
124
+ if (!sessionColumns.some((column) => column.name === "item_count")) {
125
+ cachedDb.exec("ALTER TABLE sessions ADD COLUMN item_count INTEGER NOT NULL DEFAULT 0;");
126
+ }
127
+ if (!sessionColumns.some((column) => column.name === "has_images")) {
128
+ cachedDb.exec("ALTER TABLE sessions ADD COLUMN has_images INTEGER NOT NULL DEFAULT 0;");
129
+ }
114
130
  return cachedDb;
115
131
  }
116
132
  catch (err) {
@@ -137,22 +153,31 @@ function scrubMessages(messages) {
137
153
  };
138
154
  });
139
155
  }
140
- function hydrateMessageImages(messages) {
156
+ const MAX_RESTORED_IMAGE_COUNT = 6;
157
+ const MAX_RESTORED_IMAGE_BYTES = 15_000_000;
158
+ export function materializeHistoryImages(messages) {
159
+ let imageCount = 0;
160
+ let totalBytes = 0;
141
161
  return messages.map((message) => {
142
162
  if (!message.images?.length)
143
- return message;
163
+ return { ...message };
144
164
  const images = message.images.flatMap((image) => {
145
165
  if (image.dataBase64)
146
166
  return [image];
147
- if (!image.path)
167
+ if (!image.path || imageCount >= MAX_RESTORED_IMAGE_COUNT)
148
168
  return [];
149
169
  try {
150
- if (statSync(image.path).size > MAX_IMAGE_BYTES)
170
+ const size = statSync(image.path).size;
171
+ if (size > MAX_IMAGE_BYTES ||
172
+ totalBytes + size > MAX_RESTORED_IMAGE_BYTES) {
151
173
  return [];
174
+ }
152
175
  const bytes = readFileSync(image.path);
153
176
  const mediaType = detectModelImageMediaType(bytes);
154
177
  if (!mediaType)
155
178
  return [];
179
+ imageCount += 1;
180
+ totalBytes += bytes.length;
156
181
  return [
157
182
  {
158
183
  mediaType,
@@ -379,15 +404,6 @@ export function compareHistoryFreshness(left, right) {
379
404
  return 0;
380
405
  return updatedAtMs(left) - updatedAtMs(right);
381
406
  }
382
- function freshestHistoryRecord(records) {
383
- let freshest;
384
- for (const record of records) {
385
- if (!freshest || compareHistoryFreshness(record, freshest) > 0) {
386
- freshest = record;
387
- }
388
- }
389
- return freshest;
390
- }
391
407
  /** Keep the newest captured version of each session id. */
392
408
  export function dedupeHistoryById(records) {
393
409
  const byId = new Map();
@@ -480,103 +496,100 @@ async function backupActiveHistory() {
480
496
  // Backup is best-effort; never block the autosave path.
481
497
  }
482
498
  }
483
- /**
484
- * Scan leftover write temps + the archive for sessions missing from the
485
- * active file and merge them back. Fixes history that was pruned by the old
486
- * slice(-200) retention or left in .tmp after a crashed rename.
487
- */
499
+ // Restore backups only for a missing/corrupt active file, then merge orphan write temps.
488
500
  export async function recoverOrphanedHistory() {
489
501
  const sources = [];
490
- const extras = [];
502
+ const tempSources = [];
491
503
  const releaseLock = await acquireJsonlWriteLock();
492
504
  try {
493
- try {
494
- const names = await readdir(historyDirPath());
495
- for (const name of names) {
496
- // Live write temps: history.jsonl.<pid>.<stamp>.tmp
497
- if (name.startsWith("history.jsonl.") &&
498
- name.endsWith(".tmp")) {
499
- const path = join(historyDirPath(), name);
500
- const rows = await readJsonlRecordsFrom(path);
501
- if (rows.length === 0) {
502
- // Empty crash leftovers — safe to remove.
503
- await rm(path, { force: true }).catch(() => undefined);
504
- continue;
505
+ const activePath = jsonlFilePath();
506
+ const activeExists = await safeExists(activePath);
507
+ let activeCorrupt = false;
508
+ let active = [];
509
+ if (activeExists) {
510
+ try {
511
+ const raw = await readFile(activePath, "utf8");
512
+ const lines = raw.split("\n").filter((line) => line.trim().length > 0);
513
+ for (const line of lines) {
514
+ try {
515
+ active.push(JSON.parse(line));
516
+ }
517
+ catch {
518
+ activeCorrupt = true;
505
519
  }
506
- extras.push(...rows);
507
- sources.push(name);
508
520
  }
509
521
  }
510
- }
511
- catch {
512
- /* dir may not exist yet */
513
- }
514
- // Also fold in archive (sessions previously pruned).
515
- if (await safeExists(archiveFilePath())) {
516
- const archived = await readJsonlRecordsFrom(archiveFilePath());
517
- if (archived.length > 0) {
518
- extras.push(...archived);
519
- sources.push("history-archive.jsonl");
522
+ catch (error) {
523
+ if (error?.code === "EACCES")
524
+ handlePermissionError(error);
525
+ activeCorrupt = true;
520
526
  }
521
527
  }
522
- // Rolling backups (last-resort recovery of wiped active files).
523
- try {
524
- if (await safeExists(backupDirPath())) {
528
+ const backupRecords = [];
529
+ if (!activeExists || activeCorrupt) {
530
+ try {
525
531
  const backups = (await readdir(backupDirPath()))
526
- .filter((n) => n.startsWith("history-") && n.endsWith(".jsonl"))
532
+ .filter((name) => name.startsWith("history-") && name.endsWith(".jsonl"))
527
533
  .sort()
528
- .reverse()
529
- .slice(0, 3);
534
+ .reverse();
530
535
  for (const name of backups) {
531
536
  const rows = await readJsonlRecordsFrom(join(backupDirPath(), name));
532
- if (rows.length > 0) {
533
- extras.push(...rows);
534
- sources.push(`history-backups/${name}`);
535
- }
537
+ if (rows.length === 0)
538
+ continue;
539
+ backupRecords.push(...rows);
540
+ sources.push(`history-backups/${name}`);
541
+ break;
542
+ }
543
+ }
544
+ catch {
545
+ // No usable backup directory.
546
+ }
547
+ }
548
+ const extras = [];
549
+ try {
550
+ const names = await readdir(historyDirPath());
551
+ for (const name of names) {
552
+ if (!name.startsWith("history.jsonl.") || !name.endsWith(".tmp")) {
553
+ continue;
536
554
  }
555
+ const path = join(historyDirPath(), name);
556
+ const rows = await readJsonlRecordsFrom(path);
557
+ if (rows.length === 0) {
558
+ await rm(path, { force: true }).catch(() => undefined);
559
+ continue;
560
+ }
561
+ extras.push(...rows);
562
+ sources.push(name);
563
+ tempSources.push(name);
537
564
  }
538
565
  }
539
566
  catch {
540
- /* ignore */
567
+ // History directory may not exist yet.
541
568
  }
542
- if (extras.length === 0)
543
- return { recovered: 0, sources: [] };
544
- const active = await readJsonlRecordsFrom(jsonlFilePath());
545
569
  const activeById = new Map(active.map((record) => [record.id, record]));
546
- const merged = dedupeHistoryById([...active, ...extras]);
570
+ const merged = dedupeHistoryById([...active, ...backupRecords, ...extras]);
547
571
  const recoveredCount = merged.filter((record) => {
548
572
  const previous = activeById.get(record.id);
549
573
  return !previous || compareHistoryFreshness(record, previous) > 0;
550
574
  }).length;
551
- if (recoveredCount === 0) {
575
+ const needsRewrite = activeCorrupt ||
576
+ (!activeExists && backupRecords.length > 0) ||
577
+ recoveredCount > 0;
578
+ if (!needsRewrite)
552
579
  return { recovered: 0, sources };
553
- }
554
- // Write WITHOUT applying retention so recovery cannot re-prune.
555
580
  await mkdir(historyDirPath(), { recursive: true });
556
581
  await fixOwner(historyDirPath());
557
- if (await safeExists(jsonlFilePath()))
582
+ if (activeExists && !activeCorrupt)
558
583
  await backupActiveHistory();
559
584
  const sorted = sortHistoryByUpdatedDesc(merged);
560
- // Stable chronological file order (oldest first) for append-friendly diffs.
561
585
  sorted.reverse();
562
- const body = sorted.length
563
- ? `${sorted.map((item) => JSON.stringify(item)).join("\n")}\n`
564
- : "";
565
- const tmpFile = `${jsonlFilePath()}.recover.${process.pid}.${Date.now().toString(36)}.tmp`;
566
- await writeFile(tmpFile, body, { mode: 0o600 });
567
- try {
568
- await rename(tmpFile, jsonlFilePath());
569
- }
570
- catch (err) {
571
- await rm(tmpFile, { force: true }).catch(() => undefined);
572
- throw err;
573
- }
574
- await fixOwner(jsonlFilePath());
575
- // Successful recovery: drop non-empty orphan temps we already merged.
576
- for (const name of sources) {
577
- if (name.startsWith("history.jsonl.") && name.endsWith(".tmp")) {
578
- await rm(join(historyDirPath(), name), { force: true }).catch(() => undefined);
579
- }
586
+ await writeIndexedJsonl(activePath, jsonlIndexFilePath(), sorted);
587
+ await Promise.all([
588
+ fixOwner(activePath),
589
+ fixOwner(jsonlIndexFilePath()),
590
+ ]);
591
+ for (const name of tempSources) {
592
+ await rm(join(historyDirPath(), name), { force: true }).catch(() => undefined);
580
593
  }
581
594
  return { recovered: recoveredCount, sources };
582
595
  }
@@ -628,48 +641,22 @@ async function writeJsonlAtomic(records) {
628
641
  // Something went wrong in partitioning — keep the safer set.
629
642
  const safe = sortHistoryByUpdatedDesc(dedupeHistoryById(existing));
630
643
  safe.reverse();
631
- const body = `${safe.map((item) => JSON.stringify(item)).join("\n")}\n`;
632
- const tmpFile = `${jsonlFilePath()}.${process.pid}.${Date.now().toString(36)}.tmp`;
633
- await writeFile(tmpFile, body, { mode: 0o600 });
634
- try {
635
- await rename(tmpFile, jsonlFilePath());
636
- }
637
- catch (err) {
638
- await rm(tmpFile, { force: true }).catch(() => undefined);
639
- throw err;
640
- }
641
- await fixOwner(jsonlFilePath());
644
+ await writeIndexedJsonl(jsonlFilePath(), jsonlIndexFilePath(), safe);
645
+ await Promise.all([
646
+ fixOwner(jsonlFilePath()),
647
+ fixOwner(jsonlIndexFilePath()),
648
+ ]);
642
649
  return;
643
650
  }
644
651
  }
645
652
  // File order: oldest → newest (matches classic append style).
646
653
  const ordered = sortHistoryByUpdatedDesc(kept);
647
654
  ordered.reverse();
648
- const body = ordered.length
649
- ? `${ordered.map((item) => JSON.stringify(item)).join("\n")}\n`
650
- : "";
651
- const tmpFile = `${jsonlFilePath()}.${process.pid}.${Date.now().toString(36)}.${Math.random()
652
- .toString(36)
653
- .slice(2, 8)}.tmp`;
654
- await writeFile(tmpFile, body, { mode: 0o600 });
655
- try {
656
- await rename(tmpFile, jsonlFilePath());
657
- }
658
- catch (err) {
659
- // Keep the temp file on rename failure so recovery can pick it up —
660
- // only remove empty temps.
661
- try {
662
- const st = await readFile(tmpFile).catch(() => null);
663
- if (!st || st.length === 0) {
664
- await rm(tmpFile, { force: true }).catch(() => undefined);
665
- }
666
- }
667
- catch {
668
- /* ignore */
669
- }
670
- throw err;
671
- }
672
- await fixOwner(jsonlFilePath());
655
+ await writeIndexedJsonl(jsonlFilePath(), jsonlIndexFilePath(), ordered);
656
+ await Promise.all([
657
+ fixOwner(jsonlFilePath()),
658
+ fixOwner(jsonlIndexFilePath()),
659
+ ]);
673
660
  }
674
661
  function serializeSessionPayload(record) {
675
662
  return JSON.stringify({
@@ -686,9 +673,11 @@ function serializeSessionPayload(record) {
686
673
  }
687
674
  /** SQLite mirror write with atomic generation/revision rejection. */
688
675
  function upsertSqlite(db, record) {
676
+ const summary = historySummary(record);
689
677
  db.prepare(`INSERT INTO sessions
690
- (id, name, created_at, updated_at, writer_generation, revision, cwd, messages_json)
691
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
678
+ (id, name, created_at, updated_at, writer_generation, revision, cwd,
679
+ message_count, item_count, has_images, messages_json)
680
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
692
681
  ON CONFLICT(id) DO UPDATE SET
693
682
  name = excluded.name,
694
683
  created_at = excluded.created_at,
@@ -696,13 +685,16 @@ function upsertSqlite(db, record) {
696
685
  writer_generation = excluded.writer_generation,
697
686
  revision = excluded.revision,
698
687
  cwd = excluded.cwd,
688
+ message_count = excluded.message_count,
689
+ item_count = excluded.item_count,
690
+ has_images = excluded.has_images,
699
691
  messages_json = excluded.messages_json
700
692
  WHERE (sessions.writer_generation IS NULL AND excluded.writer_generation IS NOT NULL)
701
693
  OR excluded.writer_generation > sessions.writer_generation
702
694
  OR (excluded.writer_generation = sessions.writer_generation
703
695
  AND excluded.revision >= sessions.revision)
704
696
  OR (sessions.writer_generation IS NULL AND excluded.writer_generation IS NULL
705
- AND excluded.revision >= sessions.revision)`).run(record.id, record.name ?? null, record.createdAt, record.updatedAt, historyWriterGeneration(record) ?? null, historyRevision(record), record.cwd, serializeSessionPayload(record));
697
+ AND excluded.revision >= sessions.revision)`).run(record.id, record.name ?? null, record.createdAt, record.updatedAt, historyWriterGeneration(record) ?? null, historyRevision(record), record.cwd, summary.messageCount, summary.itemCount, summary.hasImages ? 1 : 0, serializeSessionPayload(record));
706
698
  }
707
699
  export async function saveSession(messages, name, transcript, contextUsage, revision, writerGeneration) {
708
700
  // Auto-derive a readable name from the first real user message if none provided
@@ -873,6 +865,90 @@ function rowToSession(row) {
873
865
  workspaceCode: Array.isArray(parsed) ? undefined : parsed.workspaceCode,
874
866
  };
875
867
  }
868
+ function rowToSummary(row) {
869
+ const data = row;
870
+ return {
871
+ id: data.id,
872
+ ...(data.writer_generation
873
+ ? { writerGeneration: data.writer_generation }
874
+ : {}),
875
+ ...(typeof data.revision === "number" && data.revision > 0
876
+ ? { revision: data.revision }
877
+ : {}),
878
+ ...(data.name ? { name: data.name } : {}),
879
+ createdAt: data.created_at,
880
+ updatedAt: data.updated_at,
881
+ cwd: data.cwd,
882
+ messageCount: Math.max(0, data.message_count ?? 0),
883
+ itemCount: Math.max(0, data.item_count ?? data.message_count ?? 0),
884
+ hasImages: data.has_images === 1,
885
+ };
886
+ }
887
+ function sortSummaries(summaries) {
888
+ return [...summaries].sort((left, right) => Date.parse(right.updatedAt || right.createdAt) -
889
+ Date.parse(left.updatedAt || left.createdAt));
890
+ }
891
+ export async function listSessionSummaries(limit = 20, options = {}) {
892
+ if (options.recovery === "blocking")
893
+ await ensureHistoryRecovered();
894
+ else
895
+ void startHistoryRecovery();
896
+ const cacheKey = historyDirPath();
897
+ const requestedLimit = limit > 0 ? Math.floor(limit) : 0;
898
+ const now = Date.now();
899
+ if (cachedSessionList?.historyDir === cacheKey &&
900
+ now - cachedSessionList.cachedAt <= SESSION_LIST_CACHE_TTL_MS &&
901
+ (cachedSessionList.coversAll ||
902
+ (requestedLimit > 0 &&
903
+ requestedLimit <= cachedSessionList.summaries.length))) {
904
+ const cached = cachedSessionList.summaries;
905
+ return requestedLimit > 0 ? cached.slice(0, requestedLimit) : [...cached];
906
+ }
907
+ const loadGeneration = sessionListGeneration;
908
+ let summaries;
909
+ let coversAll = false;
910
+ const entries = await readValidatedHistoryIndex(jsonlFilePath(), jsonlIndexFilePath());
911
+ if (entries) {
912
+ summaries = sortSummaries(entries.map((entry) => entry.summary));
913
+ coversAll = true;
914
+ }
915
+ if (!summaries) {
916
+ const db = await loadDatabase();
917
+ if (db) {
918
+ try {
919
+ const sql = "SELECT id, name, created_at, updated_at, writer_generation, revision, cwd, " +
920
+ "message_count, item_count, has_images FROM sessions " +
921
+ "ORDER BY updated_at DESC" +
922
+ (requestedLimit > 0 ? " LIMIT ?" : "");
923
+ const rows = requestedLimit > 0
924
+ ? db.prepare(sql).all(requestedLimit)
925
+ : db.prepare(sql).all();
926
+ summaries = rows.map(rowToSummary);
927
+ coversAll = requestedLimit === 0 || rows.length < requestedLimit;
928
+ }
929
+ catch {
930
+ summaries = undefined;
931
+ }
932
+ }
933
+ }
934
+ if (!summaries || summaries.length === 0) {
935
+ const rebuilt = await rebuildHistoryIndex(jsonlFilePath(), jsonlIndexFilePath());
936
+ summaries = sortSummaries(rebuilt.map((entry) => entry.summary));
937
+ coversAll = true;
938
+ }
939
+ else {
940
+ void rebuildHistoryIndex(jsonlFilePath(), jsonlIndexFilePath());
941
+ }
942
+ if (sessionListGeneration === loadGeneration) {
943
+ cachedSessionList = {
944
+ historyDir: cacheKey,
945
+ summaries,
946
+ cachedAt: Date.now(),
947
+ coversAll,
948
+ };
949
+ }
950
+ return requestedLimit > 0 ? summaries.slice(0, requestedLimit) : [...summaries];
951
+ }
876
952
  async function listJsonlSessions(limit) {
877
953
  try {
878
954
  const records = sortHistoryByUpdatedDesc(dedupeHistoryById(await readJsonlRecordsFrom(jsonlFilePath())));
@@ -891,22 +967,10 @@ function mergeSessionLists(...lists) {
891
967
  return sortHistoryByUpdatedDesc(dedupeHistoryById(lists.flat()));
892
968
  }
893
969
  export async function listSessions(limit = 20, options = {}) {
894
- if (options.recovery === "background") {
970
+ if (options.recovery === "background")
895
971
  void startHistoryRecovery();
896
- }
897
- else {
972
+ else
898
973
  await ensureHistoryRecovered();
899
- }
900
- const cacheKey = historyDirPath();
901
- const now = Date.now();
902
- if (cachedSessionList?.historyDir === cacheKey &&
903
- now - cachedSessionList.cachedAt <= SESSION_LIST_CACHE_TTL_MS) {
904
- const cached = cachedSessionList.records;
905
- return !limit || limit <= 0 ? [...cached] : cached.slice(0, limit);
906
- }
907
- // Do not let a slow pre-write/pre-recovery read overwrite a newer cache.
908
- // Every mutation increments this generation before and after persistence.
909
- const loadGeneration = sessionListGeneration;
910
974
  const [fromJsonl, db] = await Promise.all([
911
975
  listJsonlSessions(0),
912
976
  loadDatabase(),
@@ -923,52 +987,37 @@ export async function listSessions(limit = 20, options = {}) {
923
987
  fromDb = [];
924
988
  }
925
989
  }
926
- // Active + SQLite only. Archive/pruned sessions are merged back into the
927
- // active file by recoverOrphanedHistory() (called on /history open), so
928
- // they reappear there rather than staying invisible forever.
929
990
  const merged = mergeSessionLists(fromJsonl, fromDb);
930
- if (sessionListGeneration === loadGeneration) {
931
- cachedSessionList = {
932
- historyDir: cacheKey,
933
- records: merged,
934
- cachedAt: Date.now(),
935
- };
936
- }
937
- if (!limit || limit <= 0)
938
- return [...merged];
939
- return merged.slice(0, limit);
991
+ return !limit || limit <= 0 ? merged : merged.slice(0, limit);
940
992
  }
941
993
  export async function getSession(sessionId) {
942
- await ensureHistoryRecovered();
994
+ void startHistoryRecovery();
995
+ const entries = await readValidatedHistoryIndex(jsonlFilePath(), jsonlIndexFilePath());
996
+ const entry = entries?.find((candidate) => candidate.id === sessionId);
997
+ if (entry) {
998
+ const indexed = await readIndexedHistoryRecord(jsonlFilePath(), entry);
999
+ if (indexed)
1000
+ return indexed;
1001
+ }
943
1002
  const db = await loadDatabase();
944
- let fromDb;
945
1003
  if (db) {
946
- const row = db
947
- .prepare("SELECT id, name, created_at, updated_at, writer_generation, revision, cwd, messages_json FROM sessions WHERE id = ?")
948
- .get(sessionId);
949
- if (row)
950
- fromDb = rowToSession(row);
951
- }
952
- const fromJsonl = (await readJsonlRecordsFrom(jsonlFilePath())).find((session) => session.id === sessionId);
953
- // Also check archive for sessions pruned from the active set.
954
- const fromArchive = fromJsonl
955
- ? undefined
956
- : (await readJsonlRecordsFrom(archiveFilePath())).find((s) => s.id === sessionId);
957
- const candidates = [fromJsonl, fromDb, fromArchive].filter((record) => Boolean(record));
958
- const freshest = freshestHistoryRecord(candidates);
959
- if (!freshest)
960
- return undefined;
961
- // Heal split-brain stores on selection. This is especially important across
962
- // upgrades where optional SQLite availability changes between launches.
963
- if (!fromJsonl || compareHistoryFreshness(freshest, fromJsonl) > 0) {
964
- await upsertJsonl(freshest);
1004
+ try {
1005
+ const row = db
1006
+ .prepare("SELECT id, name, created_at, updated_at, writer_generation, revision, cwd, messages_json FROM sessions WHERE id = ?")
1007
+ .get(sessionId);
1008
+ if (row)
1009
+ return rowToSession(row);
1010
+ }
1011
+ catch {
1012
+ // Fall through to streaming JSONL lookup.
1013
+ }
965
1014
  }
966
- if (db && (!fromDb || compareHistoryFreshness(freshest, fromDb) > 0)) {
967
- upsertSqlite(db, freshest);
968
- await enforceSqliteRetention(db);
969
- invalidateSessionListCache();
1015
+ const active = await findHistoryRecordStreaming(jsonlFilePath(), sessionId);
1016
+ if (active) {
1017
+ void rebuildHistoryIndex(jsonlFilePath(), jsonlIndexFilePath());
1018
+ return active;
970
1019
  }
971
- return { ...freshest, messages: hydrateMessageImages(freshest.messages) };
1020
+ return findHistoryRecordStreaming(archiveFilePath(), sessionId);
972
1021
  }
973
1022
  export function getHistoryPath() {
974
1023
  // Prefer JSONL as the durable path users can inspect/backup; SQLite is
@@ -976,55 +1025,47 @@ export function getHistoryPath() {
976
1025
  return jsonlFilePath();
977
1026
  }
978
1027
  export async function clearAllHistory() {
979
- let detail = "";
980
1028
  await ensureHistoryRecovered();
1029
+ const details = [];
981
1030
  try {
982
- const snapshot = await readJsonlRecordsFrom(jsonlFilePath());
983
- if (snapshot.length > 0) {
984
- await backupActiveHistory();
985
- detail += `backed up ${snapshot.length} session(s); `;
1031
+ invalidateSessionListCache();
1032
+ const db = await loadDatabase();
1033
+ if (db) {
1034
+ db.exec("DELETE FROM sessions; DELETE FROM tool_calls; PRAGMA wal_checkpoint(TRUNCATE); VACUUM;");
1035
+ details.push("sqlite cleared");
986
1036
  }
987
1037
  }
988
1038
  catch (error) {
989
- detail += `backup error: ${error instanceof Error ? error.message : String(error)}; `;
1039
+ details.push(`sqlite error: ${error instanceof Error ? error.message : String(error)}`);
990
1040
  }
1041
+ const releaseLock = await acquireJsonlWriteLock();
991
1042
  try {
992
- invalidateSessionListCache();
993
- const db = await loadDatabase();
994
- if (db) {
995
- db.exec("DELETE FROM sessions; DELETE FROM tool_calls;");
996
- detail += "sqlite cleared; ";
997
- }
1043
+ const names = await readdir(historyDirPath()).catch(() => []);
1044
+ const removable = names.filter((name) => name === "history.jsonl" ||
1045
+ name === "history.index.json" ||
1046
+ name === "history-archive.jsonl" ||
1047
+ name === "history-backups" ||
1048
+ name.startsWith("history-cleared-") ||
1049
+ (name.startsWith("history.jsonl.") && name.endsWith(".tmp")));
1050
+ await Promise.all(removable.map((name) => rm(join(historyDirPath(), name), { recursive: true, force: true })));
1051
+ details.push("history, index, archives, and backups deleted");
998
1052
  }
999
1053
  catch (error) {
1000
- detail += `sqlite error: ${error instanceof Error ? error.message : String(error)}; `;
1054
+ details.push(`history file error: ${error instanceof Error ? error.message : String(error)}`);
1001
1055
  }
1002
- if (await safeExists(jsonlFilePath())) {
1003
- try {
1004
- // Move aside rather than unlink so crash mid-clear still leaves a file.
1005
- const clearedCopy = join(historyDirPath(), `history-cleared-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
1006
- await rename(jsonlFilePath(), clearedCopy).catch(async () => {
1007
- await copyFile(jsonlFilePath(), clearedCopy).catch(() => undefined);
1008
- await rm(jsonlFilePath(), { force: true });
1009
- });
1010
- detail += `jsonl moved to ${clearedCopy} (recoverable)`;
1011
- }
1012
- catch (error) {
1013
- detail += `jsonl error: ${error instanceof Error ? error.message : String(error)}`;
1014
- }
1056
+ finally {
1057
+ await releaseLock();
1015
1058
  }
1016
- // Plans live alongside history (same DB / a sibling JSONL). Clearing
1017
- // history should clear stored plans too so nothing leaks across a reset.
1018
1059
  try {
1019
1060
  const { clearAllPlans } = await import("./plan.js");
1020
1061
  await clearAllPlans();
1021
- detail += "; plans cleared";
1062
+ details.push("plans cleared");
1022
1063
  }
1023
1064
  catch {
1024
- /* plan store optional */
1065
+ details.push("plan store unavailable");
1025
1066
  }
1026
1067
  invalidateSessionListCache();
1027
- return { cleared: true, detail: detail.trim() };
1068
+ return { cleared: true, detail: details.join("; ") };
1028
1069
  }
1029
1070
  export function getJsonlHistoryPath() {
1030
1071
  return jsonlFilePath();