@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.
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +626 -163
- package/yeaft/conversation/search.js +51 -15
- package/yeaft/session.js +27 -18
- package/yeaft/web-bridge.js +25 -12
|
@@ -1,33 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* persist.js — Conversation message persistence
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* ~/.yeaft/chat/
|
|
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
|
-
*
|
|
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 {
|
|
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}
|
|
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,
|
|
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 ${
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
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
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
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
|
|
1136
|
-
|
|
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
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
1493
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
1695
|
-
|
|
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
|
-
.
|
|
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.#
|
|
1725
|
-
|
|
1726
|
-
.
|
|
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 =
|
|
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)) {
|