@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.
@@ -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
- export const DEFAULT_RECENT_TURNS = 20;
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 (`loadOlderByGroup`) to compare ids numerically without having
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
- #groupsDir; // ~/.yeaft/groups
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/group/legacy
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.#groupsDir = join(dir, 'groups');
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.#groupsDir,
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.#groupConversationDir(sessionId, { create: true }), 'compact');
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
- hasAnyCompactSummaryForGroup(sessionId) {
734
+ hasAnyCompactSummaryForSession(sessionId) {
636
735
  if (!sessionId) return false;
637
- const compactDir = join(this.#groupConversationDir(sessionId), 'compact');
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.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold')]) {
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
- loadRecentByGroup(sessionId, turnsLimit = DEFAULT_RECENT_TURNS) {
888
+ loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS) {
790
889
  if (!sessionId) return [];
791
- const all = this.#loadGroupMessages(sessionId)
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
- return pairSanitize(sliceLastNTurns(filtered, turnsLimit));
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
- loadAllByGroup(sessionId) {
804
- return this.loadRecentByGroup(sessionId, Infinity);
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
- loadGroupHistoryForVp(sessionId, vpId) {
945
+ loadSessionHistoryForVp(sessionId, vpId) {
833
946
  if (!sessionId || !vpId) return [];
834
- const all = this.#loadGroupMessages(sessionId)
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
- * `loadOlderByGroup(g, store.firstSeq || 0, ...)` will silently get
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
- loadOlderByGroup(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
1017
+ loadOlderBySession(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
905
1018
  if (!sessionId) return { messages: [], oldestSeq: null, hasMore: false };
906
- const hot = this.#loadGroupHotMessages(sessionId);
907
- const cold = this.#loadGroupColdMessages(sessionId);
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 `loadOlderByGroup`, this
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
- loadVisibleByGroup(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
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.#loadVisibleFromDirsByGroup([...this.#groupMessageDirs('messages', sessionId), this.#legacyMsgDir], sessionId, cutoff);
948
- const cold = this.#loadVisibleFromDirsByGroup([...this.#groupMessageDirs('cold', sessionId), this.#legacyColdDir], sessionId, cutoff);
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.#loadGroupHotMessages(sessionId);
985
- const cold = this.#loadGroupColdMessages(sessionId);
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.#groupMessageDirs('messages'), this.#legacyMsgDir]);
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.#groupMessageDirs('cold'), this.#legacyColdDir]);
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.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
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.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
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.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
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.#groupMessageDirs('cold'), ...this.#groupMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
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.#groupMessageDirs('cold'), ...this.#groupMessageDirs('messages'), this.#legacyColdDir, this.#legacyMsgDir]) {
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.#groupConversationDir(msg.sessionId, { create: true }), 'messages');
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 loadRecentByGroup. */
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 loadGroupHistoryForVp. */
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
- #groupConversationDir(sessionId, { create = false } = {}) {
1500
- const dir = join(this.#groupsDir, this.#safeDirComponent(sessionId), 'conversation');
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
- #groupConversationDirs() {
1512
- if (!existsSync(this.#groupsDir)) return [];
1624
+ #sessionConversationDirs() {
1625
+ if (!existsSync(this.#sessionsDir)) return [];
1513
1626
  const dirs = [];
1514
- for (const name of readdirSync(this.#groupsDir)) {
1515
- const sessionDir = join(this.#groupsDir, name);
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
- #groupMessageDirs(kind, sessionId = null) {
1641
+ #sessionMessageDirs(kind, sessionId = null) {
1529
1642
  if (sessionId) {
1530
- const dir = join(this.#groupConversationDir(sessionId), kind);
1643
+ const dir = join(this.#sessionConversationDir(sessionId), kind);
1531
1644
  return existsSync(dir) ? [dir] : [];
1532
1645
  }
1533
- return this.#groupConversationDirs()
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.#groupConversationDirs().map(dir => [join(dir, 'messages'), join(dir, 'cold')]),
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
- #loadGroupHotMessages(sessionId = null) {
1670
+ #loadSessionHotMessages(sessionId = null) {
1558
1671
  return [
1559
1672
  ...this.#loadFromDir(this.#legacyMsgDir, Infinity).filter(m => m?.sessionId),
1560
- ...this.#groupMessageDirs('messages', sessionId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1673
+ ...this.#sessionMessageDirs('messages', sessionId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1561
1674
  ].sort(compareMessagesBySeq);
1562
1675
  }
1563
1676
 
1564
- #loadGroupColdMessages(sessionId = null) {
1677
+ #loadSessionColdMessages(sessionId = null) {
1565
1678
  return [
1566
1679
  ...this.#loadFromDir(this.#legacyColdDir, Infinity).filter(m => m?.sessionId),
1567
- ...this.#groupMessageDirs('cold', sessionId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1680
+ ...this.#sessionMessageDirs('cold', sessionId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1568
1681
  ].sort(compareMessagesBySeq);
1569
1682
  }
1570
1683
 
1571
- #loadGroupMessages(sessionId = null) {
1572
- return [...this.#loadGroupColdMessages(sessionId), ...this.#loadGroupHotMessages(sessionId)].sort(compareMessagesBySeq);
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.#groupMessageDirs('cold').flatMap(dir => this.#loadFromDir(dir, Infinity)),
1582
- ...this.#groupMessageDirs('messages').flatMap(dir => this.#loadFromDir(dir, Infinity)),
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
- #loadVisibleFromDirsByGroup(dirs, sessionId, beforeSeq) {
1600
- return dirs.flatMap(dir => this.#loadVisibleFromDirByGroup(dir, sessionId, beforeSeq))
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
- #loadVisibleFromDirByGroup(dir, sessionId, beforeSeq) {
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.#groupMessageDirs('messages'), ...this.#groupMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
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.loadGroupHistoryForVp).
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.loadGroupHistoryForVp === 'function');
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.loadGroupHistoryForVp(this.#sessionId, this.#vpId)
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;
@@ -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#loadRecentByGroup` and
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 — group-keyed AMS lifecycle.
2
+ * memory/ams-registry.js — session-keyed AMS lifecycle.
3
3
  *
4
- * The Active Memory Set is conceptually session-scoped, but Yeaft's
5
- * unit of "session" is a group: a deactivated group can be reactivated
6
- * later and should resume with the AMS state it had on disconnect (the
7
- * onDemand segments it had pulled in, the recent LRU touches, whether
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/groups/<sessionId>/ams.json
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 group is opened. Resident layer is derived state
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 group), the registry uses the literal
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 DEFAULT_GROUP_KEY = 'default';
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 `DEFAULT_GROUP_KEY`. No defensive
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 || DEFAULT_GROUP_KEY);
91
- return join(this.yeaftDir, 'memory', 'groups', key, 'ams.json');
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 || DEFAULT_GROUP_KEY;
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 || DEFAULT_GROUP_KEY;
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 || DEFAULT_GROUP_KEY;
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 || DEFAULT_GROUP_KEY);
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 || DEFAULT_GROUP_KEY;
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;
@@ -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#loadRecentByGroup` and
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: