@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.
- package/dist/agent/responder-context.js +1 -1
- package/dist/agent/responder-context.js.map +1 -1
- package/dist/agent/runner.js +1 -1
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tool-call-parser.js +2 -1
- package/dist/agent/tool-call-parser.js.map +1 -1
- package/dist/app/adapters/current-jobs-adapter.js +4 -0
- package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
- package/dist/app/controllers/job-controller.d.ts +4 -0
- package/dist/app/controllers/job-controller.js +12 -0
- package/dist/app/controllers/job-controller.js.map +1 -1
- package/dist/app/controllers/plan-controller.d.ts +1 -0
- package/dist/app/controllers/plan-controller.js +15 -0
- package/dist/app/controllers/plan-controller.js.map +1 -1
- package/dist/app/controllers/session-controller.d.ts +2 -0
- package/dist/app/controllers/session-controller.js +32 -10
- package/dist/app/controllers/session-controller.js.map +1 -1
- package/dist/app/controllers/session-responder.d.ts +19 -23
- package/dist/app/controllers/session-responder.js +157 -262
- package/dist/app/controllers/session-responder.js.map +1 -1
- package/dist/app/ports/jobs-port.d.ts +4 -0
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/repl.js +14 -68
- package/dist/repl.js.map +1 -1
- package/dist/store/history-index.d.ts +43 -0
- package/dist/store/history-index.js +197 -0
- package/dist/store/history-index.js.map +1 -0
- package/dist/store/history.d.ts +6 -5
- package/dist/store/history.js +246 -205
- package/dist/store/history.js.map +1 -1
- package/dist/store/plan.d.ts +1 -1
- package/dist/store/plan.js +17 -8
- package/dist/store/plan.js.map +1 -1
- package/dist/store/responder-settlement.d.ts +3 -0
- package/dist/store/responder-settlement.js +60 -0
- package/dist/store/responder-settlement.js.map +1 -0
- package/dist/tools/definitions.js +16 -20
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/jobs.d.ts +25 -6
- package/dist/tools/jobs.js +251 -51
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/registry.js +18 -23
- package/dist/tools/registry.js.map +1 -1
- package/dist/tui-v2/app/commands/picker-commands.js +9 -10
- package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
- package/dist/tui-v2/bootstrap/composition-root.js +9 -0
- package/dist/tui-v2/bootstrap/composition-root.js.map +1 -1
- package/dist/tui-v2/components/jobs/jobs-panel.js +49 -26
- package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
- package/dist/tui-v2/components/status/status-line.d.ts +2 -0
- package/dist/tui-v2/components/status/status-line.js +23 -2
- package/dist/tui-v2/components/status/status-line.js.map +1 -1
- package/dist/tui-v2/state/transcript-hydrate.d.ts +8 -2
- package/dist/tui-v2/state/transcript-hydrate.js +99 -9
- package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
- package/dist/version.generated.d.ts +2 -2
- package/dist/version.generated.js +2 -2
- package/package.json +1 -1
package/dist/store/history.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { appendFile, copyFile, mkdir, readdir, open, readFile, rm, stat, utimes,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
502
|
+
const tempSources = [];
|
|
491
503
|
const releaseLock = await acquireJsonlWriteLock();
|
|
492
504
|
try {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
528
|
+
const backupRecords = [];
|
|
529
|
+
if (!activeExists || activeCorrupt) {
|
|
530
|
+
try {
|
|
525
531
|
const backups = (await readdir(backupDirPath()))
|
|
526
|
-
.filter((
|
|
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
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
await
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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,
|
|
691
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1015
|
+
const active = await findHistoryRecordStreaming(jsonlFilePath(), sessionId);
|
|
1016
|
+
if (active) {
|
|
1017
|
+
void rebuildHistoryIndex(jsonlFilePath(), jsonlIndexFilePath());
|
|
1018
|
+
return active;
|
|
970
1019
|
}
|
|
971
|
-
return
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
|
|
1039
|
+
details.push(`sqlite error: ${error instanceof Error ? error.message : String(error)}`);
|
|
990
1040
|
}
|
|
1041
|
+
const releaseLock = await acquireJsonlWriteLock();
|
|
991
1042
|
try {
|
|
992
|
-
|
|
993
|
-
const
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
1054
|
+
details.push(`history file error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1001
1055
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1062
|
+
details.push("plans cleared");
|
|
1022
1063
|
}
|
|
1023
1064
|
catch {
|
|
1024
|
-
|
|
1065
|
+
details.push("plan store unavailable");
|
|
1025
1066
|
}
|
|
1026
1067
|
invalidateSessionListCache();
|
|
1027
|
-
return { cleared: true, detail:
|
|
1068
|
+
return { cleared: true, detail: details.join("; ") };
|
|
1028
1069
|
}
|
|
1029
1070
|
export function getJsonlHistoryPath() {
|
|
1030
1071
|
return jsonlFilePath();
|