@yeaft/webchat-agent 1.0.76 → 1.0.77
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/package.json +1 -1
- package/yeaft/conversation/persist.js +458 -112
- package/yeaft/conversation/search.js +51 -15
- package/yeaft/session.js +27 -18
- package/yeaft/web-bridge.js +25 -10
package/package.json
CHANGED
|
@@ -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)) {
|
|
@@ -8,14 +8,24 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
|
8
8
|
import { join } from 'path';
|
|
9
9
|
import { parseMessage, parseSeqFromId } from './persist.js';
|
|
10
10
|
|
|
11
|
+
function parseJsonLine(line) {
|
|
12
|
+
if (!line || !line.trim()) return null;
|
|
13
|
+
try {
|
|
14
|
+
const msg = JSON.parse(line);
|
|
15
|
+
return msg && typeof msg === 'object' ? msg : null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
/**
|
|
12
|
-
* Search messages in a directory for a keyword.
|
|
22
|
+
* Search Markdown messages in a directory for a keyword.
|
|
13
23
|
*
|
|
14
24
|
* @param {string} dir — messages directory
|
|
15
25
|
* @param {string} keyword — search term (case-insensitive)
|
|
16
26
|
* @returns {object[]} — matching messages
|
|
17
27
|
*/
|
|
18
|
-
function
|
|
28
|
+
function searchMarkdownDir(dir, keyword) {
|
|
19
29
|
if (!existsSync(dir)) return [];
|
|
20
30
|
|
|
21
31
|
const lowerKeyword = keyword.toLowerCase();
|
|
@@ -35,6 +45,29 @@ function searchDir(dir, keyword) {
|
|
|
35
45
|
return results;
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
function searchSegmentDir(dir, keyword) {
|
|
49
|
+
if (!existsSync(dir)) return [];
|
|
50
|
+
const lowerKeyword = keyword.toLowerCase();
|
|
51
|
+
const files = readdirSync(dir)
|
|
52
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
53
|
+
.sort()
|
|
54
|
+
.reverse();
|
|
55
|
+
|
|
56
|
+
const results = [];
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const raw = readFileSync(join(dir, file), 'utf8');
|
|
59
|
+
if (!raw.toLowerCase().includes(lowerKeyword)) continue;
|
|
60
|
+
const lines = raw.split('\n');
|
|
61
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
62
|
+
const line = lines[i];
|
|
63
|
+
if (!line || !line.toLowerCase().includes(lowerKeyword)) continue;
|
|
64
|
+
const msg = parseJsonLine(line);
|
|
65
|
+
if (msg) results.push(msg);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
38
71
|
function compareNewest(a, b) {
|
|
39
72
|
const sa = parseSeqFromId(a?.id);
|
|
40
73
|
const sb = parseSeqFromId(b?.id);
|
|
@@ -42,7 +75,7 @@ function compareNewest(a, b) {
|
|
|
42
75
|
return String(b?.time || '').localeCompare(String(a?.time || ''));
|
|
43
76
|
}
|
|
44
77
|
|
|
45
|
-
function
|
|
78
|
+
function sessionConversationDirs(dir) {
|
|
46
79
|
const dirs = [];
|
|
47
80
|
const seen = new Set();
|
|
48
81
|
for (const rootName of ['sessions', 'groups']) {
|
|
@@ -57,12 +90,9 @@ function sessionConversationMessageDirs(dir) {
|
|
|
57
90
|
}
|
|
58
91
|
|
|
59
92
|
const conversationDir = join(sessionDir, 'conversation');
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
seen.add(messagesDir);
|
|
64
|
-
dirs.push(messagesDir);
|
|
65
|
-
}
|
|
93
|
+
if (seen.has(conversationDir)) continue;
|
|
94
|
+
seen.add(conversationDir);
|
|
95
|
+
dirs.push(conversationDir);
|
|
66
96
|
}
|
|
67
97
|
}
|
|
68
98
|
return dirs;
|
|
@@ -79,17 +109,23 @@ function sessionConversationMessageDirs(dir) {
|
|
|
79
109
|
export function searchMessages(dir, keyword, limit = 20) {
|
|
80
110
|
if (!keyword || !keyword.trim()) return [];
|
|
81
111
|
|
|
82
|
-
const
|
|
83
|
-
join(dir, 'chat'
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
const conversationDirs = [
|
|
113
|
+
join(dir, 'chat'),
|
|
114
|
+
...sessionConversationDirs(dir),
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const markdownDirs = [
|
|
118
|
+
...conversationDirs.flatMap(d => [join(d, 'messages'), join(d, 'cold')]),
|
|
86
119
|
// Compatibility for profiles created before chat/session split.
|
|
87
120
|
join(dir, 'conversation', 'messages'),
|
|
88
121
|
join(dir, 'conversation', 'cold'),
|
|
89
122
|
];
|
|
123
|
+
const segmentDirs = conversationDirs.map(d => join(d, 'segments'));
|
|
90
124
|
|
|
91
|
-
return
|
|
92
|
-
.flatMap(d =>
|
|
125
|
+
return [
|
|
126
|
+
...segmentDirs.flatMap(d => searchSegmentDir(d, keyword)),
|
|
127
|
+
...markdownDirs.flatMap(d => searchMarkdownDir(d, keyword)),
|
|
128
|
+
]
|
|
93
129
|
.sort(compareNewest)
|
|
94
130
|
.slice(0, limit);
|
|
95
131
|
}
|
package/yeaft/session.js
CHANGED
|
@@ -45,7 +45,7 @@ import { TaskManager } from './tasks/manager.js';
|
|
|
45
45
|
// resumes with the same onDemand/recent membership it had on
|
|
46
46
|
// disconnect. Engine.#runQuery uses the registry to populate the
|
|
47
47
|
// AMS each turn and to run `memory/adjust.js` post-turn.
|
|
48
|
-
import { ensureDefaultSessionIfEmpty } from './sessions/session-crud.js';
|
|
48
|
+
import { ensureDefaultSessionIfEmpty, yeaftDirForWorkDir } from './sessions/session-crud.js';
|
|
49
49
|
import { seedDefaultVps } from './vp/seed-defaults.js';
|
|
50
50
|
import { topUpDefaultVps } from './vp/seed-topup.js';
|
|
51
51
|
import { archiveLegacyScopes } from './memory/seed-backfill.js';
|
|
@@ -71,6 +71,7 @@ const DEFAULT_COMPACT_TRIGGER_RATIO = 0.7;
|
|
|
71
71
|
/**
|
|
72
72
|
* @typedef {Object} SessionOptions
|
|
73
73
|
* @property {string} [dir] — Yeaft data directory override (default: ~/.yeaft)
|
|
74
|
+
* @property {string} [workDir] — Session workDir; when provided, storage lives under <workDir>/.yeaft while config still comes from dir/defaults
|
|
74
75
|
* @property {string} [model] — Model override
|
|
75
76
|
* @property {string} [language] — Language override ('en' | 'zh')
|
|
76
77
|
* @property {boolean} [debug] — Debug mode override
|
|
@@ -136,6 +137,7 @@ function prepareToolStatsDir(yeaftDir) {
|
|
|
136
137
|
export async function loadSession(options = {}) {
|
|
137
138
|
const {
|
|
138
139
|
dir,
|
|
140
|
+
workDir,
|
|
139
141
|
model,
|
|
140
142
|
language,
|
|
141
143
|
debug,
|
|
@@ -146,21 +148,29 @@ export async function loadSession(options = {}) {
|
|
|
146
148
|
serverMode = false,
|
|
147
149
|
} = options;
|
|
148
150
|
|
|
149
|
-
// ─── 1. Determine
|
|
150
|
-
// Must happen BEFORE loadConfig so that first-run
|
|
151
|
-
//
|
|
151
|
+
// ─── 1. Determine config + store directories ─────────────
|
|
152
|
+
// Must happen BEFORE loadConfig so that first-run generates a
|
|
153
|
+
// default config.json that loadConfig can read. workDir-backed
|
|
154
|
+
// Sessions store conversation/session data under <workDir>/.yeaft,
|
|
155
|
+
// but runtime config still comes from the agent-local configDir.
|
|
152
156
|
const overrides = { ...configOverrides };
|
|
153
157
|
if (dir) overrides.dir = dir;
|
|
154
158
|
if (model) overrides.model = model;
|
|
155
159
|
if (language) overrides.language = language;
|
|
156
160
|
if (debug !== undefined) overrides.debug = debug;
|
|
157
161
|
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
const sessionWorkDir = typeof workDir === 'string' && workDir.trim() ? workDir.trim() : '';
|
|
163
|
+
const configDir = overrides.dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
164
|
+
const yeaftDir = sessionWorkDir ? yeaftDirForWorkDir(sessionWorkDir) : configDir;
|
|
165
|
+
const configInitResult = initYeaftDir(configDir);
|
|
166
|
+
const storeInitResult = yeaftDir === configDir ? configInitResult : initYeaftDir(yeaftDir);
|
|
167
|
+
overrides.dir = configDir;
|
|
168
|
+
|
|
169
|
+
// Log any warnings from directory initialization.
|
|
170
|
+
const initWarnings = yeaftDir === configDir
|
|
171
|
+
? configInitResult.warnings
|
|
172
|
+
: [...configInitResult.warnings, ...storeInitResult.warnings];
|
|
173
|
+
for (const w of initWarnings) {
|
|
164
174
|
console.warn(`[Yeaft] ${w}`);
|
|
165
175
|
}
|
|
166
176
|
|
|
@@ -212,7 +222,7 @@ export async function loadSession(options = {}) {
|
|
|
212
222
|
// ─── 2a. Permission pre-check ─────────────────────────
|
|
213
223
|
// If the data dir is not writable, mark session as read-only.
|
|
214
224
|
// Persistence (conversation, memory, dream) is skipped in this mode.
|
|
215
|
-
if (!
|
|
225
|
+
if (!storeInitResult.writable) {
|
|
216
226
|
config._readOnly = true;
|
|
217
227
|
console.warn(`[Yeaft] ${yeaftDir} is not writable — running in read-only mode`);
|
|
218
228
|
}
|
|
@@ -365,24 +375,23 @@ export async function loadSession(options = {}) {
|
|
|
365
375
|
}
|
|
366
376
|
|
|
367
377
|
// ─── 6. Load skills ────────────────────────────────────
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
const projectTierRoot = process.cwd();
|
|
378
|
+
// User/global runtime assets still come from configDir. workDir, when
|
|
379
|
+
// present, is only a project tier overlay plus the storage root.
|
|
380
|
+
const projectTierRoot = sessionWorkDir || process.cwd();
|
|
372
381
|
|
|
373
382
|
let skillManager;
|
|
374
383
|
if (skipSkills) {
|
|
375
384
|
// Pass the literal user-tier dir (matches the normal branch's tier 2)
|
|
376
385
|
// so any save/remove calls land in the same place users expect. New
|
|
377
386
|
// `SkillManager` API takes literal scan dirs — no auto-suffix of /skills.
|
|
378
|
-
skillManager = new SkillManager(join(
|
|
387
|
+
skillManager = new SkillManager(join(configDir, 'skills'));
|
|
379
388
|
// Don't call .load() — empty skill manager
|
|
380
389
|
} else {
|
|
381
|
-
skillManager = createSkillManager(
|
|
390
|
+
skillManager = createSkillManager(configDir, projectTierRoot);
|
|
382
391
|
}
|
|
383
392
|
|
|
384
393
|
// ─── 7. Connect MCP servers ────────────────────────────
|
|
385
|
-
const mcpConfig = loadMCPConfig(
|
|
394
|
+
const mcpConfig = loadMCPConfig(configDir, undefined, projectTierRoot);
|
|
386
395
|
const mcpManager = new MCPManager();
|
|
387
396
|
let mcpStatus = { connected: [], failed: [] };
|
|
388
397
|
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
scanWorkdirSessions,
|
|
54
54
|
restoreSessionToRegistry,
|
|
55
55
|
readWorkDirRegistry,
|
|
56
|
+
yeaftDirForWorkDir,
|
|
56
57
|
} from './sessions/session-crud.js';
|
|
57
58
|
import { openSession, loadSessionMeta } from './sessions/session-store.js';
|
|
58
59
|
import { loadSessionConfig, resolveSessionConfig, SessionConfigError } from './sessions/session-config.js';
|
|
@@ -414,6 +415,13 @@ function projectRuntimeKey(workDir) {
|
|
|
414
415
|
return normalizeSessionWorkDir(workDir) || '__agent_cwd__';
|
|
415
416
|
}
|
|
416
417
|
|
|
418
|
+
function resolveStoreYeaftDirForSession(defaultYeaftDir, { sessionId = null, sessionMeta = null, workDir = '' } = {}) {
|
|
419
|
+
const normalizedWorkDir = normalizeSessionWorkDir(workDir || sessionMeta?.workDir);
|
|
420
|
+
if (normalizedWorkDir) return yeaftDirForWorkDir(normalizedWorkDir);
|
|
421
|
+
if (sessionId) return resolveSessionYeaftDir(defaultYeaftDir, sessionId);
|
|
422
|
+
return defaultYeaftDir;
|
|
423
|
+
}
|
|
424
|
+
|
|
417
425
|
function createThreadId() {
|
|
418
426
|
return `thr_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
|
|
419
427
|
}
|
|
@@ -3880,6 +3888,11 @@ async function ensureSessionLoaded(opts = {}) {
|
|
|
3880
3888
|
sessionLoadPromise = (async () => {
|
|
3881
3889
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
3882
3890
|
const normalizedWorkDir = normalizeSessionWorkDir(opts?.workDir || opts?.sessionMeta?.workDir);
|
|
3891
|
+
const sessionYeaftDir = resolveStoreYeaftDirForSession(yeaftDir, {
|
|
3892
|
+
sessionId: opts?.sessionId || opts?.sessionMeta?.id || null,
|
|
3893
|
+
sessionMeta: opts?.sessionMeta || null,
|
|
3894
|
+
workDir: normalizedWorkDir,
|
|
3895
|
+
});
|
|
3883
3896
|
session = await loadSession({
|
|
3884
3897
|
...(yeaftDir && { dir: yeaftDir }),
|
|
3885
3898
|
...(normalizedWorkDir && { workDir: normalizedWorkDir }),
|
|
@@ -5640,12 +5653,22 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
5640
5653
|
const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
|
|
5641
5654
|
ensureYeaftConversationId();
|
|
5642
5655
|
|
|
5656
|
+
let sessionMetaForRuntime = null;
|
|
5657
|
+
let sessionYeaftDir = yeaftDir;
|
|
5658
|
+
if (sessionId) {
|
|
5659
|
+
try {
|
|
5660
|
+
sessionYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
|
|
5661
|
+
const metaDir = join(sessionsRoot(sessionYeaftDir), sessionId);
|
|
5662
|
+
sessionMetaForRuntime = loadSessionMeta(metaDir);
|
|
5663
|
+
} catch { /* best-effort metadata hint */ }
|
|
5664
|
+
}
|
|
5665
|
+
|
|
5643
5666
|
// First paint must not wait for full Yeaft runtime boot (MCP connects,
|
|
5644
|
-
// skill scans, memory index sync). The conversation
|
|
5667
|
+
// skill scans, memory index sync). The conversation segment store is the
|
|
5645
5668
|
// source of truth and can be opened cheaply, so replay the visible message
|
|
5646
5669
|
// window immediately, then finish loadSession below for actual turns.
|
|
5647
5670
|
const coldStoreStart = perfNowMs();
|
|
5648
|
-
const coldStore = new ConversationStore(
|
|
5671
|
+
const coldStore = new ConversationStore(sessionYeaftDir);
|
|
5649
5672
|
traceDuration('history.cold_store_open', coldStoreStart);
|
|
5650
5673
|
if (sessionId && (afterSeqRaw !== null || afterMessageId)) {
|
|
5651
5674
|
let afterSeq = afterSeqRaw;
|
|
@@ -5675,14 +5698,6 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
5675
5698
|
}
|
|
5676
5699
|
historyAlreadyReplayed = true;
|
|
5677
5700
|
|
|
5678
|
-
let sessionMetaForRuntime = null;
|
|
5679
|
-
if (sessionId) {
|
|
5680
|
-
try {
|
|
5681
|
-
const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
|
|
5682
|
-
const metaDir = join(sessionsRoot(groupYeaftDir), sessionId);
|
|
5683
|
-
sessionMetaForRuntime = loadSessionMeta(metaDir);
|
|
5684
|
-
} catch { /* best-effort metadata hint */ }
|
|
5685
|
-
}
|
|
5686
5701
|
// Full runtime boot can be expensive (memory FTS sync, skills, MCP, dream
|
|
5687
5702
|
// boot checks). It is not needed to render persisted history, so keep this
|
|
5688
5703
|
// request short and let message-send await the same single-flight boot when
|