@yeaft/webchat-agent 1.0.79 → 1.0.81
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/connection/buffer.js +2 -6
- package/connection/message-router.js +1 -64
- package/conversation.js +4 -32
- package/history.js +2 -2
- package/index.js +0 -6
- package/package.json +1 -1
- package/providers/claude-code.js +1 -1
- package/yeaft/attachments.js +2 -2
- package/yeaft/config.js +1 -1
- package/yeaft/conversation/persist.js +458 -112
- package/yeaft/conversation/search.js +51 -15
- package/yeaft/session.js +27 -18
- package/yeaft/sessions/session-config.js +2 -0
- package/yeaft/sessions/session-crud.js +3 -2
- package/yeaft/web-bridge.js +27 -15
- package/crew/builtin-actions.js +0 -154
- package/crew/context-loader.js +0 -171
- package/crew/control.js +0 -444
- package/crew/human-interaction.js +0 -195
- package/crew/persistence.js +0 -295
- package/crew/role-management.js +0 -182
- package/crew/role-output.js +0 -461
- package/crew/role-query.js +0 -406
- package/crew/role-states.js +0 -180
- package/crew/routing-fallback.js +0 -64
- package/crew/routing-metrics.js +0 -215
- package/crew/routing.js +0 -951
- package/crew/session.js +0 -648
- package/crew/shared-dir.js +0 -266
- package/crew/task-files.js +0 -554
- package/crew/ui-messages.js +0 -274
- package/crew/worktree.js +0 -130
- package/crew-i18n.js +0 -579
- package/crew.js +0 -48
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* persist.js — Conversation message persistence
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* ~/.yeaft/chat/
|
|
4
|
+
* Hot conversation messages are stored as JSONL segments with a small JSON index
|
|
5
|
+
* under ~/.yeaft/chat/ or ~/.yeaft/sessions/<sessionId>/conversation/.
|
|
6
|
+
* Older per-message Markdown
|
|
7
|
+
* files are read as a legacy fallback only.
|
|
6
8
|
*
|
|
7
9
|
* Vocabulary note: the primary on-disk layout uses `sessions/<id>/`. Older
|
|
8
10
|
* installs may still have transcript files under `groups/<id>/`; those are
|
|
9
11
|
* read as a legacy fallback only. Every API surface above the disk layer
|
|
10
12
|
* uses "session" vocabulary.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* id: m0355
|
|
15
|
-
* role: user
|
|
16
|
-
* time: 2026-04-09T14:35:00Z
|
|
17
|
-
* mode: chat
|
|
18
|
-
* model: claude-sonnet-4-20250514
|
|
19
|
-
* tokens_est: 230
|
|
20
|
-
* ---
|
|
21
|
-
* Message content here...
|
|
14
|
+
* Segment format: one JSON object per line, keyed by global monotonic `seq` /
|
|
15
|
+
* `id` (`m0001`, `m0002`, ...).
|
|
22
16
|
*
|
|
23
17
|
* Reference: yeaft-yeaft-core-systems.md §4.1, yeaft-yeaft-brainstorm-v5.1.md
|
|
24
18
|
*/
|
|
25
19
|
|
|
26
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync } from 'fs';
|
|
20
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync } from 'fs';
|
|
27
21
|
import { join, basename } from 'path';
|
|
28
22
|
import { isPermissionError } from '../init.js';
|
|
29
23
|
import { pairSanitize } from '../pair-sanitize.js';
|
|
@@ -64,6 +58,58 @@ const RECENT_SESSION_SCAN_BASE_CAP = 64;
|
|
|
64
58
|
const RECENT_SESSION_SCAN_PER_TURN_CAP = 4;
|
|
65
59
|
const RECENT_SESSION_SCAN_MAX_CAP = 256;
|
|
66
60
|
|
|
61
|
+
|
|
62
|
+
const SEGMENT_INDEX_FILE = 'index.json';
|
|
63
|
+
const SEGMENT_DIR = 'segments';
|
|
64
|
+
const SEGMENT_TARGET_BYTES = 1024 * 1024;
|
|
65
|
+
const SEGMENT_FIRST_NAME = '000001.jsonl';
|
|
66
|
+
|
|
67
|
+
function emptySegmentIndex() {
|
|
68
|
+
return {
|
|
69
|
+
version: 1,
|
|
70
|
+
nextSeq: 1,
|
|
71
|
+
totalMessages: 0,
|
|
72
|
+
lastMessageId: null,
|
|
73
|
+
activeSegment: SEGMENT_FIRST_NAME,
|
|
74
|
+
segments: [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function seqId(seq) {
|
|
79
|
+
return `m${String(seq).padStart(4, '0')}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeSegmentRecord(msg) {
|
|
83
|
+
if (!msg || typeof msg !== 'object') return null;
|
|
84
|
+
const seq = Number.isFinite(msg.seq) ? msg.seq : parseSeqFromId(msg.id);
|
|
85
|
+
if (!Number.isFinite(seq)) return null;
|
|
86
|
+
const out = { ...msg, seq, id: msg.id || seqId(seq) };
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function segmentNameForNumber(n) {
|
|
91
|
+
return `${String(n).padStart(6, '0')}.jsonl`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function nextSegmentName(name) {
|
|
95
|
+
const m = String(name || '').match(/^(\d+)\.jsonl$/);
|
|
96
|
+
const n = m ? parseInt(m[1], 10) + 1 : 1;
|
|
97
|
+
return segmentNameForNumber(n);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseJsonLine(line) {
|
|
101
|
+
if (!line || !line.trim()) return null;
|
|
102
|
+
try { return normalizeSegmentRecord(JSON.parse(line)); }
|
|
103
|
+
catch { return null; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseSegmentOrMarkdown(raw) {
|
|
107
|
+
if (!raw) return null;
|
|
108
|
+
const msg = parseJsonLine(raw);
|
|
109
|
+
if (msg) return msg;
|
|
110
|
+
return parseMessage(raw);
|
|
111
|
+
}
|
|
112
|
+
|
|
67
113
|
/** Read the current default cold-start replay window (turn count). */
|
|
68
114
|
export function getDefaultRecentTurnsLimit() {
|
|
69
115
|
return DEFAULT_RECENT_TURNS;
|
|
@@ -441,6 +487,209 @@ export function parseMessage(raw) {
|
|
|
441
487
|
return msg;
|
|
442
488
|
}
|
|
443
489
|
|
|
490
|
+
|
|
491
|
+
class SegmentStore {
|
|
492
|
+
constructor(rootDir) {
|
|
493
|
+
this.rootDir = rootDir;
|
|
494
|
+
this.segmentDir = join(rootDir, SEGMENT_DIR);
|
|
495
|
+
this.indexPath = join(rootDir, SEGMENT_INDEX_FILE);
|
|
496
|
+
this.index = null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
ensure() {
|
|
500
|
+
if (!existsSync(this.rootDir)) mkdirSync(this.rootDir, { recursive: true, mode: 0o755 });
|
|
501
|
+
if (!existsSync(this.segmentDir)) mkdirSync(this.segmentDir, { recursive: true, mode: 0o755 });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
hasData() {
|
|
505
|
+
if (existsSync(this.indexPath)) return true;
|
|
506
|
+
if (!existsSync(this.segmentDir)) return false;
|
|
507
|
+
try { return readdirSync(this.segmentDir).some(f => f.endsWith('.jsonl')); }
|
|
508
|
+
catch (err) { if (isPermissionError(err)) return false; throw err; }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
loadIndex() {
|
|
512
|
+
if (this.index) return this.index;
|
|
513
|
+
let idx = null;
|
|
514
|
+
if (existsSync(this.indexPath)) {
|
|
515
|
+
try {
|
|
516
|
+
const parsed = JSON.parse(readFileSync(this.indexPath, 'utf8') || '{}');
|
|
517
|
+
if (parsed && typeof parsed === 'object') idx = parsed;
|
|
518
|
+
} catch {
|
|
519
|
+
idx = null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
this.index = this.#normalizeIndex(idx || this.#rebuildIndex());
|
|
523
|
+
if (!existsSync(this.indexPath) && this.hasData()) this.saveIndex();
|
|
524
|
+
return this.index;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
saveIndex() {
|
|
528
|
+
this.ensure();
|
|
529
|
+
writeFileSync(this.indexPath, `${JSON.stringify(this.index || emptySegmentIndex(), null, 2)}\n`, { encoding: 'utf8', mode: 0o644 });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
append(msg) {
|
|
533
|
+
this.ensure();
|
|
534
|
+
const idx = this.loadIndex();
|
|
535
|
+
let segment = idx.segments[idx.segments.length - 1] || null;
|
|
536
|
+
let active = segment?.file || idx.activeSegment || SEGMENT_FIRST_NAME;
|
|
537
|
+
let activePath = join(this.segmentDir, active);
|
|
538
|
+
const currentSize = existsSync(activePath) ? statSync(activePath).size : 0;
|
|
539
|
+
if (currentSize >= SEGMENT_TARGET_BYTES && segment && segment.count > 0) {
|
|
540
|
+
active = nextSegmentName(active);
|
|
541
|
+
activePath = join(this.segmentDir, active);
|
|
542
|
+
segment = null;
|
|
543
|
+
}
|
|
544
|
+
const line = `${JSON.stringify(msg)}\n`;
|
|
545
|
+
appendFileSync(activePath, line, { encoding: 'utf8', mode: 0o644 });
|
|
546
|
+
|
|
547
|
+
const seq = parseSeqFromId(msg.id);
|
|
548
|
+
if (!segment || segment.file !== active) {
|
|
549
|
+
segment = { file: active, firstSeq: seq, lastSeq: seq, count: 0, bytes: 0 };
|
|
550
|
+
idx.segments.push(segment);
|
|
551
|
+
}
|
|
552
|
+
segment.firstSeq = Number.isFinite(segment.firstSeq) ? Math.min(segment.firstSeq, seq) : seq;
|
|
553
|
+
segment.lastSeq = Number.isFinite(segment.lastSeq) ? Math.max(segment.lastSeq, seq) : seq;
|
|
554
|
+
segment.count = (segment.count || 0) + 1;
|
|
555
|
+
segment.bytes = (segment.bytes || currentSize) + Buffer.byteLength(line);
|
|
556
|
+
idx.activeSegment = active;
|
|
557
|
+
idx.totalMessages = (idx.totalMessages || 0) + 1;
|
|
558
|
+
idx.lastMessageId = msg.id || null;
|
|
559
|
+
idx.nextSeq = Math.max(Number(idx.nextSeq) || 1, seq + 1);
|
|
560
|
+
this.saveIndex();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
readAll({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
|
|
564
|
+
if (!this.hasData()) return [];
|
|
565
|
+
const idx = this.loadIndex();
|
|
566
|
+
const segments = (idx.segments || [])
|
|
567
|
+
.filter(seg => this.#segmentMayContain(seg, beforeSeq, afterSeq))
|
|
568
|
+
.slice()
|
|
569
|
+
.sort((a, b) => desc ? (b.lastSeq || 0) - (a.lastSeq || 0) : (a.firstSeq || 0) - (b.firstSeq || 0));
|
|
570
|
+
const out = [];
|
|
571
|
+
for (const seg of segments) {
|
|
572
|
+
const rows = this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold });
|
|
573
|
+
out.push(...rows);
|
|
574
|
+
}
|
|
575
|
+
return desc ? out.sort((a, b) => parseSeqFromId(b.id) - parseSeqFromId(a.id)) : out.sort(compareMessagesBySeq);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
*scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
|
|
579
|
+
if (!this.hasData()) return;
|
|
580
|
+
const idx = this.loadIndex();
|
|
581
|
+
const segments = (idx.segments || [])
|
|
582
|
+
.filter(seg => this.#segmentMayContain(seg, beforeSeq, afterSeq))
|
|
583
|
+
.slice()
|
|
584
|
+
.sort((a, b) => desc ? (b.lastSeq || 0) - (a.lastSeq || 0) : (a.firstSeq || 0) - (b.firstSeq || 0));
|
|
585
|
+
for (const seg of segments) {
|
|
586
|
+
for (const row of this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold })) yield row;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
count(kind = 'hot') {
|
|
591
|
+
if (!this.hasData()) return 0;
|
|
592
|
+
if (kind === 'all') {
|
|
593
|
+
const idx = this.loadIndex();
|
|
594
|
+
return Number(idx.totalMessages) || (idx.segments || []).reduce((sum, seg) => sum + (Number(seg.count) || 0), 0);
|
|
595
|
+
}
|
|
596
|
+
const rows = this.readAll({ includeCold: true });
|
|
597
|
+
if (kind === 'cold') return rows.filter(m => m && m.cold === true).length;
|
|
598
|
+
return rows.filter(m => m && m.cold !== true).length;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
replaceAll(rows) {
|
|
602
|
+
this.clear();
|
|
603
|
+
for (const msg of (rows || []).filter(Boolean).sort(compareMessagesBySeq)) this.append(msg);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
markCold(id) {
|
|
607
|
+
if (!id || !this.hasData()) return 0;
|
|
608
|
+
const rows = this.readAll({ includeCold: true });
|
|
609
|
+
let changed = 0;
|
|
610
|
+
for (const msg of rows) {
|
|
611
|
+
if (msg?.id === id && msg.cold !== true) {
|
|
612
|
+
msg.cold = true;
|
|
613
|
+
changed += 1;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (changed > 0) this.replaceAll(rows);
|
|
617
|
+
return changed;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
clear() {
|
|
621
|
+
if (existsSync(this.segmentDir)) {
|
|
622
|
+
for (const f of readdirSync(this.segmentDir)) {
|
|
623
|
+
if (f.endsWith('.jsonl')) unlinkSync(join(this.segmentDir, f));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (existsSync(this.indexPath)) unlinkSync(this.indexPath);
|
|
627
|
+
this.index = emptySegmentIndex();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
#normalizeIndex(idx) {
|
|
631
|
+
const out = { ...emptySegmentIndex(), ...(idx || {}) };
|
|
632
|
+
out.segments = Array.isArray(out.segments) ? out.segments.filter(s => s && s.file) : [];
|
|
633
|
+
out.segments.sort((a, b) => (Number(a.firstSeq) || 0) - (Number(b.firstSeq) || 0));
|
|
634
|
+
const maxSeq = out.segments.reduce((max, seg) => Math.max(max, Number(seg.lastSeq) || 0), 0);
|
|
635
|
+
out.nextSeq = Math.max(Number(out.nextSeq) || 1, maxSeq + 1);
|
|
636
|
+
out.activeSegment = out.activeSegment || out.segments[out.segments.length - 1]?.file || SEGMENT_FIRST_NAME;
|
|
637
|
+
out.totalMessages = Number(out.totalMessages) || out.segments.reduce((sum, seg) => sum + (Number(seg.count) || 0), 0);
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
#rebuildIndex() {
|
|
642
|
+
const idx = emptySegmentIndex();
|
|
643
|
+
if (!existsSync(this.segmentDir)) return idx;
|
|
644
|
+
const files = readdirSync(this.segmentDir).filter(f => f.endsWith('.jsonl')).sort();
|
|
645
|
+
for (const file of files) {
|
|
646
|
+
const rows = this.#readSegment(file, { includeCold: true });
|
|
647
|
+
if (rows.length === 0) continue;
|
|
648
|
+
const seqs = rows.map(r => parseSeqFromId(r.id)).filter(Number.isFinite);
|
|
649
|
+
const path = join(this.segmentDir, file);
|
|
650
|
+
const seg = {
|
|
651
|
+
file,
|
|
652
|
+
firstSeq: Math.min(...seqs),
|
|
653
|
+
lastSeq: Math.max(...seqs),
|
|
654
|
+
count: rows.length,
|
|
655
|
+
bytes: existsSync(path) ? statSync(path).size : 0,
|
|
656
|
+
};
|
|
657
|
+
idx.segments.push(seg);
|
|
658
|
+
idx.totalMessages += rows.length;
|
|
659
|
+
idx.lastMessageId = rows[rows.length - 1]?.id || idx.lastMessageId;
|
|
660
|
+
idx.nextSeq = Math.max(idx.nextSeq, seg.lastSeq + 1);
|
|
661
|
+
idx.activeSegment = file;
|
|
662
|
+
}
|
|
663
|
+
return idx;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
#segmentMayContain(seg, beforeSeq, afterSeq) {
|
|
667
|
+
const first = Number(seg.firstSeq);
|
|
668
|
+
const last = Number(seg.lastSeq);
|
|
669
|
+
if (Number.isFinite(beforeSeq) && Number.isFinite(first) && first >= beforeSeq) return false;
|
|
670
|
+
if (Number.isFinite(afterSeq) && Number.isFinite(last) && last <= afterSeq) return false;
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
#readSegment(file, { beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
|
|
675
|
+
const path = join(this.segmentDir, file);
|
|
676
|
+
if (!existsSync(path)) return [];
|
|
677
|
+
const raw = readFileSync(path, 'utf8');
|
|
678
|
+
const rows = [];
|
|
679
|
+
for (const line of raw.split('\n')) {
|
|
680
|
+
const msg = parseJsonLine(line);
|
|
681
|
+
if (!msg) continue;
|
|
682
|
+
const seq = parseSeqFromId(msg.id);
|
|
683
|
+
if (Number.isFinite(beforeSeq) && seq >= beforeSeq) continue;
|
|
684
|
+
if (Number.isFinite(afterSeq) && seq <= afterSeq) continue;
|
|
685
|
+
if (!includeCold && msg.cold === true) continue;
|
|
686
|
+
rows.push(msg);
|
|
687
|
+
}
|
|
688
|
+
rows.sort(compareMessagesBySeq);
|
|
689
|
+
return desc ? rows.reverse() : rows;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
444
693
|
// ─── ConversationStore ───────────────────────────────────────
|
|
445
694
|
|
|
446
695
|
/**
|
|
@@ -448,15 +697,14 @@ export function parseMessage(raw) {
|
|
|
448
697
|
*
|
|
449
698
|
* Directory layout:
|
|
450
699
|
* chat/ — one-to-one chat mode history
|
|
451
|
-
* index.
|
|
700
|
+
* index.json
|
|
701
|
+
* segments/
|
|
452
702
|
* compact.md
|
|
453
|
-
* messages/
|
|
454
|
-
* cold/
|
|
455
703
|
* blobs/
|
|
456
704
|
* sessions/<sessionId>/conversation/
|
|
705
|
+
* index.json
|
|
706
|
+
* segments/
|
|
457
707
|
* compact/
|
|
458
|
-
* messages/
|
|
459
|
-
* cold/
|
|
460
708
|
* blobs/
|
|
461
709
|
*
|
|
462
710
|
* Legacy compatibility: ~/.yeaft/conversation is read as an old mixed store,
|
|
@@ -517,7 +765,7 @@ export class ConversationStore {
|
|
|
517
765
|
// errors). Per-session conversation directories are created lazily once a
|
|
518
766
|
// sessionId is known. Legacy directories are never created by new versions.
|
|
519
767
|
for (const d of [
|
|
520
|
-
this.#chatDir, join(this.#chatDir, 'blobs'), this.#chatMsgDir, this.#chatColdDir,
|
|
768
|
+
this.#chatDir, join(this.#chatDir, 'blobs'), join(this.#chatDir, SEGMENT_DIR), this.#chatMsgDir, this.#chatColdDir,
|
|
521
769
|
this.#sessionsDir,
|
|
522
770
|
]) {
|
|
523
771
|
try {
|
|
@@ -553,9 +801,8 @@ export class ConversationStore {
|
|
|
553
801
|
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
554
802
|
};
|
|
555
803
|
|
|
556
|
-
const filePath = join(this.#messageDirFor(fullMsg), `${id}.md`);
|
|
557
804
|
try {
|
|
558
|
-
|
|
805
|
+
this.#segmentStoreFor(fullMsg, { create: true }).append(fullMsg);
|
|
559
806
|
} catch (err) {
|
|
560
807
|
if (isPermissionError(err)) {
|
|
561
808
|
if (!_permissionWarned) {
|
|
@@ -588,6 +835,10 @@ export class ConversationStore {
|
|
|
588
835
|
* @param {string} id — message id (e.g. "m0355")
|
|
589
836
|
*/
|
|
590
837
|
moveToCold(id) {
|
|
838
|
+
for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
839
|
+
const moved = this.#segmentStoreForConversationDir(dir).markCold(id);
|
|
840
|
+
if (moved > 0) return;
|
|
841
|
+
}
|
|
591
842
|
for (const [hotDir, coldDir] of this.#hotColdDirPairs({ includeLegacy: false })) {
|
|
592
843
|
const src = join(hotDir, `${id}.md`);
|
|
593
844
|
const dst = join(coldDir, `${id}.md`);
|
|
@@ -827,7 +1078,10 @@ export class ConversationStore {
|
|
|
827
1078
|
}
|
|
828
1079
|
}
|
|
829
1080
|
}
|
|
830
|
-
|
|
1081
|
+
for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
1082
|
+
this.#segmentStoreForConversationDir(dir).clear();
|
|
1083
|
+
}
|
|
1084
|
+
// Reset compact files in the new chat/session stores. Legacy
|
|
831
1085
|
// ~/.yeaft/conversation is intentionally left untouched.
|
|
832
1086
|
for (const path of [this.#compactPath]) {
|
|
833
1087
|
if (!existsSync(path)) continue;
|
|
@@ -1039,11 +1293,9 @@ export class ConversationStore {
|
|
|
1039
1293
|
*/
|
|
1040
1294
|
loadOlderBySession(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
1041
1295
|
if (!sessionId) return { messages: [], oldestSeq: null, hasMore: false };
|
|
1042
|
-
const all = this.#loadSessionMessages(sessionId);
|
|
1043
1296
|
const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
|
|
1044
|
-
const prefix =
|
|
1045
|
-
&& !isHiddenConversationRow(m)
|
|
1046
|
-
&& parseSeqFromId(m.id) < cutoff);
|
|
1297
|
+
const prefix = this.#readSessionRows(sessionId, { beforeSeq: cutoff })
|
|
1298
|
+
.filter(m => m && m.sessionId === sessionId && !isHiddenConversationRow(m));
|
|
1047
1299
|
if (prefix.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
|
|
1048
1300
|
const sliced = pairSanitize(sliceLastNTurns(prefix, turnsLimit));
|
|
1049
1301
|
// Turn-based hasMore: there's an EARLIER turn boundary we didn't keep.
|
|
@@ -1117,14 +1369,7 @@ export class ConversationStore {
|
|
|
1117
1369
|
if (cutoff === null) return { messages: [], latestSeq: null };
|
|
1118
1370
|
const after = [];
|
|
1119
1371
|
let newestScannedSeq = cutoff;
|
|
1120
|
-
for (const
|
|
1121
|
-
let m;
|
|
1122
|
-
try {
|
|
1123
|
-
m = this.readMessageFile(entry.path);
|
|
1124
|
-
} catch (err) {
|
|
1125
|
-
if (isPermissionError(err)) continue;
|
|
1126
|
-
throw err;
|
|
1127
|
-
}
|
|
1372
|
+
for (const m of this.#iterateSessionRows(sessionId, { afterSeq: cutoff, desc: false })) {
|
|
1128
1373
|
if (!m || m.sessionId !== sessionId) continue;
|
|
1129
1374
|
const seq = parseSeqFromId(m.id);
|
|
1130
1375
|
if (Number.isFinite(seq) && seq > newestScannedSeq) newestScannedSeq = seq;
|
|
@@ -1156,7 +1401,13 @@ export class ConversationStore {
|
|
|
1156
1401
|
* @returns {number}
|
|
1157
1402
|
*/
|
|
1158
1403
|
countHot() {
|
|
1159
|
-
|
|
1404
|
+
let total = this.#countSegmentMessages([this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]);
|
|
1405
|
+
const markdownDirs = [this.#legacyMsgDir];
|
|
1406
|
+
if (!this.#segmentStoreForConversationDir(this.#chatDir).hasData()) markdownDirs.push(this.#chatMsgDir);
|
|
1407
|
+
for (const dir of this.#sessionConversationDirs()) {
|
|
1408
|
+
if (!this.#segmentStoreForConversationDir(dir).hasData()) markdownDirs.push(join(dir, 'messages'));
|
|
1409
|
+
}
|
|
1410
|
+
return total + this.#countFilesInDirs(markdownDirs);
|
|
1160
1411
|
}
|
|
1161
1412
|
|
|
1162
1413
|
/**
|
|
@@ -1165,7 +1416,8 @@ export class ConversationStore {
|
|
|
1165
1416
|
* @returns {number}
|
|
1166
1417
|
*/
|
|
1167
1418
|
countCold() {
|
|
1168
|
-
return this.#
|
|
1419
|
+
return this.#countSegmentMessages([this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })], 'cold')
|
|
1420
|
+
+ this.#countFilesInDirs([this.#chatColdDir, ...this.#sessionMessageDirs('cold'), this.#legacyColdDir]);
|
|
1169
1421
|
}
|
|
1170
1422
|
|
|
1171
1423
|
/**
|
|
@@ -1174,7 +1426,7 @@ export class ConversationStore {
|
|
|
1174
1426
|
* @returns {number}
|
|
1175
1427
|
*/
|
|
1176
1428
|
hotTokens() {
|
|
1177
|
-
const messages = this
|
|
1429
|
+
const messages = this.#loadHotMessages();
|
|
1178
1430
|
return messages.reduce((sum, m) => sum + (m.tokens_est || estimateTokens(m.content || '')), 0);
|
|
1179
1431
|
}
|
|
1180
1432
|
|
|
@@ -1221,7 +1473,13 @@ export class ConversationStore {
|
|
|
1221
1473
|
deleteByGroup(sessionId) {
|
|
1222
1474
|
if (!sessionId) return 0;
|
|
1223
1475
|
let removed = 0;
|
|
1224
|
-
|
|
1476
|
+
const primaryDir = this.#sessionConversationDir(sessionId);
|
|
1477
|
+
const segmentStore = this.#segmentStoreForConversationDir(primaryDir);
|
|
1478
|
+
if (segmentStore.hasData()) {
|
|
1479
|
+
removed += segmentStore.count('all');
|
|
1480
|
+
segmentStore.clear();
|
|
1481
|
+
}
|
|
1482
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages', sessionId), ...this.#sessionMessageDirs('cold', sessionId)]) {
|
|
1225
1483
|
if (!existsSync(dir)) continue;
|
|
1226
1484
|
let files;
|
|
1227
1485
|
try {
|
|
@@ -1250,7 +1508,6 @@ export class ConversationStore {
|
|
|
1250
1508
|
}
|
|
1251
1509
|
}
|
|
1252
1510
|
}
|
|
1253
|
-
// Invalidate cached next-seq — countHot/loadAll will re-scan.
|
|
1254
1511
|
this.#nextSeq = null;
|
|
1255
1512
|
return removed;
|
|
1256
1513
|
}
|
|
@@ -1279,6 +1536,33 @@ export class ConversationStore {
|
|
|
1279
1536
|
let scanned = 0;
|
|
1280
1537
|
let removed = 0;
|
|
1281
1538
|
const orphans = [];
|
|
1539
|
+
|
|
1540
|
+
const scanMsg = (msg, locator) => {
|
|
1541
|
+
if (!msg) return false;
|
|
1542
|
+
scanned += 1;
|
|
1543
|
+
const isOrphan = !msg.sessionId || !keep.has(msg.sessionId);
|
|
1544
|
+
if (!isOrphan) return false;
|
|
1545
|
+
orphans.push(locator);
|
|
1546
|
+
return true;
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
1550
|
+
const store = this.#segmentStoreForConversationDir(dir);
|
|
1551
|
+
if (!store.hasData()) continue;
|
|
1552
|
+
const rows = store.readAll({ includeCold: true });
|
|
1553
|
+
let keepRows = [];
|
|
1554
|
+
let dirty = false;
|
|
1555
|
+
for (const msg of rows) {
|
|
1556
|
+
const orphan = scanMsg(msg, `${dir}/${SEGMENT_DIR}/${msg.id || 'unknown'}`);
|
|
1557
|
+
if (orphan) dirty = true;
|
|
1558
|
+
else keepRows.push(msg);
|
|
1559
|
+
}
|
|
1560
|
+
if (dirty && !dryRun) {
|
|
1561
|
+
store.replaceAll(keepRows);
|
|
1562
|
+
removed += rows.length - keepRows.length;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1282
1566
|
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1283
1567
|
if (!existsSync(dir)) continue;
|
|
1284
1568
|
let files;
|
|
@@ -1298,11 +1582,7 @@ export class ConversationStore {
|
|
|
1298
1582
|
throw err;
|
|
1299
1583
|
}
|
|
1300
1584
|
const msg = parseMessage(raw);
|
|
1301
|
-
if (!msg) continue;
|
|
1302
|
-
scanned += 1;
|
|
1303
|
-
const isOrphan = !msg.sessionId || !keep.has(msg.sessionId);
|
|
1304
|
-
if (!isOrphan) continue;
|
|
1305
|
-
orphans.push(path);
|
|
1585
|
+
if (!scanMsg(msg, path)) continue;
|
|
1306
1586
|
if (dryRun) continue;
|
|
1307
1587
|
try {
|
|
1308
1588
|
unlinkSync(path);
|
|
@@ -1333,6 +1613,25 @@ export class ConversationStore {
|
|
|
1333
1613
|
reassignThread(sourceId, targetId) {
|
|
1334
1614
|
if (!sourceId || !targetId || sourceId === targetId) return 0;
|
|
1335
1615
|
let rewritten = 0;
|
|
1616
|
+
|
|
1617
|
+
for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
1618
|
+
const store = this.#segmentStoreForConversationDir(dir);
|
|
1619
|
+
if (!store.hasData()) continue;
|
|
1620
|
+
const rows = store.readAll();
|
|
1621
|
+
let dirty = false;
|
|
1622
|
+
for (const msg of rows) {
|
|
1623
|
+
if (!msg || msg.threadId !== sourceId) continue;
|
|
1624
|
+
if (!msg.sourceThreadId) msg.sourceThreadId = sourceId;
|
|
1625
|
+
msg.threadId = targetId;
|
|
1626
|
+
rewritten += 1;
|
|
1627
|
+
dirty = true;
|
|
1628
|
+
}
|
|
1629
|
+
if (dirty) {
|
|
1630
|
+
store.clear();
|
|
1631
|
+
for (const msg of rows) store.append(msg);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1336
1635
|
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1337
1636
|
if (!existsSync(dir)) continue;
|
|
1338
1637
|
let files;
|
|
@@ -1353,7 +1652,6 @@ export class ConversationStore {
|
|
|
1353
1652
|
}
|
|
1354
1653
|
const msg = parseMessage(raw);
|
|
1355
1654
|
if (!msg || msg.threadId !== sourceId) continue;
|
|
1356
|
-
// Preserve original thread id for UI pill; only stamp once.
|
|
1357
1655
|
if (!msg.sourceThreadId) msg.sourceThreadId = sourceId;
|
|
1358
1656
|
msg.threadId = targetId;
|
|
1359
1657
|
try {
|
|
@@ -1410,19 +1708,9 @@ export class ConversationStore {
|
|
|
1410
1708
|
throw err;
|
|
1411
1709
|
}
|
|
1412
1710
|
|
|
1413
|
-
// Collect source-thread candidate
|
|
1711
|
+
// Collect source-thread candidate rows from segmented storage and legacy Markdown dirs.
|
|
1712
|
+
const sourceRows = this.#loadAllMessages().filter(m => m && m.threadId === sourceId);
|
|
1414
1713
|
const candidates = [];
|
|
1415
|
-
for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#sessionMessageDirs('cold'), ...this.#sessionMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
|
|
1416
|
-
if (!existsSync(dir)) continue;
|
|
1417
|
-
let files;
|
|
1418
|
-
try {
|
|
1419
|
-
files = readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
1420
|
-
} catch (err) {
|
|
1421
|
-
if (isPermissionError(err)) continue;
|
|
1422
|
-
throw err;
|
|
1423
|
-
}
|
|
1424
|
-
for (const f of files) candidates.push(join(dir, f));
|
|
1425
|
-
}
|
|
1426
1714
|
// Also pick up any already-forked sub-thread dir (chain fork).
|
|
1427
1715
|
const sourceSubDir = this.#threadMsgDir(sourceId);
|
|
1428
1716
|
if (existsSync(sourceSubDir)) {
|
|
@@ -1434,11 +1722,7 @@ export class ConversationStore {
|
|
|
1434
1722
|
if (!isPermissionError(err)) throw err;
|
|
1435
1723
|
}
|
|
1436
1724
|
}
|
|
1437
|
-
|
|
1438
|
-
// restart at m0001 so sorting by basename alone is ambiguous across
|
|
1439
|
-
// dirs; but a given source thread stores messages in exactly ONE place
|
|
1440
|
-
// (either legacy flat dir OR its sub-dir — see below), so ties never
|
|
1441
|
-
// arise. Sorting by (path, seq) is still well-defined.
|
|
1725
|
+
sourceRows.sort(compareMessagesBySeq);
|
|
1442
1726
|
candidates.sort((a, b) => {
|
|
1443
1727
|
const ma = a.match(/m(\d+)\.md$/);
|
|
1444
1728
|
const mb = b.match(/m(\d+)\.md$/);
|
|
@@ -1450,25 +1734,13 @@ export class ConversationStore {
|
|
|
1450
1734
|
const cutoffSeq = parseInt(cutoffMatch[1], 10);
|
|
1451
1735
|
|
|
1452
1736
|
let copied = 0;
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
if (seq > cutoffSeq) continue; // do not break — sub-thread dir mixed in may interleave
|
|
1458
|
-
let raw;
|
|
1459
|
-
try {
|
|
1460
|
-
raw = readFileSync(path, 'utf8');
|
|
1461
|
-
} catch (err) {
|
|
1462
|
-
if (isPermissionError(err)) continue;
|
|
1463
|
-
throw err;
|
|
1464
|
-
}
|
|
1465
|
-
const msg = parseMessage(raw);
|
|
1466
|
-
if (!msg || msg.threadId !== sourceId) continue;
|
|
1467
|
-
// Mint a fresh per-thread id restarting at m0001 under the target
|
|
1468
|
-
// thread's own namespace.
|
|
1737
|
+
const copyMsg = (msg) => {
|
|
1738
|
+
if (!msg || msg.threadId !== sourceId) return;
|
|
1739
|
+
const seq = parseSeqFromId(msg.id);
|
|
1740
|
+
if (!Number.isFinite(seq) || seq > cutoffSeq) return;
|
|
1469
1741
|
const nextSeq = this.#getNextThreadSeq(targetId);
|
|
1470
1742
|
const newId = `m${String(nextSeq).padStart(4, '0')}`;
|
|
1471
|
-
const { id: _id, ...rest } = msg;
|
|
1743
|
+
const { id: _id, seq: _seq, ...rest } = msg;
|
|
1472
1744
|
const copy = {
|
|
1473
1745
|
...rest,
|
|
1474
1746
|
id: newId,
|
|
@@ -1478,10 +1750,18 @@ export class ConversationStore {
|
|
|
1478
1750
|
tokens_est: rest.tokens_est || estimateTokens(rest.content || ''),
|
|
1479
1751
|
};
|
|
1480
1752
|
const filePath = join(targetDir, `${newId}.md`);
|
|
1753
|
+
writeFileSync(filePath, serializeMessage(copy), { encoding: 'utf8', mode: 0o644 });
|
|
1754
|
+
this.#nextSeqByThread.set(targetId, nextSeq + 1);
|
|
1755
|
+
copied += 1;
|
|
1756
|
+
};
|
|
1757
|
+
for (const msg of sourceRows) {
|
|
1758
|
+
try { copyMsg(msg); }
|
|
1759
|
+
catch (err) { if (isPermissionError(err)) continue; throw err; }
|
|
1760
|
+
}
|
|
1761
|
+
for (const path of candidates) {
|
|
1481
1762
|
try {
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
copied += 1;
|
|
1763
|
+
const msg = parseMessage(readFileSync(path, 'utf8'));
|
|
1764
|
+
copyMsg(msg);
|
|
1485
1765
|
} catch (err) {
|
|
1486
1766
|
if (isPermissionError(err)) continue;
|
|
1487
1767
|
throw err;
|
|
@@ -1517,8 +1797,10 @@ export class ConversationStore {
|
|
|
1517
1797
|
}
|
|
1518
1798
|
return out;
|
|
1519
1799
|
}
|
|
1520
|
-
// Legacy: messages live in the flat
|
|
1521
|
-
const collected =
|
|
1800
|
+
// Legacy plus segmented root: messages live in the flat/session stores stamped with threadId.
|
|
1801
|
+
const collected = this.#loadAllMessages()
|
|
1802
|
+
.filter(msg => msg && msg.threadId === threadId)
|
|
1803
|
+
.map(msg => ({ msg, f: `${msg.id || ''}.md` }));
|
|
1522
1804
|
for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#sessionMessageDirs('cold'), ...this.#sessionMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
|
|
1523
1805
|
if (!existsSync(dir)) continue;
|
|
1524
1806
|
for (const f of readdirSync(dir).filter(x => x.endsWith('.md'))) {
|
|
@@ -1539,12 +1821,6 @@ export class ConversationStore {
|
|
|
1539
1821
|
return collected.map(x => x.msg);
|
|
1540
1822
|
}
|
|
1541
1823
|
|
|
1542
|
-
#messageDirFor(msg) {
|
|
1543
|
-
if (msg?.chatId) return join(this.#chatConversationDir(msg.chatId, { create: true }), 'messages');
|
|
1544
|
-
if (!msg?.sessionId) return this.#chatMsgDir;
|
|
1545
|
-
return join(this.#sessionConversationDir(msg.sessionId, { create: true }), 'messages');
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
1824
|
#chatConversationDir(chatId, { create = false } = {}) {
|
|
1549
1825
|
const dir = join(this.#dir, 'chats', this.#safeDirComponent(chatId), 'conversation');
|
|
1550
1826
|
if (create) this.#ensureConversationDirs(dir);
|
|
@@ -1610,6 +1886,7 @@ export class ConversationStore {
|
|
|
1610
1886
|
loadRecentByChat(chatId, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
1611
1887
|
if (!chatId) return [];
|
|
1612
1888
|
const all = [
|
|
1889
|
+
...this.#readSegmentRows(this.#chatConversationDir(chatId)),
|
|
1613
1890
|
...this.#chatMessageDirs('messages', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1614
1891
|
...this.#chatMessageDirs('cold', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1615
1892
|
].sort(compareMessagesBySeq);
|
|
@@ -1622,6 +1899,7 @@ export class ConversationStore {
|
|
|
1622
1899
|
loadChatHistoryForVp(chatId, vpId) {
|
|
1623
1900
|
if (!chatId || !vpId) return [];
|
|
1624
1901
|
const all = [
|
|
1902
|
+
...this.#readSegmentRows(this.#chatConversationDir(chatId)),
|
|
1625
1903
|
...this.#chatMessageDirs('messages', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1626
1904
|
...this.#chatMessageDirs('cold', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1627
1905
|
].sort(compareMessagesBySeq);
|
|
@@ -1651,15 +1929,28 @@ export class ConversationStore {
|
|
|
1651
1929
|
}
|
|
1652
1930
|
|
|
1653
1931
|
#ensureConversationDirs(dir) {
|
|
1654
|
-
for (const d of [dir, join(dir, 'blobs'), join(dir, 'messages'), join(dir, 'cold'), join(dir, 'compact')]) {
|
|
1932
|
+
for (const d of [dir, join(dir, 'blobs'), join(dir, SEGMENT_DIR), join(dir, 'messages'), join(dir, 'cold'), join(dir, 'compact')]) {
|
|
1655
1933
|
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
|
|
1656
1934
|
}
|
|
1657
1935
|
}
|
|
1658
1936
|
|
|
1659
|
-
#
|
|
1937
|
+
#segmentStoreFor(msg, { create = false } = {}) {
|
|
1938
|
+
let dir;
|
|
1939
|
+
if (msg?.chatId) dir = this.#chatConversationDir(msg.chatId, { create });
|
|
1940
|
+
else if (msg?.sessionId) dir = this.#sessionConversationDir(msg.sessionId, { create });
|
|
1941
|
+
else dir = this.#chatDir;
|
|
1942
|
+
return new SegmentStore(dir);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
#segmentStoreForConversationDir(dir) {
|
|
1946
|
+
return new SegmentStore(dir);
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
#sessionConversationDirs({ primaryOnly = false } = {}) {
|
|
1660
1951
|
const dirs = [];
|
|
1661
1952
|
const seen = new Set();
|
|
1662
|
-
for (const root of [this.#sessionsDir, this.#legacySessionsDir]) {
|
|
1953
|
+
for (const root of (primaryOnly ? [this.#sessionsDir] : [this.#sessionsDir, this.#legacySessionsDir])) {
|
|
1663
1954
|
if (!existsSync(root)) continue;
|
|
1664
1955
|
for (const name of readdirSync(root)) {
|
|
1665
1956
|
const sessionDir = join(root, name);
|
|
@@ -1679,15 +1970,19 @@ export class ConversationStore {
|
|
|
1679
1970
|
}
|
|
1680
1971
|
|
|
1681
1972
|
#sessionMessageDirs(kind, sessionId = null) {
|
|
1973
|
+
const kinds = kind === 'all' ? ['cold', 'messages'] : [kind];
|
|
1682
1974
|
if (sessionId) {
|
|
1683
|
-
const dirs = [
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1975
|
+
const dirs = [];
|
|
1976
|
+
for (const k of kinds) {
|
|
1977
|
+
dirs.push(
|
|
1978
|
+
join(this.#sessionConversationDir(sessionId), k),
|
|
1979
|
+
join(this.#legacySessionConversationDir(sessionId), k),
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1687
1982
|
return dirs.filter(dir => existsSync(dir));
|
|
1688
1983
|
}
|
|
1689
1984
|
return this.#sessionConversationDirs()
|
|
1690
|
-
.
|
|
1985
|
+
.flatMap(dir => kinds.map(k => join(dir, k)))
|
|
1691
1986
|
.filter(dir => existsSync(dir));
|
|
1692
1987
|
}
|
|
1693
1988
|
|
|
@@ -1705,15 +2000,18 @@ export class ConversationStore {
|
|
|
1705
2000
|
// mode compatibility, only import legacy records that are not stamped with
|
|
1706
2001
|
// a sessionId, so group mode cannot bleed into chat.
|
|
1707
2002
|
return [
|
|
2003
|
+
...this.#readSegmentRows(this.#chatDir).filter(m => !m?.sessionId),
|
|
1708
2004
|
...this.#loadFromDir(this.#legacyMsgDir, Infinity).filter(m => !m?.sessionId),
|
|
1709
2005
|
...this.#loadFromDir(this.#chatMsgDir, Infinity),
|
|
1710
2006
|
].sort(compareMessagesBySeq);
|
|
1711
2007
|
}
|
|
1712
2008
|
|
|
1713
2009
|
#loadSessionHotMessages(sessionId = null) {
|
|
1714
|
-
return this.#
|
|
1715
|
-
|
|
1716
|
-
.
|
|
2010
|
+
if (sessionId) return this.#readSessionRows(sessionId);
|
|
2011
|
+
return [
|
|
2012
|
+
...this.#sessionConversationDirs({ primaryOnly: true }).flatMap(dir => this.#readSegmentRows(dir)),
|
|
2013
|
+
...this.#sessionMessageDirs('messages', null).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
2014
|
+
].sort(compareMessagesBySeq);
|
|
1717
2015
|
}
|
|
1718
2016
|
|
|
1719
2017
|
#loadSessionMessages(sessionId = null) {
|
|
@@ -1760,20 +2058,13 @@ export class ConversationStore {
|
|
|
1760
2058
|
// and stop after the requested turn window is complete. Hidden/internal and
|
|
1761
2059
|
// non-turn rows are not allowed to force an unbounded scan; a hard parse cap
|
|
1762
2060
|
// conservatively marks the page truncated.
|
|
1763
|
-
for (const
|
|
2061
|
+
for (const m of this.#iterateSessionRows(sessionId, { beforeSeq, desc: true })) {
|
|
1764
2062
|
if (parsed >= scanCap) {
|
|
1765
2063
|
truncated = true;
|
|
1766
2064
|
break;
|
|
1767
2065
|
}
|
|
1768
2066
|
parsed += 1;
|
|
1769
2067
|
|
|
1770
|
-
let m;
|
|
1771
|
-
try {
|
|
1772
|
-
m = this.readMessageFile(entry.path);
|
|
1773
|
-
} catch (err) {
|
|
1774
|
-
if (isPermissionError(err)) continue;
|
|
1775
|
-
throw err;
|
|
1776
|
-
}
|
|
1777
2068
|
if (!m || m.sessionId !== sessionId) continue;
|
|
1778
2069
|
|
|
1779
2070
|
const boundaryComplete = turnsFromEnd >= turnsLimit;
|
|
@@ -1824,8 +2115,38 @@ export class ConversationStore {
|
|
|
1824
2115
|
};
|
|
1825
2116
|
}
|
|
1826
2117
|
|
|
2118
|
+
#readSegmentRows(conversationDir, opts = {}) {
|
|
2119
|
+
return this.#segmentStoreForConversationDir(conversationDir).readAll(opts);
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
*#iterateSessionRows(sessionId, opts = {}) {
|
|
2123
|
+
const primaryDir = this.#sessionConversationDir(sessionId);
|
|
2124
|
+
const segmentStore = this.#segmentStoreForConversationDir(primaryDir);
|
|
2125
|
+
yield* segmentStore.scan({ includeCold: true, ...opts });
|
|
2126
|
+
for (const entry of this.#sessionFileEntries('all', sessionId, opts)) {
|
|
2127
|
+
try {
|
|
2128
|
+
const msg = this.readMessageFile(entry.path);
|
|
2129
|
+
if (msg) yield msg;
|
|
2130
|
+
} catch (err) {
|
|
2131
|
+
if (isPermissionError(err)) continue;
|
|
2132
|
+
throw err;
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
#readSessionRows(sessionId, opts = {}) {
|
|
2138
|
+
return Array.from(this.#iterateSessionRows(sessionId, opts)).sort(compareMessagesBySeq);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
#countSegmentMessages(conversationDirs, kind = 'hot') {
|
|
2142
|
+
let total = 0;
|
|
2143
|
+
for (const dir of conversationDirs) total += this.#segmentStoreForConversationDir(dir).count(kind);
|
|
2144
|
+
return total;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
1827
2147
|
#sessionFileEntries(kind, sessionId, { beforeSeq = Infinity, afterSeq = -Infinity, desc = false } = {}) {
|
|
1828
2148
|
const entries = [];
|
|
2149
|
+
const kindOrder = kind === 'all' ? { cold: 0, messages: 1 } : null;
|
|
1829
2150
|
for (const dir of this.#sessionMessageDirs(kind, sessionId)) {
|
|
1830
2151
|
let files;
|
|
1831
2152
|
try {
|
|
@@ -1840,10 +2161,14 @@ export class ConversationStore {
|
|
|
1840
2161
|
if (!Number.isFinite(seq)) continue;
|
|
1841
2162
|
if (Number.isFinite(beforeSeq) && seq >= beforeSeq) continue;
|
|
1842
2163
|
if (Number.isFinite(afterSeq) && seq <= afterSeq) continue;
|
|
1843
|
-
|
|
2164
|
+
const kindRank = kindOrder ? kindOrder[basename(dir)] ?? 0 : 0;
|
|
2165
|
+
entries.push({ path: join(dir, file), seq, kindRank });
|
|
1844
2166
|
}
|
|
1845
2167
|
}
|
|
1846
|
-
entries.sort((a, b) =>
|
|
2168
|
+
entries.sort((a, b) => {
|
|
2169
|
+
if (a.seq !== b.seq) return desc ? b.seq - a.seq : a.seq - b.seq;
|
|
2170
|
+
return desc ? b.kindRank - a.kindRank : a.kindRank - b.kindRank;
|
|
2171
|
+
});
|
|
1847
2172
|
return entries;
|
|
1848
2173
|
}
|
|
1849
2174
|
|
|
@@ -1853,11 +2178,25 @@ export class ConversationStore {
|
|
|
1853
2178
|
...this.#loadFromDir(this.#legacyMsgDir, Infinity),
|
|
1854
2179
|
...this.#loadFromDir(this.#chatColdDir, Infinity),
|
|
1855
2180
|
...this.#loadFromDir(this.#chatMsgDir, Infinity),
|
|
2181
|
+
...this.#readSegmentRows(this.#chatDir, { includeCold: true }),
|
|
2182
|
+
...this.#sessionConversationDirs({ primaryOnly: true }).flatMap(dir => this.#readSegmentRows(dir, { includeCold: true })),
|
|
1856
2183
|
...this.#sessionMessageDirs('cold').flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1857
2184
|
...this.#sessionMessageDirs('messages').flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1858
2185
|
].sort(compareMessagesBySeq);
|
|
1859
2186
|
}
|
|
1860
2187
|
|
|
2188
|
+
#loadHotMessages() {
|
|
2189
|
+
return [
|
|
2190
|
+
...this.#loadFromDir(this.#legacyMsgDir, Infinity),
|
|
2191
|
+
...this.#loadFromDir(this.#chatMsgDir, Infinity),
|
|
2192
|
+
...this.#readSegmentRows(this.#chatDir),
|
|
2193
|
+
...this.#sessionConversationDirs({ primaryOnly: true }).flatMap(dir => this.#readSegmentRows(dir)),
|
|
2194
|
+
...this.#sessionMessageDirs('messages').flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
2195
|
+
]
|
|
2196
|
+
.filter(m => m && m.cold !== true)
|
|
2197
|
+
.sort(compareMessagesBySeq);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
1861
2200
|
#countFilesInDirs(dirs) {
|
|
1862
2201
|
let total = 0;
|
|
1863
2202
|
for (const dir of dirs) {
|
|
@@ -1922,7 +2261,7 @@ export class ConversationStore {
|
|
|
1922
2261
|
const messages = [];
|
|
1923
2262
|
for (const file of selected) {
|
|
1924
2263
|
const raw = readFileSync(join(dir, file), 'utf8');
|
|
1925
|
-
const parsed =
|
|
2264
|
+
const parsed = parseSegmentOrMarkdown(raw);
|
|
1926
2265
|
if (parsed) messages.push(parsed);
|
|
1927
2266
|
}
|
|
1928
2267
|
|
|
@@ -1937,6 +2276,13 @@ export class ConversationStore {
|
|
|
1937
2276
|
if (this.#nextSeq != null) return this.#nextSeq;
|
|
1938
2277
|
|
|
1939
2278
|
let maxSeq = 0;
|
|
2279
|
+
for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
2280
|
+
const store = this.#segmentStoreForConversationDir(dir);
|
|
2281
|
+
if (store.hasData()) {
|
|
2282
|
+
const idx = store.loadIndex();
|
|
2283
|
+
maxSeq = Math.max(maxSeq, (Number(idx.nextSeq) || 1) - 1);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
1940
2286
|
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1941
2287
|
if (!existsSync(dir)) continue;
|
|
1942
2288
|
for (const file of readdirSync(dir)) {
|