@yeaft/webchat-agent 1.0.76 → 1.0.78

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.
@@ -1,29 +1,23 @@
1
1
  /**
2
2
  * persist.js — Conversation message persistence
3
3
  *
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.
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
- * 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...
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.md
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
- writeFileSync(filePath, serializeMessage(fullMsg), { encoding: 'utf8', mode: 0o644 });
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
- // Reset compact files in the new chat/group stores. Legacy
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 = all.filter(m => m && m.sessionId === sessionId
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 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
- }
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
- return this.#countFilesInDirs([this.#chatMsgDir, ...this.#sessionMessageDirs('messages'), this.#legacyMsgDir]);
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.#countFilesInDirs([this.#chatColdDir, ...this.#sessionMessageDirs('cold'), this.#legacyColdDir]);
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.loadAll();
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
- for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
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 files from both hot + cold dirs.
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
- // 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.
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
- 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.
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
- writeFileSync(filePath, serializeMessage(copy), { encoding: 'utf8', mode: 0o644 });
1483
- this.#nextSeqByThread.set(targetId, nextSeq + 1);
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 dir stamped with threadId.
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
- #sessionConversationDirs() {
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
- join(this.#sessionConversationDir(sessionId), kind),
1685
- join(this.#legacySessionConversationDir(sessionId), kind),
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
- .map(dir => join(dir, kind))
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.#sessionMessageDirs('messages', sessionId)
1715
- .flatMap(dir => this.#loadFromDir(dir, Infinity))
1716
- .sort(compareMessagesBySeq);
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 entry of this.#sessionFileEntries('messages', sessionId, { beforeSeq, desc: true })) {
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
- entries.push({ path: join(dir, file), seq });
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) => desc ? b.seq - a.seq : a.seq - b.seq);
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 = parseMessage(raw);
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)) {