@yeaft/webchat-agent 1.0.78 → 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.
@@ -1,23 +1,29 @@
1
1
  /**
2
2
  * persist.js — Conversation message persistence
3
3
  *
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.
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
- * Segment format: one JSON object per line, keyed by global monotonic `seq` /
15
- * `id` (`m0001`, `m0002`, ...).
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, appendFileSync } from 'fs';
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.json
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'), join(this.#chatDir, SEGMENT_DIR), this.#chatMsgDir, this.#chatColdDir,
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
- this.#segmentStoreFor(fullMsg, { create: true }).append(fullMsg);
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
- 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
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 = this.#readSessionRows(sessionId, { beforeSeq: cutoff })
1298
- .filter(m => m && m.sessionId === sessionId && !isHiddenConversationRow(m));
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 m of this.#iterateSessionRows(sessionId, { afterSeq: cutoff, desc: false })) {
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
- 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);
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.#countSegmentMessages([this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })], 'cold')
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.#loadHotMessages();
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 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)]) {
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 (!scanMsg(msg, path)) continue;
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 rows from segmented storage and legacy Markdown dirs.
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
- sourceRows.sort(compareMessagesBySeq);
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 copyMsg = (msg) => {
1738
- if (!msg || msg.threadId !== sourceId) return;
1739
- const seq = parseSeqFromId(msg.id);
1740
- if (!Number.isFinite(seq) || seq > cutoffSeq) return;
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, seq: _seq, ...rest } = msg;
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
- const msg = parseMessage(readFileSync(path, 'utf8'));
1764
- copyMsg(msg);
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 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` }));
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, SEGMENT_DIR), join(dir, 'messages'), join(dir, 'cold'), join(dir, 'compact')]) {
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
- #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 } = {}) {
1659
+ #sessionConversationDirs() {
1951
1660
  const dirs = [];
1952
1661
  const seen = new Set();
1953
- for (const root of (primaryOnly ? [this.#sessionsDir] : [this.#sessionsDir, this.#legacySessionsDir])) {
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
- for (const k of kinds) {
1977
- dirs.push(
1978
- join(this.#sessionConversationDir(sessionId), k),
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
- .flatMap(dir => kinds.map(k => join(dir, k)))
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
- 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);
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 m of this.#iterateSessionRows(sessionId, { beforeSeq, desc: true })) {
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
- const kindRank = kindOrder ? kindOrder[basename(dir)] ?? 0 : 0;
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 = parseSegmentOrMarkdown(raw);
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)) {