@yeaft/webchat-agent 1.0.75 → 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.
@@ -1,33 +1,27 @@
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';
30
- import { countTurns, indexOfNthTurnFromEnd, sliceLastNTurns, stripVpMentionPrefix } from '../turn-utils.js';
24
+ import { sliceLastNTurns, stripVpMentionPrefix } from '../turn-utils.js';
31
25
  import { isHiddenConversationRow } from './internal-control.js';
32
26
 
33
27
  /**
@@ -57,6 +51,65 @@ import { isHiddenConversationRow } from './internal-control.js';
57
51
  */
58
52
  let DEFAULT_RECENT_TURNS = 20;
59
53
 
54
+ // Circuit breaker for newest-to-oldest session scans. This bounds event-loop
55
+ // starvation when the newest transcript tail is dense with hidden/internal or
56
+ // otherwise non-turn rows and no user boundary is found quickly.
57
+ const RECENT_SESSION_SCAN_BASE_CAP = 64;
58
+ const RECENT_SESSION_SCAN_PER_TURN_CAP = 4;
59
+ const RECENT_SESSION_SCAN_MAX_CAP = 256;
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
+
60
113
  /** Read the current default cold-start replay window (turn count). */
61
114
  export function getDefaultRecentTurnsLimit() {
62
115
  return DEFAULT_RECENT_TURNS;
@@ -114,20 +167,18 @@ export function __resetTruncationWarned() {
114
167
  *
115
168
  * @param {string} sessionId
116
169
  * @param {string} storeDir
117
- * @param {number} totalTurns turns available on disk
118
- * @param {number} returnedTurns — turns the load returned
170
+ * @param {number} recentTurnsLimit configured recent-turn window
119
171
  * @param {boolean} hasCompactSummary
120
172
  */
121
- function maybeWarnHistoryTruncated(sessionId, storeDir, totalTurns, returnedTurns, hasCompactSummary) {
173
+ function maybeWarnHistoryTruncated(sessionId, storeDir, recentTurnsLimit, hasCompactSummary) {
122
174
  if (!sessionId || !storeDir) return;
123
- if (returnedTurns >= totalTurns) return;
124
175
  if (hasCompactSummary) return;
125
176
  const key = `${storeDir}::${sessionId}`;
126
177
  if (_truncationWarned.has(key)) return;
127
178
  _truncationWarned.add(key);
128
179
  // eslint-disable-next-line no-console
129
180
  console.warn(
130
- `[Yeaft] history for session ${sessionId} truncated to ${returnedTurns} of ${totalTurns} turns (recentTurnsLimit=${DEFAULT_RECENT_TURNS}); ` +
181
+ `[Yeaft] history for session ${sessionId} truncated to ${recentTurnsLimit} recent turns (recentTurnsLimit=${DEFAULT_RECENT_TURNS}); ` +
131
182
  `no compact summary exists, so older context is dropped. ` +
132
183
  `Raise yeaft.recentTurnsLimit in ~/.yeaft/config.json if this is a problem.`
133
184
  );
@@ -179,6 +230,11 @@ function canonicalUserTurnContent(content) {
179
230
  return text ? stripVpMentionPrefix(text) : null;
180
231
  }
181
232
 
233
+ function recentSessionScanCap(turnsLimit) {
234
+ const turns = Number.isFinite(turnsLimit) && turnsLimit > 0 ? Math.ceil(turnsLimit) : DEFAULT_RECENT_TURNS;
235
+ return Math.min(RECENT_SESSION_SCAN_MAX_CAP, RECENT_SESSION_SCAN_BASE_CAP + turns * RECENT_SESSION_SCAN_PER_TURN_CAP);
236
+ }
237
+
182
238
  // ─── Frontmatter helpers ─────────────────────────────────────
183
239
 
184
240
  /**
@@ -431,6 +487,209 @@ export function parseMessage(raw) {
431
487
  return msg;
432
488
  }
433
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
+
434
693
  // ─── ConversationStore ───────────────────────────────────────
435
694
 
436
695
  /**
@@ -438,15 +697,14 @@ export function parseMessage(raw) {
438
697
  *
439
698
  * Directory layout:
440
699
  * chat/ — one-to-one chat mode history
441
- * index.md
700
+ * index.json
701
+ * segments/
442
702
  * compact.md
443
- * messages/
444
- * cold/
445
703
  * blobs/
446
704
  * sessions/<sessionId>/conversation/
705
+ * index.json
706
+ * segments/
447
707
  * compact/
448
- * messages/
449
- * cold/
450
708
  * blobs/
451
709
  *
452
710
  * Legacy compatibility: ~/.yeaft/conversation is read as an old mixed store,
@@ -507,7 +765,7 @@ export class ConversationStore {
507
765
  // errors). Per-session conversation directories are created lazily once a
508
766
  // sessionId is known. Legacy directories are never created by new versions.
509
767
  for (const d of [
510
- 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,
511
769
  this.#sessionsDir,
512
770
  ]) {
513
771
  try {
@@ -543,9 +801,8 @@ export class ConversationStore {
543
801
  tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
544
802
  };
545
803
 
546
- const filePath = join(this.#messageDirFor(fullMsg), `${id}.md`);
547
804
  try {
548
- writeFileSync(filePath, serializeMessage(fullMsg), { encoding: 'utf8', mode: 0o644 });
805
+ this.#segmentStoreFor(fullMsg, { create: true }).append(fullMsg);
549
806
  } catch (err) {
550
807
  if (isPermissionError(err)) {
551
808
  if (!_permissionWarned) {
@@ -578,6 +835,10 @@ export class ConversationStore {
578
835
  * @param {string} id — message id (e.g. "m0355")
579
836
  */
580
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
+ }
581
842
  for (const [hotDir, coldDir] of this.#hotColdDirPairs({ includeLegacy: false })) {
582
843
  const src = join(hotDir, `${id}.md`);
583
844
  const dst = join(coldDir, `${id}.md`);
@@ -817,7 +1078,10 @@ export class ConversationStore {
817
1078
  }
818
1079
  }
819
1080
  }
820
- // 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
821
1085
  // ~/.yeaft/conversation is intentionally left untouched.
822
1086
  for (const path of [this.#compactPath]) {
823
1087
  if (!existsSync(path)) continue;
@@ -889,13 +1153,10 @@ export class ConversationStore {
889
1153
  * boundary cuts are pair-safe by construction, but if a hand-edited
890
1154
  * store somehow contains pre-existing orphans we drop them anyway.
891
1155
  *
892
- * Implementation note: filters AFTER reading the most recent files
893
- * because the on-disk order is global by sequence id. We over-read by
894
- * loading every hot file and slicing the tail of the FILTERED set so
895
- * `turnsLimit` reflects "N most recent turns in this group", not "N
896
- * most recent messages on disk that happen to be in this group". For
897
- * typical inboxes (≤ a few thousand hot messages) this is cheap; if
898
- * it ever becomes a hot path we add a per-group on-disk index.
1156
+ * Implementation note: this walks session files newest-to-oldest and stops
1157
+ * once it has the requested turn window plus proof of an older turn. Do not
1158
+ * replace this with `#loadSessionMessages()`; that full synchronous parse is
1159
+ * exactly what can starve websocket heartbeat on large sessions.
899
1160
  *
900
1161
  * @param {string} sessionId — required; null/empty returns []
901
1162
  * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
@@ -903,24 +1164,22 @@ export class ConversationStore {
903
1164
  */
904
1165
  loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS) {
905
1166
  if (!sessionId) return [];
906
- const all = this.#loadSessionMessages(sessionId);
907
- const filtered = all.filter(m => m && m.sessionId === sessionId && !isHiddenConversationRow(m));
908
- if (turnsLimit === Infinity || turnsLimit < 0) return pairSanitize(filtered);
909
- const sliced = sliceLastNTurns(filtered, turnsLimit);
910
- // Warn once per (sessionId, storeDir) when truncation drops turns
911
- // that no compact summary covers — the user is silently losing
912
- // older context otherwise. Cheap check: countTurns is O(N) over
913
- // already-loaded messages; we only run it when the slice actually
914
- // returned fewer rows than the full filtered set.
915
- if (sliced.length < filtered.length) {
916
- const totalTurns = countTurns(filtered);
917
- const returnedTurns = countTurns(sliced);
918
- if (returnedTurns < totalTurns) {
919
- const hasCompact = this.hasAnyCompactSummaryForSession(sessionId);
920
- maybeWarnHistoryTruncated(sessionId, this.#dir, totalTurns, returnedTurns, hasCompact);
921
- }
1167
+ if (turnsLimit === Infinity || turnsLimit < 0) {
1168
+ const all = this.#loadSessionMessages(sessionId);
1169
+ const filtered = all.filter(m => m && m.sessionId === sessionId && !isHiddenConversationRow(m));
1170
+ return pairSanitize(filtered);
1171
+ }
1172
+ if (!(turnsLimit > 0)) return [];
1173
+
1174
+ const { messages, truncated } = this.#loadRecentSessionWindow(sessionId, turnsLimit, {
1175
+ roles: null,
1176
+ stripAssistantToolCalls: false,
1177
+ });
1178
+ if (truncated) {
1179
+ const hasCompact = this.hasAnyCompactSummaryForSession(sessionId);
1180
+ maybeWarnHistoryTruncated(sessionId, this.#dir, turnsLimit, hasCompact);
922
1181
  }
923
- return pairSanitize(sliced);
1182
+ return pairSanitize(messages);
924
1183
  }
925
1184
 
926
1185
  /**
@@ -1034,11 +1293,9 @@ export class ConversationStore {
1034
1293
  */
1035
1294
  loadOlderBySession(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
1036
1295
  if (!sessionId) return { messages: [], oldestSeq: null, hasMore: false };
1037
- const all = this.#loadSessionMessages(sessionId);
1038
1296
  const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
1039
- const prefix = all.filter(m => m && m.sessionId === sessionId
1040
- && !isHiddenConversationRow(m)
1041
- && parseSeqFromId(m.id) < cutoff);
1297
+ const prefix = this.#readSessionRows(sessionId, { beforeSeq: cutoff })
1298
+ .filter(m => m && m.sessionId === sessionId && !isHiddenConversationRow(m));
1042
1299
  if (prefix.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
1043
1300
  const sliced = pairSanitize(sliceLastNTurns(prefix, turnsLimit));
1044
1301
  // Turn-based hasMore: there's an EARLIER turn boundary we didn't keep.
@@ -1073,40 +1330,18 @@ export class ConversationStore {
1073
1330
  if (!sessionId || !(turnsLimit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
1074
1331
 
1075
1332
  const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
1076
- const all = this.#loadSessionMessages(sessionId);
1077
- const visible = all.filter(m => m && m.sessionId === sessionId
1078
- && !isHiddenConversationRow(m)
1079
- && (m.role === 'user' || m.role === 'assistant')
1080
- && parseSeqFromId(m.id) < cutoff);
1081
-
1082
- if (visible.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
1083
-
1084
- const sliced = sliceLastNTurns(visible, turnsLimit);
1085
-
1086
- // Turn-based hasMore: an EARLIER turn boundary was dropped.
1087
- const oldestSlicedSeq = sliced.length ? parseSeqFromId(sliced[0].id) : NaN;
1088
- const oldestVisibleSeq = parseSeqFromId(visible[0].id);
1089
- const hasMore = sliced.length > 0
1090
- && Number.isFinite(oldestSlicedSeq)
1091
- && Number.isFinite(oldestVisibleSeq)
1092
- && oldestSlicedSeq > oldestVisibleSeq;
1093
-
1094
- // Visible history is for UI replay, not LLM context. Strip tool-call
1095
- // metadata — don't run pairSanitize (it can incorrectly drop assistant
1096
- // messages that reference tool calls stripped from the UI).
1097
- const messages = sliced.map(m => {
1098
- if (m && m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
1099
- const { toolCalls, ...rest } = m;
1100
- return { ...rest, toolSummaryCount: toolCalls.length };
1101
- }
1102
- return m;
1333
+ const page = this.#loadRecentSessionWindow(sessionId, turnsLimit, {
1334
+ beforeSeq: cutoff,
1335
+ roles: new Set(['user', 'assistant']),
1336
+ stripAssistantToolCalls: true,
1103
1337
  });
1104
- const oldestSeq = messages.length ? parseSeqFromId(messages[0].id) : null;
1338
+ if (page.messages.length === 0) return { messages: [], oldestSeq: null, hasMore: page.truncated };
1105
1339
 
1340
+ const oldestSeq = page.messages.length ? parseSeqFromId(page.messages[0].id) : null;
1106
1341
  return {
1107
- messages,
1342
+ messages: page.messages,
1108
1343
  oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
1109
- hasMore,
1344
+ hasMore: page.truncated,
1110
1345
  };
1111
1346
  }
1112
1347
 
@@ -1132,16 +1367,16 @@ export class ConversationStore {
1132
1367
  const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? opts.limit : 500;
1133
1368
  const cutoff = Number.isFinite(afterSeq) && afterSeq >= 0 ? afterSeq : null;
1134
1369
  if (cutoff === null) return { messages: [], latestSeq: null };
1135
- const rawAfter = this.#loadSessionMessages(sessionId).filter((m) => {
1136
- if (!m || m.sessionId !== sessionId) return false;
1370
+ const after = [];
1371
+ let newestScannedSeq = cutoff;
1372
+ for (const m of this.#iterateSessionRows(sessionId, { afterSeq: cutoff, desc: false })) {
1373
+ if (!m || m.sessionId !== sessionId) continue;
1137
1374
  const seq = parseSeqFromId(m.id);
1138
- return Number.isFinite(seq) && seq > cutoff;
1139
- });
1140
- const newestScannedSeq = rawAfter.slice(0, limit).reduce((max, m) => {
1141
- const seq = parseSeqFromId(m?.id);
1142
- return Number.isFinite(seq) && seq > max ? seq : max;
1143
- }, cutoff);
1144
- const after = rawAfter.filter(m => !isHiddenConversationRow(m));
1375
+ if (Number.isFinite(seq) && seq > newestScannedSeq) newestScannedSeq = seq;
1376
+ if (isHiddenConversationRow(m)) continue;
1377
+ after.push(m);
1378
+ if (after.length >= limit) break;
1379
+ }
1145
1380
  const sliced = pairSanitize(after.slice(0, limit));
1146
1381
  const lastSeq = sliced.length ? parseSeqFromId(sliced[sliced.length - 1].id) : null;
1147
1382
  const latestSeq = Number.isFinite(lastSeq) ? Math.max(lastSeq, newestScannedSeq) : newestScannedSeq;
@@ -1166,7 +1401,13 @@ export class ConversationStore {
1166
1401
  * @returns {number}
1167
1402
  */
1168
1403
  countHot() {
1169
- 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);
1170
1411
  }
1171
1412
 
1172
1413
  /**
@@ -1175,7 +1416,8 @@ export class ConversationStore {
1175
1416
  * @returns {number}
1176
1417
  */
1177
1418
  countCold() {
1178
- 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]);
1179
1421
  }
1180
1422
 
1181
1423
  /**
@@ -1184,7 +1426,7 @@ export class ConversationStore {
1184
1426
  * @returns {number}
1185
1427
  */
1186
1428
  hotTokens() {
1187
- const messages = this.loadAll();
1429
+ const messages = this.#loadHotMessages();
1188
1430
  return messages.reduce((sum, m) => sum + (m.tokens_est || estimateTokens(m.content || '')), 0);
1189
1431
  }
1190
1432
 
@@ -1231,7 +1473,13 @@ export class ConversationStore {
1231
1473
  deleteByGroup(sessionId) {
1232
1474
  if (!sessionId) return 0;
1233
1475
  let removed = 0;
1234
- 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)]) {
1235
1483
  if (!existsSync(dir)) continue;
1236
1484
  let files;
1237
1485
  try {
@@ -1260,7 +1508,6 @@ export class ConversationStore {
1260
1508
  }
1261
1509
  }
1262
1510
  }
1263
- // Invalidate cached next-seq — countHot/loadAll will re-scan.
1264
1511
  this.#nextSeq = null;
1265
1512
  return removed;
1266
1513
  }
@@ -1289,6 +1536,33 @@ export class ConversationStore {
1289
1536
  let scanned = 0;
1290
1537
  let removed = 0;
1291
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
+
1292
1566
  for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
1293
1567
  if (!existsSync(dir)) continue;
1294
1568
  let files;
@@ -1308,11 +1582,7 @@ export class ConversationStore {
1308
1582
  throw err;
1309
1583
  }
1310
1584
  const msg = parseMessage(raw);
1311
- if (!msg) continue;
1312
- scanned += 1;
1313
- const isOrphan = !msg.sessionId || !keep.has(msg.sessionId);
1314
- if (!isOrphan) continue;
1315
- orphans.push(path);
1585
+ if (!scanMsg(msg, path)) continue;
1316
1586
  if (dryRun) continue;
1317
1587
  try {
1318
1588
  unlinkSync(path);
@@ -1343,6 +1613,25 @@ export class ConversationStore {
1343
1613
  reassignThread(sourceId, targetId) {
1344
1614
  if (!sourceId || !targetId || sourceId === targetId) return 0;
1345
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
+
1346
1635
  for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
1347
1636
  if (!existsSync(dir)) continue;
1348
1637
  let files;
@@ -1363,7 +1652,6 @@ export class ConversationStore {
1363
1652
  }
1364
1653
  const msg = parseMessage(raw);
1365
1654
  if (!msg || msg.threadId !== sourceId) continue;
1366
- // Preserve original thread id for UI pill; only stamp once.
1367
1655
  if (!msg.sourceThreadId) msg.sourceThreadId = sourceId;
1368
1656
  msg.threadId = targetId;
1369
1657
  try {
@@ -1420,19 +1708,9 @@ export class ConversationStore {
1420
1708
  throw err;
1421
1709
  }
1422
1710
 
1423
- // 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);
1424
1713
  const candidates = [];
1425
- for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#sessionMessageDirs('cold'), ...this.#sessionMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
1426
- if (!existsSync(dir)) continue;
1427
- let files;
1428
- try {
1429
- files = readdirSync(dir).filter(f => f.endsWith('.md'));
1430
- } catch (err) {
1431
- if (isPermissionError(err)) continue;
1432
- throw err;
1433
- }
1434
- for (const f of files) candidates.push(join(dir, f));
1435
- }
1436
1714
  // Also pick up any already-forked sub-thread dir (chain fork).
1437
1715
  const sourceSubDir = this.#threadMsgDir(sourceId);
1438
1716
  if (existsSync(sourceSubDir)) {
@@ -1444,11 +1722,7 @@ export class ConversationStore {
1444
1722
  if (!isPermissionError(err)) throw err;
1445
1723
  }
1446
1724
  }
1447
- // Sort by the "m{NNNN}" basename. For chain-fork, sub-thread ids also
1448
- // restart at m0001 so sorting by basename alone is ambiguous across
1449
- // dirs; but a given source thread stores messages in exactly ONE place
1450
- // (either legacy flat dir OR its sub-dir — see below), so ties never
1451
- // arise. Sorting by (path, seq) is still well-defined.
1725
+ sourceRows.sort(compareMessagesBySeq);
1452
1726
  candidates.sort((a, b) => {
1453
1727
  const ma = a.match(/m(\d+)\.md$/);
1454
1728
  const mb = b.match(/m(\d+)\.md$/);
@@ -1460,25 +1734,13 @@ export class ConversationStore {
1460
1734
  const cutoffSeq = parseInt(cutoffMatch[1], 10);
1461
1735
 
1462
1736
  let copied = 0;
1463
- for (const path of candidates) {
1464
- const fileMatch = path.match(/m(\d+)\.md$/);
1465
- if (!fileMatch) continue;
1466
- const seq = parseInt(fileMatch[1], 10);
1467
- if (seq > cutoffSeq) continue; // do not break — sub-thread dir mixed in may interleave
1468
- let raw;
1469
- try {
1470
- raw = readFileSync(path, 'utf8');
1471
- } catch (err) {
1472
- if (isPermissionError(err)) continue;
1473
- throw err;
1474
- }
1475
- const msg = parseMessage(raw);
1476
- if (!msg || msg.threadId !== sourceId) continue;
1477
- // Mint a fresh per-thread id restarting at m0001 under the target
1478
- // 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;
1479
1741
  const nextSeq = this.#getNextThreadSeq(targetId);
1480
1742
  const newId = `m${String(nextSeq).padStart(4, '0')}`;
1481
- const { id: _id, ...rest } = msg;
1743
+ const { id: _id, seq: _seq, ...rest } = msg;
1482
1744
  const copy = {
1483
1745
  ...rest,
1484
1746
  id: newId,
@@ -1488,10 +1750,18 @@ export class ConversationStore {
1488
1750
  tokens_est: rest.tokens_est || estimateTokens(rest.content || ''),
1489
1751
  };
1490
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) {
1491
1762
  try {
1492
- writeFileSync(filePath, serializeMessage(copy), { encoding: 'utf8', mode: 0o644 });
1493
- this.#nextSeqByThread.set(targetId, nextSeq + 1);
1494
- copied += 1;
1763
+ const msg = parseMessage(readFileSync(path, 'utf8'));
1764
+ copyMsg(msg);
1495
1765
  } catch (err) {
1496
1766
  if (isPermissionError(err)) continue;
1497
1767
  throw err;
@@ -1527,8 +1797,10 @@ export class ConversationStore {
1527
1797
  }
1528
1798
  return out;
1529
1799
  }
1530
- // Legacy: messages live in the flat dir stamped with threadId.
1531
- 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` }));
1532
1804
  for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#sessionMessageDirs('cold'), ...this.#sessionMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
1533
1805
  if (!existsSync(dir)) continue;
1534
1806
  for (const f of readdirSync(dir).filter(x => x.endsWith('.md'))) {
@@ -1549,12 +1821,6 @@ export class ConversationStore {
1549
1821
  return collected.map(x => x.msg);
1550
1822
  }
1551
1823
 
1552
- #messageDirFor(msg) {
1553
- if (msg?.chatId) return join(this.#chatConversationDir(msg.chatId, { create: true }), 'messages');
1554
- if (!msg?.sessionId) return this.#chatMsgDir;
1555
- return join(this.#sessionConversationDir(msg.sessionId, { create: true }), 'messages');
1556
- }
1557
-
1558
1824
  #chatConversationDir(chatId, { create = false } = {}) {
1559
1825
  const dir = join(this.#dir, 'chats', this.#safeDirComponent(chatId), 'conversation');
1560
1826
  if (create) this.#ensureConversationDirs(dir);
@@ -1620,6 +1886,7 @@ export class ConversationStore {
1620
1886
  loadRecentByChat(chatId, turnsLimit = DEFAULT_RECENT_TURNS) {
1621
1887
  if (!chatId) return [];
1622
1888
  const all = [
1889
+ ...this.#readSegmentRows(this.#chatConversationDir(chatId)),
1623
1890
  ...this.#chatMessageDirs('messages', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1624
1891
  ...this.#chatMessageDirs('cold', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1625
1892
  ].sort(compareMessagesBySeq);
@@ -1632,6 +1899,7 @@ export class ConversationStore {
1632
1899
  loadChatHistoryForVp(chatId, vpId) {
1633
1900
  if (!chatId || !vpId) return [];
1634
1901
  const all = [
1902
+ ...this.#readSegmentRows(this.#chatConversationDir(chatId)),
1635
1903
  ...this.#chatMessageDirs('messages', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1636
1904
  ...this.#chatMessageDirs('cold', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1637
1905
  ].sort(compareMessagesBySeq);
@@ -1661,15 +1929,28 @@ export class ConversationStore {
1661
1929
  }
1662
1930
 
1663
1931
  #ensureConversationDirs(dir) {
1664
- 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')]) {
1665
1933
  if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
1666
1934
  }
1667
1935
  }
1668
1936
 
1669
- #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 } = {}) {
1670
1951
  const dirs = [];
1671
1952
  const seen = new Set();
1672
- for (const root of [this.#sessionsDir, this.#legacySessionsDir]) {
1953
+ for (const root of (primaryOnly ? [this.#sessionsDir] : [this.#sessionsDir, this.#legacySessionsDir])) {
1673
1954
  if (!existsSync(root)) continue;
1674
1955
  for (const name of readdirSync(root)) {
1675
1956
  const sessionDir = join(root, name);
@@ -1689,15 +1970,19 @@ export class ConversationStore {
1689
1970
  }
1690
1971
 
1691
1972
  #sessionMessageDirs(kind, sessionId = null) {
1973
+ const kinds = kind === 'all' ? ['cold', 'messages'] : [kind];
1692
1974
  if (sessionId) {
1693
- const dirs = [
1694
- join(this.#sessionConversationDir(sessionId), kind),
1695
- join(this.#legacySessionConversationDir(sessionId), kind),
1696
- ];
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
+ }
1697
1982
  return dirs.filter(dir => existsSync(dir));
1698
1983
  }
1699
1984
  return this.#sessionConversationDirs()
1700
- .map(dir => join(dir, kind))
1985
+ .flatMap(dir => kinds.map(k => join(dir, k)))
1701
1986
  .filter(dir => existsSync(dir));
1702
1987
  }
1703
1988
 
@@ -1715,32 +2000,203 @@ export class ConversationStore {
1715
2000
  // mode compatibility, only import legacy records that are not stamped with
1716
2001
  // a sessionId, so group mode cannot bleed into chat.
1717
2002
  return [
2003
+ ...this.#readSegmentRows(this.#chatDir).filter(m => !m?.sessionId),
1718
2004
  ...this.#loadFromDir(this.#legacyMsgDir, Infinity).filter(m => !m?.sessionId),
1719
2005
  ...this.#loadFromDir(this.#chatMsgDir, Infinity),
1720
2006
  ].sort(compareMessagesBySeq);
1721
2007
  }
1722
2008
 
1723
2009
  #loadSessionHotMessages(sessionId = null) {
1724
- return this.#sessionMessageDirs('messages', sessionId)
1725
- .flatMap(dir => this.#loadFromDir(dir, Infinity))
1726
- .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);
1727
2015
  }
1728
2016
 
1729
2017
  #loadSessionMessages(sessionId = null) {
1730
2018
  return this.#loadSessionHotMessages(sessionId);
1731
2019
  }
1732
2020
 
2021
+ readMessageFile(path) {
2022
+ return parseMessage(readFileSync(path, 'utf8'));
2023
+ }
2024
+
2025
+ #loadRecentSessionWindow(sessionId, turnsLimit, { beforeSeq = Infinity, roles = null, stripAssistantToolCalls = false } = {}) {
2026
+ const kept = [];
2027
+ const pendingBoundaryRows = [];
2028
+ let turnsFromEnd = 0;
2029
+ let openCanonical = null;
2030
+ let boundaryCanonical = null;
2031
+ let truncated = false;
2032
+ let parsed = 0;
2033
+ const scanCap = recentSessionScanCap(turnsLimit);
2034
+
2035
+ const project = (m) => {
2036
+ if (roles && !roles.has(m.role)) return null;
2037
+ if (stripAssistantToolCalls && m.role === 'assistant') {
2038
+ const { toolCalls, ...rest } = m;
2039
+ if (!rest.content && !rest.attachments && (!Array.isArray(toolCalls) || toolCalls.length === 0)) return null;
2040
+ if (Array.isArray(toolCalls) && toolCalls.length > 0) return { ...rest, toolSummaryCount: toolCalls.length };
2041
+ return rest;
2042
+ }
2043
+ return m;
2044
+ };
2045
+ const keep = (m) => {
2046
+ const projected = project(m);
2047
+ if (projected) kept.push(projected);
2048
+ };
2049
+ const queueBoundaryRow = (m) => {
2050
+ const projected = project(m);
2051
+ if (projected) pendingBoundaryRows.push(projected);
2052
+ };
2053
+
2054
+ // Do not materialize the whole session transcript just to paint or hydrate
2055
+ // the recent context window. Large Yeaft sessions can have thousands of
2056
+ // markdown rows; parsing all of them is synchronous and can starve websocket
2057
+ // heartbeat long enough for the agent to look dead. Walk newest-to-oldest
2058
+ // and stop after the requested turn window is complete. Hidden/internal and
2059
+ // non-turn rows are not allowed to force an unbounded scan; a hard parse cap
2060
+ // conservatively marks the page truncated.
2061
+ for (const m of this.#iterateSessionRows(sessionId, { beforeSeq, desc: true })) {
2062
+ if (parsed >= scanCap) {
2063
+ truncated = true;
2064
+ break;
2065
+ }
2066
+ parsed += 1;
2067
+
2068
+ if (!m || m.sessionId !== sessionId) continue;
2069
+
2070
+ const boundaryComplete = turnsFromEnd >= turnsLimit;
2071
+ if (isHiddenConversationRow(m)) {
2072
+ if (boundaryComplete) {
2073
+ truncated = true;
2074
+ break;
2075
+ }
2076
+ continue;
2077
+ }
2078
+
2079
+ if (boundaryComplete) {
2080
+ if (m.role === 'user') {
2081
+ const canonical = canonicalUserTurnContent(m.content);
2082
+ if (canonical != null && canonical === boundaryCanonical) {
2083
+ kept.push(...pendingBoundaryRows.splice(0));
2084
+ keep(m);
2085
+ continue;
2086
+ }
2087
+ } else {
2088
+ queueBoundaryRow(m);
2089
+ continue;
2090
+ }
2091
+ truncated = true;
2092
+ break;
2093
+ }
2094
+
2095
+ if (m.role === 'user') {
2096
+ const canonical = canonicalUserTurnContent(m.content);
2097
+ if (canonical != null && canonical !== openCanonical) {
2098
+ turnsFromEnd += 1;
2099
+ openCanonical = canonical;
2100
+ if (turnsFromEnd === turnsLimit) boundaryCanonical = canonical;
2101
+ if (turnsFromEnd > turnsLimit) {
2102
+ truncated = true;
2103
+ break;
2104
+ }
2105
+ }
2106
+ }
2107
+
2108
+ keep(m);
2109
+ }
2110
+
2111
+ kept.reverse();
2112
+ return {
2113
+ messages: turnsFromEnd > 0 ? sliceLastNTurns(kept, turnsLimit) : kept,
2114
+ truncated,
2115
+ };
2116
+ }
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
+
2147
+ #sessionFileEntries(kind, sessionId, { beforeSeq = Infinity, afterSeq = -Infinity, desc = false } = {}) {
2148
+ const entries = [];
2149
+ const kindOrder = kind === 'all' ? { cold: 0, messages: 1 } : null;
2150
+ for (const dir of this.#sessionMessageDirs(kind, sessionId)) {
2151
+ let files;
2152
+ try {
2153
+ files = readdirSync(dir).filter(f => f.endsWith('.md'));
2154
+ } catch (err) {
2155
+ if (isPermissionError(err)) continue;
2156
+ throw err;
2157
+ }
2158
+ for (const file of files) {
2159
+ const seqMatch = file.match(/^m(\d+)\.md$/);
2160
+ const seq = seqMatch ? parseInt(seqMatch[1], 10) : NaN;
2161
+ if (!Number.isFinite(seq)) continue;
2162
+ if (Number.isFinite(beforeSeq) && seq >= beforeSeq) continue;
2163
+ 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 });
2166
+ }
2167
+ }
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
+ });
2172
+ return entries;
2173
+ }
2174
+
1733
2175
  #loadAllMessages() {
1734
2176
  return [
1735
2177
  ...this.#loadFromDir(this.#legacyColdDir, Infinity),
1736
2178
  ...this.#loadFromDir(this.#legacyMsgDir, Infinity),
1737
2179
  ...this.#loadFromDir(this.#chatColdDir, Infinity),
1738
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 })),
1739
2183
  ...this.#sessionMessageDirs('cold').flatMap(dir => this.#loadFromDir(dir, Infinity)),
1740
2184
  ...this.#sessionMessageDirs('messages').flatMap(dir => this.#loadFromDir(dir, Infinity)),
1741
2185
  ].sort(compareMessagesBySeq);
1742
2186
  }
1743
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
+
1744
2200
  #countFilesInDirs(dirs) {
1745
2201
  let total = 0;
1746
2202
  for (const dir of dirs) {
@@ -1805,7 +2261,7 @@ export class ConversationStore {
1805
2261
  const messages = [];
1806
2262
  for (const file of selected) {
1807
2263
  const raw = readFileSync(join(dir, file), 'utf8');
1808
- const parsed = parseMessage(raw);
2264
+ const parsed = parseSegmentOrMarkdown(raw);
1809
2265
  if (parsed) messages.push(parsed);
1810
2266
  }
1811
2267
 
@@ -1820,6 +2276,13 @@ export class ConversationStore {
1820
2276
  if (this.#nextSeq != null) return this.#nextSeq;
1821
2277
 
1822
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
+ }
1823
2286
  for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
1824
2287
  if (!existsSync(dir)) continue;
1825
2288
  for (const file of readdirSync(dir)) {