@yeaft/webchat-agent 1.0.76 → 1.0.77

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