@yeaft/webchat-agent 1.0.77 → 1.0.79
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 +112 -458
- package/yeaft/conversation/search.js +15 -51
- package/yeaft/session.js +18 -27
- package/yeaft/web-bridge.js +10 -25
package/package.json
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* persist.js — Conversation message persistence
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Older per-message Markdown
|
|
7
|
-
* files are read as a legacy fallback only.
|
|
4
|
+
* Each message is stored as a .md file with YAML frontmatter in
|
|
5
|
+
* ~/.yeaft/chat/messages/ or ~/.yeaft/sessions/<sessionId>/conversation/messages/. Design: zero JSON, all Markdown.
|
|
8
6
|
*
|
|
9
7
|
* Vocabulary note: the primary on-disk layout uses `sessions/<id>/`. Older
|
|
10
8
|
* installs may still have transcript files under `groups/<id>/`; those are
|
|
11
9
|
* read as a legacy fallback only. Every API surface above the disk layer
|
|
12
10
|
* uses "session" vocabulary.
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Message format:
|
|
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...
|
|
16
22
|
*
|
|
17
23
|
* Reference: yeaft-yeaft-core-systems.md §4.1, yeaft-yeaft-brainstorm-v5.1.md
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync
|
|
26
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync } from 'fs';
|
|
21
27
|
import { join, basename } from 'path';
|
|
22
28
|
import { isPermissionError } from '../init.js';
|
|
23
29
|
import { pairSanitize } from '../pair-sanitize.js';
|
|
@@ -58,58 +64,6 @@ const RECENT_SESSION_SCAN_BASE_CAP = 64;
|
|
|
58
64
|
const RECENT_SESSION_SCAN_PER_TURN_CAP = 4;
|
|
59
65
|
const RECENT_SESSION_SCAN_MAX_CAP = 256;
|
|
60
66
|
|
|
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
|
-
|
|
113
67
|
/** Read the current default cold-start replay window (turn count). */
|
|
114
68
|
export function getDefaultRecentTurnsLimit() {
|
|
115
69
|
return DEFAULT_RECENT_TURNS;
|
|
@@ -487,209 +441,6 @@ export function parseMessage(raw) {
|
|
|
487
441
|
return msg;
|
|
488
442
|
}
|
|
489
443
|
|
|
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
|
-
|
|
693
444
|
// ─── ConversationStore ───────────────────────────────────────
|
|
694
445
|
|
|
695
446
|
/**
|
|
@@ -697,14 +448,15 @@ class SegmentStore {
|
|
|
697
448
|
*
|
|
698
449
|
* Directory layout:
|
|
699
450
|
* chat/ — one-to-one chat mode history
|
|
700
|
-
* index.
|
|
701
|
-
* segments/
|
|
451
|
+
* index.md
|
|
702
452
|
* compact.md
|
|
453
|
+
* messages/
|
|
454
|
+
* cold/
|
|
703
455
|
* blobs/
|
|
704
456
|
* sessions/<sessionId>/conversation/
|
|
705
|
-
* index.json
|
|
706
|
-
* segments/
|
|
707
457
|
* compact/
|
|
458
|
+
* messages/
|
|
459
|
+
* cold/
|
|
708
460
|
* blobs/
|
|
709
461
|
*
|
|
710
462
|
* Legacy compatibility: ~/.yeaft/conversation is read as an old mixed store,
|
|
@@ -765,7 +517,7 @@ export class ConversationStore {
|
|
|
765
517
|
// errors). Per-session conversation directories are created lazily once a
|
|
766
518
|
// sessionId is known. Legacy directories are never created by new versions.
|
|
767
519
|
for (const d of [
|
|
768
|
-
this.#chatDir, join(this.#chatDir, 'blobs'),
|
|
520
|
+
this.#chatDir, join(this.#chatDir, 'blobs'), this.#chatMsgDir, this.#chatColdDir,
|
|
769
521
|
this.#sessionsDir,
|
|
770
522
|
]) {
|
|
771
523
|
try {
|
|
@@ -801,8 +553,9 @@ export class ConversationStore {
|
|
|
801
553
|
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
802
554
|
};
|
|
803
555
|
|
|
556
|
+
const filePath = join(this.#messageDirFor(fullMsg), `${id}.md`);
|
|
804
557
|
try {
|
|
805
|
-
|
|
558
|
+
writeFileSync(filePath, serializeMessage(fullMsg), { encoding: 'utf8', mode: 0o644 });
|
|
806
559
|
} catch (err) {
|
|
807
560
|
if (isPermissionError(err)) {
|
|
808
561
|
if (!_permissionWarned) {
|
|
@@ -835,10 +588,6 @@ export class ConversationStore {
|
|
|
835
588
|
* @param {string} id — message id (e.g. "m0355")
|
|
836
589
|
*/
|
|
837
590
|
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
|
-
}
|
|
842
591
|
for (const [hotDir, coldDir] of this.#hotColdDirPairs({ includeLegacy: false })) {
|
|
843
592
|
const src = join(hotDir, `${id}.md`);
|
|
844
593
|
const dst = join(coldDir, `${id}.md`);
|
|
@@ -1078,10 +827,7 @@ export class ConversationStore {
|
|
|
1078
827
|
}
|
|
1079
828
|
}
|
|
1080
829
|
}
|
|
1081
|
-
|
|
1082
|
-
this.#segmentStoreForConversationDir(dir).clear();
|
|
1083
|
-
}
|
|
1084
|
-
// Reset compact files in the new chat/session stores. Legacy
|
|
830
|
+
// Reset compact files in the new chat/group stores. Legacy
|
|
1085
831
|
// ~/.yeaft/conversation is intentionally left untouched.
|
|
1086
832
|
for (const path of [this.#compactPath]) {
|
|
1087
833
|
if (!existsSync(path)) continue;
|
|
@@ -1293,9 +1039,11 @@ export class ConversationStore {
|
|
|
1293
1039
|
*/
|
|
1294
1040
|
loadOlderBySession(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
1295
1041
|
if (!sessionId) return { messages: [], oldestSeq: null, hasMore: false };
|
|
1042
|
+
const all = this.#loadSessionMessages(sessionId);
|
|
1296
1043
|
const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
|
|
1297
|
-
const prefix =
|
|
1298
|
-
|
|
1044
|
+
const prefix = all.filter(m => m && m.sessionId === sessionId
|
|
1045
|
+
&& !isHiddenConversationRow(m)
|
|
1046
|
+
&& parseSeqFromId(m.id) < cutoff);
|
|
1299
1047
|
if (prefix.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
|
|
1300
1048
|
const sliced = pairSanitize(sliceLastNTurns(prefix, turnsLimit));
|
|
1301
1049
|
// Turn-based hasMore: there's an EARLIER turn boundary we didn't keep.
|
|
@@ -1369,7 +1117,14 @@ export class ConversationStore {
|
|
|
1369
1117
|
if (cutoff === null) return { messages: [], latestSeq: null };
|
|
1370
1118
|
const after = [];
|
|
1371
1119
|
let newestScannedSeq = cutoff;
|
|
1372
|
-
for (const
|
|
1120
|
+
for (const entry of this.#sessionFileEntries('messages', sessionId, { afterSeq: cutoff, desc: false })) {
|
|
1121
|
+
let m;
|
|
1122
|
+
try {
|
|
1123
|
+
m = this.readMessageFile(entry.path);
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
if (isPermissionError(err)) continue;
|
|
1126
|
+
throw err;
|
|
1127
|
+
}
|
|
1373
1128
|
if (!m || m.sessionId !== sessionId) continue;
|
|
1374
1129
|
const seq = parseSeqFromId(m.id);
|
|
1375
1130
|
if (Number.isFinite(seq) && seq > newestScannedSeq) newestScannedSeq = seq;
|
|
@@ -1401,13 +1156,7 @@ export class ConversationStore {
|
|
|
1401
1156
|
* @returns {number}
|
|
1402
1157
|
*/
|
|
1403
1158
|
countHot() {
|
|
1404
|
-
|
|
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);
|
|
1159
|
+
return this.#countFilesInDirs([this.#chatMsgDir, ...this.#sessionMessageDirs('messages'), this.#legacyMsgDir]);
|
|
1411
1160
|
}
|
|
1412
1161
|
|
|
1413
1162
|
/**
|
|
@@ -1416,8 +1165,7 @@ export class ConversationStore {
|
|
|
1416
1165
|
* @returns {number}
|
|
1417
1166
|
*/
|
|
1418
1167
|
countCold() {
|
|
1419
|
-
return this.#
|
|
1420
|
-
+ this.#countFilesInDirs([this.#chatColdDir, ...this.#sessionMessageDirs('cold'), this.#legacyColdDir]);
|
|
1168
|
+
return this.#countFilesInDirs([this.#chatColdDir, ...this.#sessionMessageDirs('cold'), this.#legacyColdDir]);
|
|
1421
1169
|
}
|
|
1422
1170
|
|
|
1423
1171
|
/**
|
|
@@ -1426,7 +1174,7 @@ export class ConversationStore {
|
|
|
1426
1174
|
* @returns {number}
|
|
1427
1175
|
*/
|
|
1428
1176
|
hotTokens() {
|
|
1429
|
-
const messages = this
|
|
1177
|
+
const messages = this.loadAll();
|
|
1430
1178
|
return messages.reduce((sum, m) => sum + (m.tokens_est || estimateTokens(m.content || '')), 0);
|
|
1431
1179
|
}
|
|
1432
1180
|
|
|
@@ -1473,13 +1221,7 @@ export class ConversationStore {
|
|
|
1473
1221
|
deleteByGroup(sessionId) {
|
|
1474
1222
|
if (!sessionId) return 0;
|
|
1475
1223
|
let removed = 0;
|
|
1476
|
-
const
|
|
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)]) {
|
|
1224
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1483
1225
|
if (!existsSync(dir)) continue;
|
|
1484
1226
|
let files;
|
|
1485
1227
|
try {
|
|
@@ -1508,6 +1250,7 @@ export class ConversationStore {
|
|
|
1508
1250
|
}
|
|
1509
1251
|
}
|
|
1510
1252
|
}
|
|
1253
|
+
// Invalidate cached next-seq — countHot/loadAll will re-scan.
|
|
1511
1254
|
this.#nextSeq = null;
|
|
1512
1255
|
return removed;
|
|
1513
1256
|
}
|
|
@@ -1536,33 +1279,6 @@ export class ConversationStore {
|
|
|
1536
1279
|
let scanned = 0;
|
|
1537
1280
|
let removed = 0;
|
|
1538
1281
|
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
|
-
|
|
1566
1282
|
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1567
1283
|
if (!existsSync(dir)) continue;
|
|
1568
1284
|
let files;
|
|
@@ -1582,7 +1298,11 @@ export class ConversationStore {
|
|
|
1582
1298
|
throw err;
|
|
1583
1299
|
}
|
|
1584
1300
|
const msg = parseMessage(raw);
|
|
1585
|
-
if (!
|
|
1301
|
+
if (!msg) continue;
|
|
1302
|
+
scanned += 1;
|
|
1303
|
+
const isOrphan = !msg.sessionId || !keep.has(msg.sessionId);
|
|
1304
|
+
if (!isOrphan) continue;
|
|
1305
|
+
orphans.push(path);
|
|
1586
1306
|
if (dryRun) continue;
|
|
1587
1307
|
try {
|
|
1588
1308
|
unlinkSync(path);
|
|
@@ -1613,25 +1333,6 @@ export class ConversationStore {
|
|
|
1613
1333
|
reassignThread(sourceId, targetId) {
|
|
1614
1334
|
if (!sourceId || !targetId || sourceId === targetId) return 0;
|
|
1615
1335
|
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
|
-
|
|
1635
1336
|
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1636
1337
|
if (!existsSync(dir)) continue;
|
|
1637
1338
|
let files;
|
|
@@ -1652,6 +1353,7 @@ export class ConversationStore {
|
|
|
1652
1353
|
}
|
|
1653
1354
|
const msg = parseMessage(raw);
|
|
1654
1355
|
if (!msg || msg.threadId !== sourceId) continue;
|
|
1356
|
+
// Preserve original thread id for UI pill; only stamp once.
|
|
1655
1357
|
if (!msg.sourceThreadId) msg.sourceThreadId = sourceId;
|
|
1656
1358
|
msg.threadId = targetId;
|
|
1657
1359
|
try {
|
|
@@ -1708,9 +1410,19 @@ export class ConversationStore {
|
|
|
1708
1410
|
throw err;
|
|
1709
1411
|
}
|
|
1710
1412
|
|
|
1711
|
-
// Collect source-thread candidate
|
|
1712
|
-
const sourceRows = this.#loadAllMessages().filter(m => m && m.threadId === sourceId);
|
|
1413
|
+
// Collect source-thread candidate files from both hot + cold dirs.
|
|
1713
1414
|
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
|
+
}
|
|
1714
1426
|
// Also pick up any already-forked sub-thread dir (chain fork).
|
|
1715
1427
|
const sourceSubDir = this.#threadMsgDir(sourceId);
|
|
1716
1428
|
if (existsSync(sourceSubDir)) {
|
|
@@ -1722,7 +1434,11 @@ export class ConversationStore {
|
|
|
1722
1434
|
if (!isPermissionError(err)) throw err;
|
|
1723
1435
|
}
|
|
1724
1436
|
}
|
|
1725
|
-
|
|
1437
|
+
// Sort by the "m{NNNN}" basename. For chain-fork, sub-thread ids also
|
|
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.
|
|
1726
1442
|
candidates.sort((a, b) => {
|
|
1727
1443
|
const ma = a.match(/m(\d+)\.md$/);
|
|
1728
1444
|
const mb = b.match(/m(\d+)\.md$/);
|
|
@@ -1734,13 +1450,25 @@ export class ConversationStore {
|
|
|
1734
1450
|
const cutoffSeq = parseInt(cutoffMatch[1], 10);
|
|
1735
1451
|
|
|
1736
1452
|
let copied = 0;
|
|
1737
|
-
const
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1453
|
+
for (const path of candidates) {
|
|
1454
|
+
const fileMatch = path.match(/m(\d+)\.md$/);
|
|
1455
|
+
if (!fileMatch) continue;
|
|
1456
|
+
const seq = parseInt(fileMatch[1], 10);
|
|
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.
|
|
1741
1469
|
const nextSeq = this.#getNextThreadSeq(targetId);
|
|
1742
1470
|
const newId = `m${String(nextSeq).padStart(4, '0')}`;
|
|
1743
|
-
const { id: _id,
|
|
1471
|
+
const { id: _id, ...rest } = msg;
|
|
1744
1472
|
const copy = {
|
|
1745
1473
|
...rest,
|
|
1746
1474
|
id: newId,
|
|
@@ -1750,18 +1478,10 @@ export class ConversationStore {
|
|
|
1750
1478
|
tokens_est: rest.tokens_est || estimateTokens(rest.content || ''),
|
|
1751
1479
|
};
|
|
1752
1480
|
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) {
|
|
1762
1481
|
try {
|
|
1763
|
-
|
|
1764
|
-
|
|
1482
|
+
writeFileSync(filePath, serializeMessage(copy), { encoding: 'utf8', mode: 0o644 });
|
|
1483
|
+
this.#nextSeqByThread.set(targetId, nextSeq + 1);
|
|
1484
|
+
copied += 1;
|
|
1765
1485
|
} catch (err) {
|
|
1766
1486
|
if (isPermissionError(err)) continue;
|
|
1767
1487
|
throw err;
|
|
@@ -1797,10 +1517,8 @@ export class ConversationStore {
|
|
|
1797
1517
|
}
|
|
1798
1518
|
return out;
|
|
1799
1519
|
}
|
|
1800
|
-
// Legacy
|
|
1801
|
-
const collected =
|
|
1802
|
-
.filter(msg => msg && msg.threadId === threadId)
|
|
1803
|
-
.map(msg => ({ msg, f: `${msg.id || ''}.md` }));
|
|
1520
|
+
// Legacy: messages live in the flat dir stamped with threadId.
|
|
1521
|
+
const collected = [];
|
|
1804
1522
|
for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#sessionMessageDirs('cold'), ...this.#sessionMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
|
|
1805
1523
|
if (!existsSync(dir)) continue;
|
|
1806
1524
|
for (const f of readdirSync(dir).filter(x => x.endsWith('.md'))) {
|
|
@@ -1821,6 +1539,12 @@ export class ConversationStore {
|
|
|
1821
1539
|
return collected.map(x => x.msg);
|
|
1822
1540
|
}
|
|
1823
1541
|
|
|
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
|
+
|
|
1824
1548
|
#chatConversationDir(chatId, { create = false } = {}) {
|
|
1825
1549
|
const dir = join(this.#dir, 'chats', this.#safeDirComponent(chatId), 'conversation');
|
|
1826
1550
|
if (create) this.#ensureConversationDirs(dir);
|
|
@@ -1886,7 +1610,6 @@ export class ConversationStore {
|
|
|
1886
1610
|
loadRecentByChat(chatId, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
1887
1611
|
if (!chatId) return [];
|
|
1888
1612
|
const all = [
|
|
1889
|
-
...this.#readSegmentRows(this.#chatConversationDir(chatId)),
|
|
1890
1613
|
...this.#chatMessageDirs('messages', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1891
1614
|
...this.#chatMessageDirs('cold', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1892
1615
|
].sort(compareMessagesBySeq);
|
|
@@ -1899,7 +1622,6 @@ export class ConversationStore {
|
|
|
1899
1622
|
loadChatHistoryForVp(chatId, vpId) {
|
|
1900
1623
|
if (!chatId || !vpId) return [];
|
|
1901
1624
|
const all = [
|
|
1902
|
-
...this.#readSegmentRows(this.#chatConversationDir(chatId)),
|
|
1903
1625
|
...this.#chatMessageDirs('messages', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1904
1626
|
...this.#chatMessageDirs('cold', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1905
1627
|
].sort(compareMessagesBySeq);
|
|
@@ -1929,28 +1651,15 @@ export class ConversationStore {
|
|
|
1929
1651
|
}
|
|
1930
1652
|
|
|
1931
1653
|
#ensureConversationDirs(dir) {
|
|
1932
|
-
for (const d of [dir, join(dir, 'blobs'), join(dir,
|
|
1654
|
+
for (const d of [dir, join(dir, 'blobs'), join(dir, 'messages'), join(dir, 'cold'), join(dir, 'compact')]) {
|
|
1933
1655
|
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
|
|
1934
1656
|
}
|
|
1935
1657
|
}
|
|
1936
1658
|
|
|
1937
|
-
#
|
|
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 } = {}) {
|
|
1659
|
+
#sessionConversationDirs() {
|
|
1951
1660
|
const dirs = [];
|
|
1952
1661
|
const seen = new Set();
|
|
1953
|
-
for (const root of
|
|
1662
|
+
for (const root of [this.#sessionsDir, this.#legacySessionsDir]) {
|
|
1954
1663
|
if (!existsSync(root)) continue;
|
|
1955
1664
|
for (const name of readdirSync(root)) {
|
|
1956
1665
|
const sessionDir = join(root, name);
|
|
@@ -1970,19 +1679,15 @@ export class ConversationStore {
|
|
|
1970
1679
|
}
|
|
1971
1680
|
|
|
1972
1681
|
#sessionMessageDirs(kind, sessionId = null) {
|
|
1973
|
-
const kinds = kind === 'all' ? ['cold', 'messages'] : [kind];
|
|
1974
1682
|
if (sessionId) {
|
|
1975
|
-
const dirs = [
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
join(this.#legacySessionConversationDir(sessionId), k),
|
|
1980
|
-
);
|
|
1981
|
-
}
|
|
1683
|
+
const dirs = [
|
|
1684
|
+
join(this.#sessionConversationDir(sessionId), kind),
|
|
1685
|
+
join(this.#legacySessionConversationDir(sessionId), kind),
|
|
1686
|
+
];
|
|
1982
1687
|
return dirs.filter(dir => existsSync(dir));
|
|
1983
1688
|
}
|
|
1984
1689
|
return this.#sessionConversationDirs()
|
|
1985
|
-
.
|
|
1690
|
+
.map(dir => join(dir, kind))
|
|
1986
1691
|
.filter(dir => existsSync(dir));
|
|
1987
1692
|
}
|
|
1988
1693
|
|
|
@@ -2000,18 +1705,15 @@ export class ConversationStore {
|
|
|
2000
1705
|
// mode compatibility, only import legacy records that are not stamped with
|
|
2001
1706
|
// a sessionId, so group mode cannot bleed into chat.
|
|
2002
1707
|
return [
|
|
2003
|
-
...this.#readSegmentRows(this.#chatDir).filter(m => !m?.sessionId),
|
|
2004
1708
|
...this.#loadFromDir(this.#legacyMsgDir, Infinity).filter(m => !m?.sessionId),
|
|
2005
1709
|
...this.#loadFromDir(this.#chatMsgDir, Infinity),
|
|
2006
1710
|
].sort(compareMessagesBySeq);
|
|
2007
1711
|
}
|
|
2008
1712
|
|
|
2009
1713
|
#loadSessionHotMessages(sessionId = null) {
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
...this.#sessionMessageDirs('messages', null).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
2014
|
-
].sort(compareMessagesBySeq);
|
|
1714
|
+
return this.#sessionMessageDirs('messages', sessionId)
|
|
1715
|
+
.flatMap(dir => this.#loadFromDir(dir, Infinity))
|
|
1716
|
+
.sort(compareMessagesBySeq);
|
|
2015
1717
|
}
|
|
2016
1718
|
|
|
2017
1719
|
#loadSessionMessages(sessionId = null) {
|
|
@@ -2058,13 +1760,20 @@ export class ConversationStore {
|
|
|
2058
1760
|
// and stop after the requested turn window is complete. Hidden/internal and
|
|
2059
1761
|
// non-turn rows are not allowed to force an unbounded scan; a hard parse cap
|
|
2060
1762
|
// conservatively marks the page truncated.
|
|
2061
|
-
for (const
|
|
1763
|
+
for (const entry of this.#sessionFileEntries('messages', sessionId, { beforeSeq, desc: true })) {
|
|
2062
1764
|
if (parsed >= scanCap) {
|
|
2063
1765
|
truncated = true;
|
|
2064
1766
|
break;
|
|
2065
1767
|
}
|
|
2066
1768
|
parsed += 1;
|
|
2067
1769
|
|
|
1770
|
+
let m;
|
|
1771
|
+
try {
|
|
1772
|
+
m = this.readMessageFile(entry.path);
|
|
1773
|
+
} catch (err) {
|
|
1774
|
+
if (isPermissionError(err)) continue;
|
|
1775
|
+
throw err;
|
|
1776
|
+
}
|
|
2068
1777
|
if (!m || m.sessionId !== sessionId) continue;
|
|
2069
1778
|
|
|
2070
1779
|
const boundaryComplete = turnsFromEnd >= turnsLimit;
|
|
@@ -2115,38 +1824,8 @@ export class ConversationStore {
|
|
|
2115
1824
|
};
|
|
2116
1825
|
}
|
|
2117
1826
|
|
|
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
|
-
|
|
2147
1827
|
#sessionFileEntries(kind, sessionId, { beforeSeq = Infinity, afterSeq = -Infinity, desc = false } = {}) {
|
|
2148
1828
|
const entries = [];
|
|
2149
|
-
const kindOrder = kind === 'all' ? { cold: 0, messages: 1 } : null;
|
|
2150
1829
|
for (const dir of this.#sessionMessageDirs(kind, sessionId)) {
|
|
2151
1830
|
let files;
|
|
2152
1831
|
try {
|
|
@@ -2161,14 +1840,10 @@ export class ConversationStore {
|
|
|
2161
1840
|
if (!Number.isFinite(seq)) continue;
|
|
2162
1841
|
if (Number.isFinite(beforeSeq) && seq >= beforeSeq) continue;
|
|
2163
1842
|
if (Number.isFinite(afterSeq) && seq <= afterSeq) continue;
|
|
2164
|
-
|
|
2165
|
-
entries.push({ path: join(dir, file), seq, kindRank });
|
|
1843
|
+
entries.push({ path: join(dir, file), seq });
|
|
2166
1844
|
}
|
|
2167
1845
|
}
|
|
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
|
-
});
|
|
1846
|
+
entries.sort((a, b) => desc ? b.seq - a.seq : a.seq - b.seq);
|
|
2172
1847
|
return entries;
|
|
2173
1848
|
}
|
|
2174
1849
|
|
|
@@ -2178,25 +1853,11 @@ export class ConversationStore {
|
|
|
2178
1853
|
...this.#loadFromDir(this.#legacyMsgDir, Infinity),
|
|
2179
1854
|
...this.#loadFromDir(this.#chatColdDir, Infinity),
|
|
2180
1855
|
...this.#loadFromDir(this.#chatMsgDir, Infinity),
|
|
2181
|
-
...this.#readSegmentRows(this.#chatDir, { includeCold: true }),
|
|
2182
|
-
...this.#sessionConversationDirs({ primaryOnly: true }).flatMap(dir => this.#readSegmentRows(dir, { includeCold: true })),
|
|
2183
1856
|
...this.#sessionMessageDirs('cold').flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
2184
1857
|
...this.#sessionMessageDirs('messages').flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
2185
1858
|
].sort(compareMessagesBySeq);
|
|
2186
1859
|
}
|
|
2187
1860
|
|
|
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
|
-
|
|
2200
1861
|
#countFilesInDirs(dirs) {
|
|
2201
1862
|
let total = 0;
|
|
2202
1863
|
for (const dir of dirs) {
|
|
@@ -2261,7 +1922,7 @@ export class ConversationStore {
|
|
|
2261
1922
|
const messages = [];
|
|
2262
1923
|
for (const file of selected) {
|
|
2263
1924
|
const raw = readFileSync(join(dir, file), 'utf8');
|
|
2264
|
-
const parsed =
|
|
1925
|
+
const parsed = parseMessage(raw);
|
|
2265
1926
|
if (parsed) messages.push(parsed);
|
|
2266
1927
|
}
|
|
2267
1928
|
|
|
@@ -2276,13 +1937,6 @@ export class ConversationStore {
|
|
|
2276
1937
|
if (this.#nextSeq != null) return this.#nextSeq;
|
|
2277
1938
|
|
|
2278
1939
|
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
|
-
}
|
|
2286
1940
|
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
2287
1941
|
if (!existsSync(dir)) continue;
|
|
2288
1942
|
for (const file of readdirSync(dir)) {
|
|
@@ -8,24 +8,14 @@ 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
|
-
|
|
21
11
|
/**
|
|
22
|
-
* Search
|
|
12
|
+
* Search messages in a directory for a keyword.
|
|
23
13
|
*
|
|
24
14
|
* @param {string} dir — messages directory
|
|
25
15
|
* @param {string} keyword — search term (case-insensitive)
|
|
26
16
|
* @returns {object[]} — matching messages
|
|
27
17
|
*/
|
|
28
|
-
function
|
|
18
|
+
function searchDir(dir, keyword) {
|
|
29
19
|
if (!existsSync(dir)) return [];
|
|
30
20
|
|
|
31
21
|
const lowerKeyword = keyword.toLowerCase();
|
|
@@ -45,29 +35,6 @@ function searchMarkdownDir(dir, keyword) {
|
|
|
45
35
|
return results;
|
|
46
36
|
}
|
|
47
37
|
|
|
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
|
-
|
|
71
38
|
function compareNewest(a, b) {
|
|
72
39
|
const sa = parseSeqFromId(a?.id);
|
|
73
40
|
const sb = parseSeqFromId(b?.id);
|
|
@@ -75,7 +42,7 @@ function compareNewest(a, b) {
|
|
|
75
42
|
return String(b?.time || '').localeCompare(String(a?.time || ''));
|
|
76
43
|
}
|
|
77
44
|
|
|
78
|
-
function
|
|
45
|
+
function sessionConversationMessageDirs(dir) {
|
|
79
46
|
const dirs = [];
|
|
80
47
|
const seen = new Set();
|
|
81
48
|
for (const rootName of ['sessions', 'groups']) {
|
|
@@ -90,9 +57,12 @@ function sessionConversationDirs(dir) {
|
|
|
90
57
|
}
|
|
91
58
|
|
|
92
59
|
const conversationDir = join(sessionDir, 'conversation');
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
60
|
+
for (const kind of ['messages', 'cold']) {
|
|
61
|
+
const messagesDir = join(conversationDir, kind);
|
|
62
|
+
if (seen.has(messagesDir)) continue;
|
|
63
|
+
seen.add(messagesDir);
|
|
64
|
+
dirs.push(messagesDir);
|
|
65
|
+
}
|
|
96
66
|
}
|
|
97
67
|
}
|
|
98
68
|
return dirs;
|
|
@@ -109,23 +79,17 @@ function sessionConversationDirs(dir) {
|
|
|
109
79
|
export function searchMessages(dir, keyword, limit = 20) {
|
|
110
80
|
if (!keyword || !keyword.trim()) return [];
|
|
111
81
|
|
|
112
|
-
const
|
|
113
|
-
join(dir, 'chat'),
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const markdownDirs = [
|
|
118
|
-
...conversationDirs.flatMap(d => [join(d, 'messages'), join(d, 'cold')]),
|
|
82
|
+
const dirs = [
|
|
83
|
+
join(dir, 'chat', 'messages'),
|
|
84
|
+
join(dir, 'chat', 'cold'),
|
|
85
|
+
...sessionConversationMessageDirs(dir),
|
|
119
86
|
// Compatibility for profiles created before chat/session split.
|
|
120
87
|
join(dir, 'conversation', 'messages'),
|
|
121
88
|
join(dir, 'conversation', 'cold'),
|
|
122
89
|
];
|
|
123
|
-
const segmentDirs = conversationDirs.map(d => join(d, 'segments'));
|
|
124
90
|
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
...markdownDirs.flatMap(d => searchMarkdownDir(d, keyword)),
|
|
128
|
-
]
|
|
91
|
+
return dirs
|
|
92
|
+
.flatMap(d => searchDir(d, keyword))
|
|
129
93
|
.sort(compareNewest)
|
|
130
94
|
.slice(0, limit);
|
|
131
95
|
}
|
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
|
|
48
|
+
import { ensureDefaultSessionIfEmpty } 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,7 +71,6 @@ 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
|
|
75
74
|
* @property {string} [model] — Model override
|
|
76
75
|
* @property {string} [language] — Language override ('en' | 'zh')
|
|
77
76
|
* @property {boolean} [debug] — Debug mode override
|
|
@@ -137,7 +136,6 @@ function prepareToolStatsDir(yeaftDir) {
|
|
|
137
136
|
export async function loadSession(options = {}) {
|
|
138
137
|
const {
|
|
139
138
|
dir,
|
|
140
|
-
workDir,
|
|
141
139
|
model,
|
|
142
140
|
language,
|
|
143
141
|
debug,
|
|
@@ -148,29 +146,21 @@ export async function loadSession(options = {}) {
|
|
|
148
146
|
serverMode = false,
|
|
149
147
|
} = options;
|
|
150
148
|
|
|
151
|
-
// ─── 1. Determine
|
|
152
|
-
// Must happen BEFORE loadConfig so that first-run
|
|
153
|
-
// default config.json that loadConfig can read.
|
|
154
|
-
// Sessions store conversation/session data under <workDir>/.yeaft,
|
|
155
|
-
// but runtime config still comes from the agent-local configDir.
|
|
149
|
+
// ─── 1. Determine yeaftDir + ensure directory structure ──
|
|
150
|
+
// Must happen BEFORE loadConfig so that first-run
|
|
151
|
+
// generates a default config.json that loadConfig can read.
|
|
156
152
|
const overrides = { ...configOverrides };
|
|
157
153
|
if (dir) overrides.dir = dir;
|
|
158
154
|
if (model) overrides.model = model;
|
|
159
155
|
if (language) overrides.language = language;
|
|
160
156
|
if (debug !== undefined) overrides.debug = debug;
|
|
161
157
|
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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) {
|
|
158
|
+
const yeaftDir = overrides.dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
|
|
159
|
+
const initResult = initYeaftDir(yeaftDir);
|
|
160
|
+
overrides.dir = yeaftDir;
|
|
161
|
+
|
|
162
|
+
// Log any warnings from directory initialization
|
|
163
|
+
for (const w of initResult.warnings) {
|
|
174
164
|
console.warn(`[Yeaft] ${w}`);
|
|
175
165
|
}
|
|
176
166
|
|
|
@@ -222,7 +212,7 @@ export async function loadSession(options = {}) {
|
|
|
222
212
|
// ─── 2a. Permission pre-check ─────────────────────────
|
|
223
213
|
// If the data dir is not writable, mark session as read-only.
|
|
224
214
|
// Persistence (conversation, memory, dream) is skipped in this mode.
|
|
225
|
-
if (!
|
|
215
|
+
if (!initResult.writable) {
|
|
226
216
|
config._readOnly = true;
|
|
227
217
|
console.warn(`[Yeaft] ${yeaftDir} is not writable — running in read-only mode`);
|
|
228
218
|
}
|
|
@@ -375,23 +365,24 @@ export async function loadSession(options = {}) {
|
|
|
375
365
|
}
|
|
376
366
|
|
|
377
367
|
// ─── 6. Load skills ────────────────────────────────────
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
|
|
368
|
+
// Project tier root for skills + MCP project assets. Per-session workDir
|
|
369
|
+
// overlays are loaded by web-bridge once it knows the selected Session meta;
|
|
370
|
+
// the base runtime still uses the agent process cwd for global/default status.
|
|
371
|
+
const projectTierRoot = process.cwd();
|
|
381
372
|
|
|
382
373
|
let skillManager;
|
|
383
374
|
if (skipSkills) {
|
|
384
375
|
// Pass the literal user-tier dir (matches the normal branch's tier 2)
|
|
385
376
|
// so any save/remove calls land in the same place users expect. New
|
|
386
377
|
// `SkillManager` API takes literal scan dirs — no auto-suffix of /skills.
|
|
387
|
-
skillManager = new SkillManager(join(
|
|
378
|
+
skillManager = new SkillManager(join(yeaftDir, 'skills'));
|
|
388
379
|
// Don't call .load() — empty skill manager
|
|
389
380
|
} else {
|
|
390
|
-
skillManager = createSkillManager(
|
|
381
|
+
skillManager = createSkillManager(yeaftDir, projectTierRoot);
|
|
391
382
|
}
|
|
392
383
|
|
|
393
384
|
// ─── 7. Connect MCP servers ────────────────────────────
|
|
394
|
-
const mcpConfig = loadMCPConfig(
|
|
385
|
+
const mcpConfig = loadMCPConfig(yeaftDir, undefined, projectTierRoot);
|
|
395
386
|
const mcpManager = new MCPManager();
|
|
396
387
|
let mcpStatus = { connected: [], failed: [] };
|
|
397
388
|
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -53,7 +53,6 @@ import {
|
|
|
53
53
|
scanWorkdirSessions,
|
|
54
54
|
restoreSessionToRegistry,
|
|
55
55
|
readWorkDirRegistry,
|
|
56
|
-
yeaftDirForWorkDir,
|
|
57
56
|
} from './sessions/session-crud.js';
|
|
58
57
|
import { openSession, loadSessionMeta } from './sessions/session-store.js';
|
|
59
58
|
import { loadSessionConfig, resolveSessionConfig, SessionConfigError } from './sessions/session-config.js';
|
|
@@ -415,13 +414,6 @@ function projectRuntimeKey(workDir) {
|
|
|
415
414
|
return normalizeSessionWorkDir(workDir) || '__agent_cwd__';
|
|
416
415
|
}
|
|
417
416
|
|
|
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
|
-
|
|
425
417
|
function createThreadId() {
|
|
426
418
|
return `thr_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
|
|
427
419
|
}
|
|
@@ -3888,11 +3880,6 @@ async function ensureSessionLoaded(opts = {}) {
|
|
|
3888
3880
|
sessionLoadPromise = (async () => {
|
|
3889
3881
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
3890
3882
|
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
|
-
});
|
|
3896
3883
|
session = await loadSession({
|
|
3897
3884
|
...(yeaftDir && { dir: yeaftDir }),
|
|
3898
3885
|
...(normalizedWorkDir && { workDir: normalizedWorkDir }),
|
|
@@ -5653,22 +5640,12 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
5653
5640
|
const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
|
|
5654
5641
|
ensureYeaftConversationId();
|
|
5655
5642
|
|
|
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
|
-
|
|
5666
5643
|
// First paint must not wait for full Yeaft runtime boot (MCP connects,
|
|
5667
|
-
// skill scans, memory index sync). The conversation
|
|
5644
|
+
// skill scans, memory index sync). The conversation markdown store is the
|
|
5668
5645
|
// source of truth and can be opened cheaply, so replay the visible message
|
|
5669
5646
|
// window immediately, then finish loadSession below for actual turns.
|
|
5670
5647
|
const coldStoreStart = perfNowMs();
|
|
5671
|
-
const coldStore = new ConversationStore(
|
|
5648
|
+
const coldStore = new ConversationStore(yeaftDir);
|
|
5672
5649
|
traceDuration('history.cold_store_open', coldStoreStart);
|
|
5673
5650
|
if (sessionId && (afterSeqRaw !== null || afterMessageId)) {
|
|
5674
5651
|
let afterSeq = afterSeqRaw;
|
|
@@ -5698,6 +5675,14 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
5698
5675
|
}
|
|
5699
5676
|
historyAlreadyReplayed = true;
|
|
5700
5677
|
|
|
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
|
+
}
|
|
5701
5686
|
// Full runtime boot can be expensive (memory FTS sync, skills, MCP, dream
|
|
5702
5687
|
// boot checks). It is not needed to render persisted history, so keep this
|
|
5703
5688
|
// request short and let message-send await the same single-flight boot when
|