@yeaft/webchat-agent 0.1.903 → 0.1.905
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/connection/message-router.js +10 -9
- package/package.json +1 -1
- package/yeaft/compact/compactor.js +2 -2
- package/yeaft/config-api.js +16 -6
- package/yeaft/config.js +15 -2
- package/yeaft/conversation/persist.js +173 -60
- package/yeaft/engine.js +3 -3
- package/yeaft/llm/router.js +1 -1
- package/yeaft/memory/ams-registry.js +18 -19
- package/yeaft/pair-sanitize.js +1 -1
- package/yeaft/session.js +10 -1
- package/yeaft/snapshot-filter.js +88 -0
- package/yeaft/web-bridge.js +169 -140
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
* Each message is stored as a .md file with YAML frontmatter in
|
|
5
5
|
* ~/.yeaft/chat/messages/ or ~/.yeaft/groups/<sessionId>/conversation/messages/. Design: zero JSON, all Markdown.
|
|
6
6
|
*
|
|
7
|
+
* Vocabulary note: the on-disk layout literally uses `groups/<id>/` (and
|
|
8
|
+
* the AMS registry uses `memory/sessions/<id>/`) — the asymmetry is
|
|
9
|
+
* deliberate. The `groups/` path predates the rename and we keep it as a
|
|
10
|
+
* literal string to avoid a destructive data migration; every API
|
|
11
|
+
* surface above the disk layer (method names, params, comments) uses
|
|
12
|
+
* "session" vocabulary. See `ConversationStore.#sessionsDir` for the
|
|
13
|
+
* boundary annotation.
|
|
14
|
+
*
|
|
7
15
|
* Message format:
|
|
8
16
|
* ---
|
|
9
17
|
* id: m0355
|
|
@@ -22,7 +30,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rename
|
|
|
22
30
|
import { join, basename } from 'path';
|
|
23
31
|
import { isPermissionError } from '../init.js';
|
|
24
32
|
import { pairSanitize } from '../pair-sanitize.js';
|
|
25
|
-
import { indexOfNthTurnFromEnd, sliceLastNTurns } from '../turn-utils.js';
|
|
33
|
+
import { countTurns, indexOfNthTurnFromEnd, sliceLastNTurns } from '../turn-utils.js';
|
|
26
34
|
|
|
27
35
|
/**
|
|
28
36
|
* Default cold-start "recent window" size, expressed in TURNS (not raw
|
|
@@ -38,8 +46,94 @@ import { indexOfNthTurnFromEnd, sliceLastNTurns } from '../turn-utils.js';
|
|
|
38
46
|
* The session-level compactor in `history-compact.js` is the authoritative
|
|
39
47
|
* size limiter once the engine is running; this is just the cold-start
|
|
40
48
|
* replay window after a fresh boot or reconnect.
|
|
49
|
+
*
|
|
50
|
+
* Configurable via `~/.yeaft/config.json` → `yeaft.recentTurnsLimit`.
|
|
51
|
+
* Session boot calls `setDefaultRecentTurnsLimit()` once with the
|
|
52
|
+
* resolved config value; tests can call it directly.
|
|
53
|
+
*
|
|
54
|
+
* NB: this is intentionally `let` (not `const`) and read via
|
|
55
|
+
* `getDefaultRecentTurnsLimit()` from outside this module — ES module
|
|
56
|
+
* named exports ARE live bindings, but callers that snapshot the value
|
|
57
|
+
* at module load (`const cap = DEFAULT_RECENT_TURNS`) would not see
|
|
58
|
+
* runtime overrides. The reader function makes that always-correct.
|
|
41
59
|
*/
|
|
42
|
-
|
|
60
|
+
let DEFAULT_RECENT_TURNS = 20;
|
|
61
|
+
|
|
62
|
+
/** Read the current default cold-start replay window (turn count). */
|
|
63
|
+
export function getDefaultRecentTurnsLimit() {
|
|
64
|
+
return DEFAULT_RECENT_TURNS;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Back-compat re-export for callers that grab a snapshot at module load.
|
|
68
|
+
// New code should call `getDefaultRecentTurnsLimit()` so it sees runtime
|
|
69
|
+
// overrides applied by `setDefaultRecentTurnsLimit()`.
|
|
70
|
+
export { DEFAULT_RECENT_TURNS };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Override the default cold-start replay window. Called once per
|
|
74
|
+
* session boot (`session.js`) from the loaded config. Silently ignores
|
|
75
|
+
* unparseable input but emits a `console.warn` so a hand-edited config
|
|
76
|
+
* (`recentTurnsLimit: "twenty"`) doesn't fail open without a signal.
|
|
77
|
+
*
|
|
78
|
+
* @param {number|string} n
|
|
79
|
+
*/
|
|
80
|
+
export function setDefaultRecentTurnsLimit(n) {
|
|
81
|
+
const v = Number(n);
|
|
82
|
+
if (!Number.isFinite(v) || v < 1) {
|
|
83
|
+
// eslint-disable-next-line no-console
|
|
84
|
+
console.warn(
|
|
85
|
+
`[Yeaft] setDefaultRecentTurnsLimit(${JSON.stringify(n)}) ignored — ` +
|
|
86
|
+
`expected a positive number; keeping DEFAULT_RECENT_TURNS=${DEFAULT_RECENT_TURNS}.`
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
DEFAULT_RECENT_TURNS = Math.floor(v);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Per-session warn-once tracker. Keyed by `${storeDir}::${sessionId}`
|
|
95
|
+
* so a single process running multiple sessions only warns once per
|
|
96
|
+
* session per boot.
|
|
97
|
+
*
|
|
98
|
+
* The Set is module-level (singleton) because the warning is about
|
|
99
|
+
* end-user UX (don't spam the console), not about test isolation. Tests
|
|
100
|
+
* that exercise the warn-once gate call `__resetTruncationWarned()` in
|
|
101
|
+
* their setup to start from a clean slate; without this seam two
|
|
102
|
+
* sequential tests in the same module would observe the gate from the
|
|
103
|
+
* first test silently suppress the second test's warn.
|
|
104
|
+
*/
|
|
105
|
+
const _truncationWarned = new Set();
|
|
106
|
+
|
|
107
|
+
/** Test-only: clear the warn-once gate (do not call in production). */
|
|
108
|
+
export function __resetTruncationWarned() {
|
|
109
|
+
_truncationWarned.clear();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Warn (once per session per process) when the cold-start replay window
|
|
114
|
+
* truncated history AND no compact summary exists to cover the dropped
|
|
115
|
+
* turns. The user is then losing context with no UX signal otherwise.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} sessionId
|
|
118
|
+
* @param {string} storeDir
|
|
119
|
+
* @param {number} totalTurns — turns available on disk
|
|
120
|
+
* @param {number} returnedTurns — turns the load returned
|
|
121
|
+
* @param {boolean} hasCompactSummary
|
|
122
|
+
*/
|
|
123
|
+
function maybeWarnHistoryTruncated(sessionId, storeDir, totalTurns, returnedTurns, hasCompactSummary) {
|
|
124
|
+
if (!sessionId || !storeDir) return;
|
|
125
|
+
if (returnedTurns >= totalTurns) return;
|
|
126
|
+
if (hasCompactSummary) return;
|
|
127
|
+
const key = `${storeDir}::${sessionId}`;
|
|
128
|
+
if (_truncationWarned.has(key)) return;
|
|
129
|
+
_truncationWarned.add(key);
|
|
130
|
+
// eslint-disable-next-line no-console
|
|
131
|
+
console.warn(
|
|
132
|
+
`[Yeaft] history for session ${sessionId} truncated to ${returnedTurns} of ${totalTurns} turns (recentTurnsLimit=${DEFAULT_RECENT_TURNS}); ` +
|
|
133
|
+
`no compact summary exists, so older context is dropped. ` +
|
|
134
|
+
`Raise yeaft.recentTurnsLimit in ~/.yeaft/config.json if this is a problem.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
43
137
|
|
|
44
138
|
// ─── Token estimation ────────────────────────────────────────
|
|
45
139
|
|
|
@@ -58,7 +152,7 @@ export function estimateTokens(text) {
|
|
|
58
152
|
/**
|
|
59
153
|
* Parse the global monotonic sequence number out of a message id of the
|
|
60
154
|
* form `m####`. Returns NaN for malformed ids. Used by the pagination
|
|
61
|
-
* cursor (`
|
|
155
|
+
* cursor (`loadOlderBySession`) to compare ids numerically without having
|
|
62
156
|
* to trust file-system sort order.
|
|
63
157
|
*
|
|
64
158
|
* @param {string} id
|
|
@@ -343,7 +437,12 @@ export function parseMessage(raw) {
|
|
|
343
437
|
export class ConversationStore {
|
|
344
438
|
#dir; // root dir (e.g. ~/.yeaft)
|
|
345
439
|
#chatDir; // ~/.yeaft/chat
|
|
346
|
-
|
|
440
|
+
// ~/.yeaft/groups — on-disk path literal kept for backward compat with
|
|
441
|
+
// existing user data; the live disk layout is still `groups/<id>/` even
|
|
442
|
+
// after the session-v1 meta migration (which only collapses the meta
|
|
443
|
+
// files, not the conversation tree). All API surfaces above the disk
|
|
444
|
+
// layer have been renamed to the "session" vocabulary.
|
|
445
|
+
#sessionsDir;
|
|
347
446
|
#legacyConvDir; // ~/.yeaft/conversation (read-only compatibility)
|
|
348
447
|
#convDir; // default thread dir root: ~/.yeaft/chat
|
|
349
448
|
#msgDir; // default hot messages dir: ~/.yeaft/chat/messages
|
|
@@ -356,7 +455,7 @@ export class ConversationStore {
|
|
|
356
455
|
#chatColdDir;
|
|
357
456
|
#legacyMsgDir;
|
|
358
457
|
#legacyColdDir;
|
|
359
|
-
#nextSeq; // next message sequence number across chat/
|
|
458
|
+
#nextSeq; // next message sequence number across chat/session/legacy
|
|
360
459
|
#nextSeqByThread; // Map<threadId, number> — per-thread counters (task-314)
|
|
361
460
|
|
|
362
461
|
/**
|
|
@@ -365,7 +464,7 @@ export class ConversationStore {
|
|
|
365
464
|
constructor(dir) {
|
|
366
465
|
this.#dir = dir;
|
|
367
466
|
this.#chatDir = join(dir, 'chat');
|
|
368
|
-
this.#
|
|
467
|
+
this.#sessionsDir = join(dir, 'groups');
|
|
369
468
|
this.#legacyConvDir = join(dir, 'conversation');
|
|
370
469
|
|
|
371
470
|
this.#convDir = this.#chatDir;
|
|
@@ -393,7 +492,7 @@ export class ConversationStore {
|
|
|
393
492
|
// new versions.
|
|
394
493
|
for (const d of [
|
|
395
494
|
this.#chatDir, join(this.#chatDir, 'blobs'), this.#chatMsgDir, this.#chatColdDir,
|
|
396
|
-
this.#
|
|
495
|
+
this.#sessionsDir,
|
|
397
496
|
]) {
|
|
398
497
|
try {
|
|
399
498
|
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
|
|
@@ -563,7 +662,7 @@ export class ConversationStore {
|
|
|
563
662
|
*/
|
|
564
663
|
#scopedCompactPath(sessionId, vpId) {
|
|
565
664
|
if (!sessionId || !vpId) return null;
|
|
566
|
-
const compactDir = join(this.#
|
|
665
|
+
const compactDir = join(this.#sessionConversationDir(sessionId, { create: true }), 'compact');
|
|
567
666
|
return join(compactDir, `${this.#safeIdComponent(vpId)}.md`);
|
|
568
667
|
}
|
|
569
668
|
|
|
@@ -632,9 +731,9 @@ export class ConversationStore {
|
|
|
632
731
|
* @param {string} sessionId
|
|
633
732
|
* @returns {boolean}
|
|
634
733
|
*/
|
|
635
|
-
|
|
734
|
+
hasAnyCompactSummaryForSession(sessionId) {
|
|
636
735
|
if (!sessionId) return false;
|
|
637
|
-
const compactDir = join(this.#
|
|
736
|
+
const compactDir = join(this.#sessionConversationDir(sessionId), 'compact');
|
|
638
737
|
for (const dir of [compactDir, this.#legacyCompactScopedDir]) {
|
|
639
738
|
if (!existsSync(dir)) continue;
|
|
640
739
|
try {
|
|
@@ -689,7 +788,7 @@ export class ConversationStore {
|
|
|
689
788
|
* Clear all messages (hot + cold + compact).
|
|
690
789
|
*/
|
|
691
790
|
clear() {
|
|
692
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#
|
|
791
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold')]) {
|
|
693
792
|
if (existsSync(dir)) {
|
|
694
793
|
for (const file of readdirSync(dir)) {
|
|
695
794
|
if (file.endsWith('.md')) {
|
|
@@ -786,12 +885,26 @@ export class ConversationStore {
|
|
|
786
885
|
* @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
|
|
787
886
|
* @returns {object[]}
|
|
788
887
|
*/
|
|
789
|
-
|
|
888
|
+
loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
790
889
|
if (!sessionId) return [];
|
|
791
|
-
const all = this.#
|
|
890
|
+
const all = this.#loadSessionMessages(sessionId);
|
|
792
891
|
const filtered = all.filter(m => m && m.sessionId === sessionId);
|
|
793
892
|
if (turnsLimit === Infinity || turnsLimit < 0) return pairSanitize(filtered);
|
|
794
|
-
|
|
893
|
+
const sliced = sliceLastNTurns(filtered, turnsLimit);
|
|
894
|
+
// Warn once per (sessionId, storeDir) when truncation drops turns
|
|
895
|
+
// that no compact summary covers — the user is silently losing
|
|
896
|
+
// older context otherwise. Cheap check: countTurns is O(N) over
|
|
897
|
+
// already-loaded messages; we only run it when the slice actually
|
|
898
|
+
// returned fewer rows than the full filtered set.
|
|
899
|
+
if (sliced.length < filtered.length) {
|
|
900
|
+
const totalTurns = countTurns(filtered);
|
|
901
|
+
const returnedTurns = countTurns(sliced);
|
|
902
|
+
if (returnedTurns < totalTurns) {
|
|
903
|
+
const hasCompact = this.hasAnyCompactSummaryForSession(sessionId);
|
|
904
|
+
maybeWarnHistoryTruncated(sessionId, this.#dir, totalTurns, returnedTurns, hasCompact);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return pairSanitize(sliced);
|
|
795
908
|
}
|
|
796
909
|
|
|
797
910
|
/**
|
|
@@ -800,8 +913,8 @@ export class ConversationStore {
|
|
|
800
913
|
* @param {string} sessionId
|
|
801
914
|
* @returns {object[]}
|
|
802
915
|
*/
|
|
803
|
-
|
|
804
|
-
return this.
|
|
916
|
+
loadAllBySession(sessionId) {
|
|
917
|
+
return this.loadRecentBySession(sessionId, Infinity);
|
|
805
918
|
}
|
|
806
919
|
|
|
807
920
|
/**
|
|
@@ -829,9 +942,9 @@ export class ConversationStore {
|
|
|
829
942
|
* @param {string} vpId
|
|
830
943
|
* @returns {object[]}
|
|
831
944
|
*/
|
|
832
|
-
|
|
945
|
+
loadSessionHistoryForVp(sessionId, vpId) {
|
|
833
946
|
if (!sessionId || !vpId) return [];
|
|
834
|
-
const all = this.#
|
|
947
|
+
const all = this.#loadSessionMessages(sessionId);
|
|
835
948
|
const out = [];
|
|
836
949
|
for (const m of all) {
|
|
837
950
|
if (!m || m.sessionId !== sessionId) continue;
|
|
@@ -896,15 +1009,15 @@ export class ConversationStore {
|
|
|
896
1009
|
* from the newest (no upper bound).
|
|
897
1010
|
* - `0` is a VALID finite cutoff that excludes everything (since seqs
|
|
898
1011
|
* start at 1). Distinct from `null`. A caller writing
|
|
899
|
-
* `
|
|
1012
|
+
* `loadOlderBySession(g, store.firstSeq || 0, ...)` will silently get
|
|
900
1013
|
* an empty page — pass `null` if you mean "from newest".
|
|
901
1014
|
* @param {number} [turnsLimit=DEFAULT_RECENT_TURNS] — max turns per page
|
|
902
1015
|
* @returns {{ messages: object[], oldestSeq: number|null, hasMore: boolean }}
|
|
903
1016
|
*/
|
|
904
|
-
|
|
1017
|
+
loadOlderBySession(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
905
1018
|
if (!sessionId) return { messages: [], oldestSeq: null, hasMore: false };
|
|
906
|
-
const hot = this.#
|
|
907
|
-
const cold = this.#
|
|
1019
|
+
const hot = this.#loadSessionHotMessages(sessionId);
|
|
1020
|
+
const cold = this.#loadSessionColdMessages(sessionId);
|
|
908
1021
|
// Cold ids strictly < hot ids by construction → chronological concat.
|
|
909
1022
|
const all = [...cold, ...hot];
|
|
910
1023
|
const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
|
|
@@ -930,7 +1043,7 @@ export class ConversationStore {
|
|
|
930
1043
|
}
|
|
931
1044
|
|
|
932
1045
|
/**
|
|
933
|
-
* Visible UI pagination read for one group. Unlike `
|
|
1046
|
+
* Visible UI pagination read for one group. Unlike `loadOlderBySession`, this
|
|
934
1047
|
* projects out internal/reflection/system rows BEFORE applying the turn
|
|
935
1048
|
* window, so a dense run of hidden metadata cannot force the first screen to
|
|
936
1049
|
* scan and materialize the group's entire history in the web bridge.
|
|
@@ -940,12 +1053,12 @@ export class ConversationStore {
|
|
|
940
1053
|
* @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
|
|
941
1054
|
* @returns {{ messages: object[], oldestSeq: number|null, hasMore: boolean }}
|
|
942
1055
|
*/
|
|
943
|
-
|
|
1056
|
+
loadVisibleBySession(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
944
1057
|
if (!sessionId || !(turnsLimit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
|
|
945
1058
|
|
|
946
1059
|
const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
|
|
947
|
-
const hot = this.#
|
|
948
|
-
const cold = this.#
|
|
1060
|
+
const hot = this.#loadVisibleFromDirsBySession([...this.#sessionMessageDirs('messages', sessionId), this.#legacyMsgDir], sessionId, cutoff);
|
|
1061
|
+
const cold = this.#loadVisibleFromDirsBySession([...this.#sessionMessageDirs('cold', sessionId), this.#legacyColdDir], sessionId, cutoff);
|
|
949
1062
|
const visible = [...cold, ...hot];
|
|
950
1063
|
if (visible.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
|
|
951
1064
|
|
|
@@ -981,8 +1094,8 @@ export class ConversationStore {
|
|
|
981
1094
|
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? opts.limit : 500;
|
|
982
1095
|
const cutoff = Number.isFinite(afterSeq) && afterSeq >= 0 ? afterSeq : null;
|
|
983
1096
|
if (cutoff === null) return { messages: [], latestSeq: null };
|
|
984
|
-
const hot = this.#
|
|
985
|
-
const cold = this.#
|
|
1097
|
+
const hot = this.#loadSessionHotMessages(sessionId);
|
|
1098
|
+
const cold = this.#loadSessionColdMessages(sessionId);
|
|
986
1099
|
const all = [...cold, ...hot].sort(compareMessagesBySeq);
|
|
987
1100
|
const after = all.filter((m) => {
|
|
988
1101
|
if (!m || m.sessionId !== sessionId) return false;
|
|
@@ -1012,7 +1125,7 @@ export class ConversationStore {
|
|
|
1012
1125
|
* @returns {number}
|
|
1013
1126
|
*/
|
|
1014
1127
|
countHot() {
|
|
1015
|
-
return this.#countFilesInDirs([this.#chatMsgDir, ...this.#
|
|
1128
|
+
return this.#countFilesInDirs([this.#chatMsgDir, ...this.#sessionMessageDirs('messages'), this.#legacyMsgDir]);
|
|
1016
1129
|
}
|
|
1017
1130
|
|
|
1018
1131
|
/**
|
|
@@ -1021,7 +1134,7 @@ export class ConversationStore {
|
|
|
1021
1134
|
* @returns {number}
|
|
1022
1135
|
*/
|
|
1023
1136
|
countCold() {
|
|
1024
|
-
return this.#countFilesInDirs([this.#chatColdDir, ...this.#
|
|
1137
|
+
return this.#countFilesInDirs([this.#chatColdDir, ...this.#sessionMessageDirs('cold'), this.#legacyColdDir]);
|
|
1025
1138
|
}
|
|
1026
1139
|
|
|
1027
1140
|
/**
|
|
@@ -1077,7 +1190,7 @@ export class ConversationStore {
|
|
|
1077
1190
|
deleteByGroup(sessionId) {
|
|
1078
1191
|
if (!sessionId) return 0;
|
|
1079
1192
|
let removed = 0;
|
|
1080
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#
|
|
1193
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1081
1194
|
if (!existsSync(dir)) continue;
|
|
1082
1195
|
let files;
|
|
1083
1196
|
try {
|
|
@@ -1135,7 +1248,7 @@ export class ConversationStore {
|
|
|
1135
1248
|
let scanned = 0;
|
|
1136
1249
|
let removed = 0;
|
|
1137
1250
|
const orphans = [];
|
|
1138
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#
|
|
1251
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1139
1252
|
if (!existsSync(dir)) continue;
|
|
1140
1253
|
let files;
|
|
1141
1254
|
try {
|
|
@@ -1189,7 +1302,7 @@ export class ConversationStore {
|
|
|
1189
1302
|
reassignThread(sourceId, targetId) {
|
|
1190
1303
|
if (!sourceId || !targetId || sourceId === targetId) return 0;
|
|
1191
1304
|
let rewritten = 0;
|
|
1192
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#
|
|
1305
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1193
1306
|
if (!existsSync(dir)) continue;
|
|
1194
1307
|
let files;
|
|
1195
1308
|
try {
|
|
@@ -1268,7 +1381,7 @@ export class ConversationStore {
|
|
|
1268
1381
|
|
|
1269
1382
|
// Collect source-thread candidate files from both hot + cold dirs.
|
|
1270
1383
|
const candidates = [];
|
|
1271
|
-
for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#
|
|
1384
|
+
for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#sessionMessageDirs('cold'), ...this.#sessionMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
|
|
1272
1385
|
if (!existsSync(dir)) continue;
|
|
1273
1386
|
let files;
|
|
1274
1387
|
try {
|
|
@@ -1375,7 +1488,7 @@ export class ConversationStore {
|
|
|
1375
1488
|
}
|
|
1376
1489
|
// Legacy: messages live in the flat dir stamped with threadId.
|
|
1377
1490
|
const collected = [];
|
|
1378
|
-
for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#
|
|
1491
|
+
for (const dir of [this.#chatColdDir, this.#chatMsgDir, ...this.#sessionMessageDirs('cold'), ...this.#sessionMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
|
|
1379
1492
|
if (!existsSync(dir)) continue;
|
|
1380
1493
|
for (const f of readdirSync(dir).filter(x => x.endsWith('.md'))) {
|
|
1381
1494
|
try {
|
|
@@ -1398,7 +1511,7 @@ export class ConversationStore {
|
|
|
1398
1511
|
#messageDirFor(msg) {
|
|
1399
1512
|
if (msg?.chatId) return join(this.#chatConversationDir(msg.chatId, { create: true }), 'messages');
|
|
1400
1513
|
if (!msg?.sessionId) return this.#chatMsgDir;
|
|
1401
|
-
return join(this.#
|
|
1514
|
+
return join(this.#sessionConversationDir(msg.sessionId, { create: true }), 'messages');
|
|
1402
1515
|
}
|
|
1403
1516
|
|
|
1404
1517
|
#chatConversationDir(chatId, { create = false } = {}) {
|
|
@@ -1462,7 +1575,7 @@ export class ConversationStore {
|
|
|
1462
1575
|
}
|
|
1463
1576
|
}
|
|
1464
1577
|
|
|
1465
|
-
/** Recent messages for a chat — chat mode mirror of
|
|
1578
|
+
/** Recent messages for a chat — chat mode mirror of loadRecentBySession. */
|
|
1466
1579
|
loadRecentByChat(chatId, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
1467
1580
|
if (!chatId) return [];
|
|
1468
1581
|
const all = [
|
|
@@ -1474,7 +1587,7 @@ export class ConversationStore {
|
|
|
1474
1587
|
return pairSanitize(sliceLastNTurns(filtered, turnsLimit));
|
|
1475
1588
|
}
|
|
1476
1589
|
|
|
1477
|
-
/** VP-scoped chat history — chat-mode mirror of
|
|
1590
|
+
/** VP-scoped chat history — chat-mode mirror of loadSessionHistoryForVp. */
|
|
1478
1591
|
loadChatHistoryForVp(chatId, vpId) {
|
|
1479
1592
|
if (!chatId || !vpId) return [];
|
|
1480
1593
|
const all = [
|
|
@@ -1496,8 +1609,8 @@ export class ConversationStore {
|
|
|
1496
1609
|
return pairSanitize(out);
|
|
1497
1610
|
}
|
|
1498
1611
|
|
|
1499
|
-
#
|
|
1500
|
-
const dir = join(this.#
|
|
1612
|
+
#sessionConversationDir(sessionId, { create = false } = {}) {
|
|
1613
|
+
const dir = join(this.#sessionsDir, this.#safeDirComponent(sessionId), 'conversation');
|
|
1501
1614
|
if (create) this.#ensureConversationDirs(dir);
|
|
1502
1615
|
return dir;
|
|
1503
1616
|
}
|
|
@@ -1508,11 +1621,11 @@ export class ConversationStore {
|
|
|
1508
1621
|
}
|
|
1509
1622
|
}
|
|
1510
1623
|
|
|
1511
|
-
#
|
|
1512
|
-
if (!existsSync(this.#
|
|
1624
|
+
#sessionConversationDirs() {
|
|
1625
|
+
if (!existsSync(this.#sessionsDir)) return [];
|
|
1513
1626
|
const dirs = [];
|
|
1514
|
-
for (const name of readdirSync(this.#
|
|
1515
|
-
const sessionDir = join(this.#
|
|
1627
|
+
for (const name of readdirSync(this.#sessionsDir)) {
|
|
1628
|
+
const sessionDir = join(this.#sessionsDir, name);
|
|
1516
1629
|
try {
|
|
1517
1630
|
if (!statSync(sessionDir).isDirectory()) continue;
|
|
1518
1631
|
} catch (err) {
|
|
@@ -1525,12 +1638,12 @@ export class ConversationStore {
|
|
|
1525
1638
|
return dirs;
|
|
1526
1639
|
}
|
|
1527
1640
|
|
|
1528
|
-
#
|
|
1641
|
+
#sessionMessageDirs(kind, sessionId = null) {
|
|
1529
1642
|
if (sessionId) {
|
|
1530
|
-
const dir = join(this.#
|
|
1643
|
+
const dir = join(this.#sessionConversationDir(sessionId), kind);
|
|
1531
1644
|
return existsSync(dir) ? [dir] : [];
|
|
1532
1645
|
}
|
|
1533
|
-
return this.#
|
|
1646
|
+
return this.#sessionConversationDirs()
|
|
1534
1647
|
.map(dir => join(dir, kind))
|
|
1535
1648
|
.filter(dir => existsSync(dir));
|
|
1536
1649
|
}
|
|
@@ -1538,7 +1651,7 @@ export class ConversationStore {
|
|
|
1538
1651
|
#hotColdDirPairs({ includeLegacy = true } = {}) {
|
|
1539
1652
|
const pairs = [
|
|
1540
1653
|
[this.#chatMsgDir, this.#chatColdDir],
|
|
1541
|
-
...this.#
|
|
1654
|
+
...this.#sessionConversationDirs().map(dir => [join(dir, 'messages'), join(dir, 'cold')]),
|
|
1542
1655
|
];
|
|
1543
1656
|
if (includeLegacy) pairs.push([this.#legacyMsgDir, this.#legacyColdDir]);
|
|
1544
1657
|
return pairs;
|
|
@@ -1554,22 +1667,22 @@ export class ConversationStore {
|
|
|
1554
1667
|
].sort(compareMessagesBySeq);
|
|
1555
1668
|
}
|
|
1556
1669
|
|
|
1557
|
-
#
|
|
1670
|
+
#loadSessionHotMessages(sessionId = null) {
|
|
1558
1671
|
return [
|
|
1559
1672
|
...this.#loadFromDir(this.#legacyMsgDir, Infinity).filter(m => m?.sessionId),
|
|
1560
|
-
...this.#
|
|
1673
|
+
...this.#sessionMessageDirs('messages', sessionId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1561
1674
|
].sort(compareMessagesBySeq);
|
|
1562
1675
|
}
|
|
1563
1676
|
|
|
1564
|
-
#
|
|
1677
|
+
#loadSessionColdMessages(sessionId = null) {
|
|
1565
1678
|
return [
|
|
1566
1679
|
...this.#loadFromDir(this.#legacyColdDir, Infinity).filter(m => m?.sessionId),
|
|
1567
|
-
...this.#
|
|
1680
|
+
...this.#sessionMessageDirs('cold', sessionId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1568
1681
|
].sort(compareMessagesBySeq);
|
|
1569
1682
|
}
|
|
1570
1683
|
|
|
1571
|
-
#
|
|
1572
|
-
return [...this.#
|
|
1684
|
+
#loadSessionMessages(sessionId = null) {
|
|
1685
|
+
return [...this.#loadSessionColdMessages(sessionId), ...this.#loadSessionHotMessages(sessionId)].sort(compareMessagesBySeq);
|
|
1573
1686
|
}
|
|
1574
1687
|
|
|
1575
1688
|
#loadAllMessages() {
|
|
@@ -1578,8 +1691,8 @@ export class ConversationStore {
|
|
|
1578
1691
|
...this.#loadFromDir(this.#legacyMsgDir, Infinity),
|
|
1579
1692
|
...this.#loadFromDir(this.#chatColdDir, Infinity),
|
|
1580
1693
|
...this.#loadFromDir(this.#chatMsgDir, Infinity),
|
|
1581
|
-
...this.#
|
|
1582
|
-
...this.#
|
|
1694
|
+
...this.#sessionMessageDirs('cold').flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1695
|
+
...this.#sessionMessageDirs('messages').flatMap(dir => this.#loadFromDir(dir, Infinity)),
|
|
1583
1696
|
].sort(compareMessagesBySeq);
|
|
1584
1697
|
}
|
|
1585
1698
|
|
|
@@ -1596,8 +1709,8 @@ export class ConversationStore {
|
|
|
1596
1709
|
return total;
|
|
1597
1710
|
}
|
|
1598
1711
|
|
|
1599
|
-
#
|
|
1600
|
-
return dirs.flatMap(dir => this.#
|
|
1712
|
+
#loadVisibleFromDirsBySession(dirs, sessionId, beforeSeq) {
|
|
1713
|
+
return dirs.flatMap(dir => this.#loadVisibleFromDirBySession(dir, sessionId, beforeSeq))
|
|
1601
1714
|
.sort(compareMessagesBySeq);
|
|
1602
1715
|
}
|
|
1603
1716
|
|
|
@@ -1659,7 +1772,7 @@ export class ConversationStore {
|
|
|
1659
1772
|
return messages;
|
|
1660
1773
|
}
|
|
1661
1774
|
|
|
1662
|
-
#
|
|
1775
|
+
#loadVisibleFromDirBySession(dir, sessionId, beforeSeq) {
|
|
1663
1776
|
if (!existsSync(dir)) return [];
|
|
1664
1777
|
|
|
1665
1778
|
const files = readdirSync(dir)
|
|
@@ -1693,7 +1806,7 @@ export class ConversationStore {
|
|
|
1693
1806
|
if (this.#nextSeq != null) return this.#nextSeq;
|
|
1694
1807
|
|
|
1695
1808
|
let maxSeq = 0;
|
|
1696
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#
|
|
1809
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
1697
1810
|
if (!existsSync(dir)) continue;
|
|
1698
1811
|
for (const file of readdirSync(dir)) {
|
|
1699
1812
|
const match = file.match(/^m(\d+)\.md$/);
|
package/yeaft/engine.js
CHANGED
|
@@ -1107,7 +1107,7 @@ export class Engine {
|
|
|
1107
1107
|
// Per-(group, vp) scoping: when this engine is bound to a fan-out VP
|
|
1108
1108
|
// (the common case in group mode), load only the rows THIS VP saw in
|
|
1109
1109
|
// its context — user prompts + every VP's assistant text, with other
|
|
1110
|
-
// VPs' tool calls/results stripped (see persist.
|
|
1110
|
+
// VPs' tool calls/results stripped (see persist.loadSessionHistoryForVp).
|
|
1111
1111
|
//
|
|
1112
1112
|
// Legacy / sub-agent callers (no sessionId/vpId pair) keep the global
|
|
1113
1113
|
// loadAll() behaviour so we don't break those flows.
|
|
@@ -1115,12 +1115,12 @@ export class Engine {
|
|
|
1115
1115
|
const scopedChat = !!(this.#chatId && this.#vpId
|
|
1116
1116
|
&& typeof conversationStore.loadChatHistoryForVp === 'function');
|
|
1117
1117
|
const scoped = !scopedChat && !!(this.#sessionId && this.#vpId
|
|
1118
|
-
&& typeof conversationStore.
|
|
1118
|
+
&& typeof conversationStore.loadSessionHistoryForVp === 'function');
|
|
1119
1119
|
try {
|
|
1120
1120
|
messages = scopedChat
|
|
1121
1121
|
? conversationStore.loadChatHistoryForVp(this.#chatId, this.#vpId)
|
|
1122
1122
|
: scoped
|
|
1123
|
-
? conversationStore.
|
|
1123
|
+
? conversationStore.loadSessionHistoryForVp(this.#sessionId, this.#vpId)
|
|
1124
1124
|
: conversationStore.loadAll();
|
|
1125
1125
|
} catch { return null; }
|
|
1126
1126
|
if (!Array.isArray(messages) || messages.length === 0) return null;
|
package/yeaft/llm/router.js
CHANGED
|
@@ -118,7 +118,7 @@ export function filterEffortForModel(params) {
|
|
|
118
118
|
* task-715: last-line-of-defense pair sanitize at the wire.
|
|
119
119
|
*
|
|
120
120
|
* `pairSanitize` already runs in two upstream paths
|
|
121
|
-
* (`conversation/persist.js#
|
|
121
|
+
* (`conversation/persist.js#loadRecentBySession` and
|
|
122
122
|
* `history-compact.js#compactHistory`), but the engine's main loop
|
|
123
123
|
* mutates `conversationMessages` AFTER those — appending tool results
|
|
124
124
|
* mid-loop, archiving bulky tool results into stubs, and (in failure
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory/ams-registry.js —
|
|
2
|
+
* memory/ams-registry.js — session-keyed AMS lifecycle.
|
|
3
3
|
*
|
|
4
|
-
* The Active Memory Set is conceptually session-scoped,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `adjust` already ran). Sessions come and go; the group's AMS persists.
|
|
4
|
+
* The Active Memory Set is conceptually session-scoped, with `sessionId`
|
|
5
|
+
* as the unit. A deactivated session can be reactivated later and should
|
|
6
|
+
* resume with the AMS state it had on disconnect (the onDemand segments
|
|
7
|
+
* it had pulled in, the recent LRU touches, whether `adjust` already ran).
|
|
9
8
|
*
|
|
10
9
|
* Persistence is identity-only:
|
|
11
10
|
*
|
|
12
|
-
* ~/.yeaft/memory/
|
|
11
|
+
* ~/.yeaft/memory/sessions/<sessionId>/ams.json
|
|
13
12
|
* {
|
|
14
13
|
* "version": 1,
|
|
15
14
|
* "ownVpId": "alice"|null,
|
|
@@ -21,11 +20,11 @@
|
|
|
21
20
|
*
|
|
22
21
|
* Bodies are NOT serialised — they're re-hydrated from the SegmentIndex
|
|
23
22
|
* on load, so a body edited by Dream after save still surfaces correctly
|
|
24
|
-
* the next time the
|
|
23
|
+
* the next time the session is opened. Resident layer is derived state
|
|
25
24
|
* (rebuilt every turn from `<scope>/summary.md`) — never persisted.
|
|
26
25
|
*
|
|
27
|
-
* For the single-VP Yeaft path (no
|
|
28
|
-
* key `"default"` so there's still a stable home for AMS state.
|
|
26
|
+
* For the single-VP Yeaft path (no session id supplied), the registry uses
|
|
27
|
+
* the literal key `"default"` so there's still a stable home for AMS state.
|
|
29
28
|
*/
|
|
30
29
|
|
|
31
30
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
@@ -35,7 +34,7 @@ import { ActiveMemorySet } from './ams.js';
|
|
|
35
34
|
import { computeBudget } from './budget.js';
|
|
36
35
|
|
|
37
36
|
export const AMS_FILE_VERSION = 1;
|
|
38
|
-
export const
|
|
37
|
+
export const DEFAULT_SESSION_KEY = 'default';
|
|
39
38
|
|
|
40
39
|
/**
|
|
41
40
|
* @typedef {object} AmsRegistryDeps
|
|
@@ -80,15 +79,15 @@ export class AmsRegistry {
|
|
|
80
79
|
*
|
|
81
80
|
* `sessionId` is trusted: `nextSessionId()` (sessions/ids.js) emits ids matching
|
|
82
81
|
* `grp_[a-z0-9_-]+_[0-9A-HJKMNP-TV-Z]{8}` (slug + 8-char crockford suffix),
|
|
83
|
-
* and the single-VP path uses the literal `
|
|
82
|
+
* and the single-VP path uses the literal `DEFAULT_SESSION_KEY`. No defensive
|
|
84
83
|
* escaping is needed.
|
|
85
84
|
*
|
|
86
85
|
* @param {string} sessionId
|
|
87
86
|
* @returns {string}
|
|
88
87
|
*/
|
|
89
88
|
amsPath(sessionId) {
|
|
90
|
-
const key = String(sessionId ||
|
|
91
|
-
return join(this.yeaftDir, 'memory', '
|
|
89
|
+
const key = String(sessionId || DEFAULT_SESSION_KEY);
|
|
90
|
+
return join(this.yeaftDir, 'memory', 'sessions', key, 'ams.json');
|
|
92
91
|
}
|
|
93
92
|
|
|
94
93
|
/**
|
|
@@ -113,7 +112,7 @@ export class AmsRegistry {
|
|
|
113
112
|
* @returns {ActiveMemorySet}
|
|
114
113
|
*/
|
|
115
114
|
getOrCreate(sessionId, opts = {}) {
|
|
116
|
-
const key = sessionId ||
|
|
115
|
+
const key = sessionId || DEFAULT_SESSION_KEY;
|
|
117
116
|
const cached = this._cache.get(key);
|
|
118
117
|
if (cached) return cached.ams;
|
|
119
118
|
|
|
@@ -136,7 +135,7 @@ export class AmsRegistry {
|
|
|
136
135
|
* @returns {boolean}
|
|
137
136
|
*/
|
|
138
137
|
adjustRanThisSession(sessionId) {
|
|
139
|
-
const key = sessionId ||
|
|
138
|
+
const key = sessionId || DEFAULT_SESSION_KEY;
|
|
140
139
|
return this._cache.get(key)?.adjustRanThisSession === true;
|
|
141
140
|
}
|
|
142
141
|
|
|
@@ -149,7 +148,7 @@ export class AmsRegistry {
|
|
|
149
148
|
* @param {boolean} value
|
|
150
149
|
*/
|
|
151
150
|
setAdjustRanThisSession(sessionId, value) {
|
|
152
|
-
const key = sessionId ||
|
|
151
|
+
const key = sessionId || DEFAULT_SESSION_KEY;
|
|
153
152
|
const entry = this._cache.get(key);
|
|
154
153
|
if (entry) entry.adjustRanThisSession = Boolean(value);
|
|
155
154
|
}
|
|
@@ -161,7 +160,7 @@ export class AmsRegistry {
|
|
|
161
160
|
* @param {string|null|undefined} sessionId
|
|
162
161
|
*/
|
|
163
162
|
markDirty(sessionId) {
|
|
164
|
-
this._dirty.add(sessionId ||
|
|
163
|
+
this._dirty.add(sessionId || DEFAULT_SESSION_KEY);
|
|
165
164
|
}
|
|
166
165
|
|
|
167
166
|
/**
|
|
@@ -177,7 +176,7 @@ export class AmsRegistry {
|
|
|
177
176
|
* @returns {boolean} true if the file was written
|
|
178
177
|
*/
|
|
179
178
|
persist(sessionId, opts = {}) {
|
|
180
|
-
const key = sessionId ||
|
|
179
|
+
const key = sessionId || DEFAULT_SESSION_KEY;
|
|
181
180
|
const entry = this._cache.get(key);
|
|
182
181
|
if (!entry) return false;
|
|
183
182
|
if (!opts.force && !this._dirty.has(key)) return false;
|
package/yeaft/pair-sanitize.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* slice so it can be safely fed to the LLM adapter.
|
|
4
4
|
*
|
|
5
5
|
* Why this exists:
|
|
6
|
-
* `agent/yeaft/conversation/persist.js#
|
|
6
|
+
* `agent/yeaft/conversation/persist.js#loadRecentBySession` and
|
|
7
7
|
* `agent/yeaft/history-compact.js#compactHistory` both produce
|
|
8
8
|
* sub-slices of a longer message stream. Both paths can — depending on
|
|
9
9
|
* where the cut lands — produce one of two illegal shapes:
|