@yeaft/webchat-agent 0.1.859 → 0.1.863

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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * chat-store.js — Per-chat persistent store for Yeaft Chat Mode.
3
+ *
4
+ * Layout (architecture parity with group-store.js):
5
+ * ~/.yeaft/chats/<chat-id>/
6
+ * chat.json # { id, displayName, vpId, workDir, createdAt, lastTurnAt }
7
+ * messages/ # JSONL size-rotation log
8
+ * 000001.jsonl
9
+ * index.json
10
+ *
11
+ * A chat is 1:1 with a single VP and persists messages the same way groups
12
+ * do — same storage primitives, same jsonl shape — but without a roster.
13
+ *
14
+ * Hard constraint: no @-mention parsing, no dispatch, no engine awareness.
15
+ * This module only owns chat.json + the messages log.
16
+ */
17
+
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ readdirSync,
23
+ renameSync,
24
+ rmSync,
25
+ statSync,
26
+ } from 'fs';
27
+ import { join } from 'path';
28
+ import { writeAtomic, openLog } from '../storage/index.js';
29
+ import { nextMsgId, isReservedVpId, ReservedVpIdError, validateVpId, InvalidVpIdError } from '../groups/ids.js';
30
+
31
+ const CHAT_FILE = 'chat.json';
32
+ const MESSAGES_DIR = 'messages';
33
+
34
+ const CHAT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
35
+
36
+ /**
37
+ * Open (or partially create) the directory for a chat. Returns a handle even
38
+ * when chat.json is absent — call createChat() to materialise it.
39
+ *
40
+ * @param {string} chatsRoot
41
+ * @param {string} chatId
42
+ * @returns {ChatHandle}
43
+ */
44
+ export function openChat(chatsRoot, chatId) {
45
+ if (!chatId || typeof chatId !== 'string') {
46
+ throw new Error('openChat: chatId required (string)');
47
+ }
48
+ if (!CHAT_ID_RE.test(chatId)) {
49
+ throw new Error(`openChat: invalid chatId "${chatId}"`);
50
+ }
51
+ const dir = join(chatsRoot, chatId);
52
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
53
+
54
+ let meta = loadChatMeta(dir);
55
+
56
+ const messagesDir = join(dir, MESSAGES_DIR);
57
+ if (!existsSync(messagesDir)) mkdirSync(messagesDir, { recursive: true });
58
+ const log = openLog(messagesDir);
59
+
60
+ return {
61
+ dir,
62
+ id: chatId,
63
+ getMeta() { return meta ? structuredClone(meta) : null; },
64
+ saveMeta(next) {
65
+ validateMeta(next);
66
+ meta = next;
67
+ writeAtomic(join(dir, CHAT_FILE), JSON.stringify(meta, null, 2));
68
+ },
69
+ appendMessage(record) {
70
+ if (!record || typeof record !== 'object') {
71
+ throw new Error('appendMessage: record required');
72
+ }
73
+ const leaked = Object.keys(record).filter((k) => typeof k === 'string' && k.startsWith('_'));
74
+ if (leaked.length > 0) {
75
+ throw new Error(`appendMessage: ephemeral fields leaked into log: ${leaked.join(', ')}`);
76
+ }
77
+ const stored = {
78
+ id: record.id || nextMsgId(),
79
+ ts: record.ts || new Date().toISOString(),
80
+ from: record.from,
81
+ role: record.role || (record.from === 'user' ? 'user' : 'assistant'),
82
+ text: record.text ?? '',
83
+ taskId: record.taskId || null,
84
+ mentions: Array.isArray(record.mentions) ? record.mentions.slice() : [],
85
+ meta: record.meta || {},
86
+ };
87
+ log.append(stored);
88
+ return stored;
89
+ },
90
+ *streamMessages() { yield* log.streamAll(); },
91
+ *readMessageRange(firstId, lastId) { yield* log.readRange(firstId, lastId); },
92
+ close() { log.close(); },
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Create a new chat on disk. Fails if chat.json already exists.
98
+ * @param {string} chatsRoot
99
+ * @param {{id:string, vpId:string, displayName?:string, workDir?:string, createdAt?:string}} spec
100
+ * @returns {ChatHandle}
101
+ */
102
+ export function createChat(chatsRoot, spec) {
103
+ if (!spec || !spec.id) throw new Error('createChat: spec.id required');
104
+ if (!spec.vpId) throw new Error('createChat: spec.vpId required');
105
+ if (isReservedVpId(spec.vpId)) throw new ReservedVpIdError(spec.vpId);
106
+ const verdict = validateVpId(spec.vpId);
107
+ if (!verdict.ok) throw new InvalidVpIdError(spec.vpId, verdict.reason);
108
+
109
+ const h = openChat(chatsRoot, spec.id);
110
+ if (h.getMeta()) {
111
+ throw new Error(`chat ${spec.id} already exists`);
112
+ }
113
+ const meta = {
114
+ id: spec.id,
115
+ displayName: typeof spec.displayName === 'string' && spec.displayName.trim()
116
+ ? spec.displayName.trim() : spec.id,
117
+ vpId: spec.vpId,
118
+ workDir: typeof spec.workDir === 'string' ? spec.workDir.trim() : '',
119
+ createdAt: spec.createdAt || new Date().toISOString(),
120
+ lastTurnAt: null,
121
+ };
122
+ h.saveMeta(meta);
123
+ return h;
124
+ }
125
+
126
+ /** Non-destructive load — returns null if chat.json is missing/corrupt. */
127
+ export function loadChatMeta(dir) {
128
+ const path = join(dir, CHAT_FILE);
129
+ if (!existsSync(path)) return null;
130
+ try {
131
+ const raw = readFileSync(path, 'utf8');
132
+ const parsed = JSON.parse(raw);
133
+ validateMeta(parsed);
134
+ if (typeof parsed.displayName !== 'string') parsed.displayName = parsed.id;
135
+ if (typeof parsed.workDir !== 'string') parsed.workDir = '';
136
+ if (parsed.lastTurnAt === undefined) parsed.lastTurnAt = null;
137
+ return parsed;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /** List every chat directory under `chatsRoot`. */
144
+ export function listChats(chatsRoot) {
145
+ if (!existsSync(chatsRoot)) return [];
146
+ const out = [];
147
+ for (const name of readdirSync(chatsRoot)) {
148
+ if (name.startsWith('.')) continue;
149
+ const p = join(chatsRoot, name);
150
+ try {
151
+ if (!statSync(p).isDirectory()) continue;
152
+ } catch { continue; }
153
+ const meta = loadChatMeta(p);
154
+ if (meta) out.push(meta);
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /** Update a chat's displayName. */
160
+ export function renameChat(chatsRoot, chatId, displayName) {
161
+ const h = openChat(chatsRoot, chatId);
162
+ const meta = h.getMeta();
163
+ if (!meta) throw new Error(`renameChat: chat ${chatId} not found`);
164
+ const next = { ...meta, displayName: String(displayName || '').trim() || meta.id };
165
+ h.saveMeta(next);
166
+ h.close();
167
+ return next;
168
+ }
169
+
170
+ /** Stamp lastTurnAt — invoked by web-bridge after a successful turn. */
171
+ export function touchChat(chatsRoot, chatId, when = new Date().toISOString()) {
172
+ const h = openChat(chatsRoot, chatId);
173
+ const meta = h.getMeta();
174
+ if (!meta) { h.close(); return null; }
175
+ const next = { ...meta, lastTurnAt: when };
176
+ h.saveMeta(next);
177
+ h.close();
178
+ return next;
179
+ }
180
+
181
+ /** Soft-archive: rename dir to `.archived-<chatId>-<ts>`. */
182
+ export function archiveChat(chatsRoot, chatId) {
183
+ const src = join(chatsRoot, chatId);
184
+ if (!existsSync(src)) return false;
185
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
186
+ const dst = join(chatsRoot, `.archived-${chatId}-${ts}`);
187
+ renameSync(src, dst);
188
+ return true;
189
+ }
190
+
191
+ /** Permanently delete a chat directory. Use after archive when sure. */
192
+ export function deleteChat(chatsRoot, chatId) {
193
+ const src = join(chatsRoot, chatId);
194
+ if (!existsSync(src)) return false;
195
+ rmSync(src, { recursive: true, force: true });
196
+ return true;
197
+ }
198
+
199
+ function validateMeta(meta) {
200
+ if (!meta || typeof meta !== 'object') throw new Error('chat.json must be object');
201
+ if (!meta.id || typeof meta.id !== 'string') throw new Error('chat.id required');
202
+ if (!meta.vpId || typeof meta.vpId !== 'string') throw new Error('chat.vpId required');
203
+ if (meta.displayName != null && typeof meta.displayName !== 'string') {
204
+ throw new Error('chat.displayName must be string');
205
+ }
206
+ if (meta.workDir != null && typeof meta.workDir !== 'string') {
207
+ throw new Error('chat.workDir must be string');
208
+ }
209
+ if (meta.lastTurnAt != null && typeof meta.lastTurnAt !== 'string') {
210
+ throw new Error('chat.lastTurnAt must be string|null');
211
+ }
212
+ }
213
+
214
+ /**
215
+ * @typedef {Object} ChatHandle
216
+ * @property {string} dir
217
+ * @property {string} id
218
+ * @property {() => any} getMeta
219
+ * @property {(next:any) => void} saveMeta
220
+ * @property {(record:any) => any} appendMessage
221
+ * @property {() => Generator<any>} streamMessages
222
+ * @property {(first:string,last:string) => Generator<any>} readMessageRange
223
+ * @property {() => void} close
224
+ */
@@ -109,6 +109,7 @@ function serializeMessage(msg) {
109
109
  // in the default group and switching back to the originating group
110
110
  // shows an empty pane.
111
111
  if (msg.groupId) fm.push(`groupId: ${msg.groupId}`);
112
+ if (msg.chatId) fm.push(`chatId: ${msg.chatId}`);
112
113
  // Group-chat attribution: when a VP authors an assistant turn (either
113
114
  // its own reply or a route_forward injection from another VP), stamp
114
115
  // the speaker so the UI can render the message on the correct VP track.
@@ -225,6 +226,7 @@ export function parseMessage(raw) {
225
226
  case 'threadId': msg.threadId = value; break;
226
227
  case 'sourceThreadId': msg.sourceThreadId = value; break;
227
228
  case 'groupId': msg.groupId = value; break;
229
+ case 'chatId': msg.chatId = value; break;
228
230
  case 'speakerVpId': msg.speakerVpId = value; break;
229
231
  case 'attachmentsB64':
230
232
  try {
@@ -1354,10 +1356,106 @@ export class ConversationStore {
1354
1356
  }
1355
1357
 
1356
1358
  #messageDirFor(msg) {
1359
+ if (msg?.chatId) return join(this.#chatConversationDir(msg.chatId, { create: true }), 'messages');
1357
1360
  if (!msg?.groupId) return this.#chatMsgDir;
1358
1361
  return join(this.#groupConversationDir(msg.groupId, { create: true }), 'messages');
1359
1362
  }
1360
1363
 
1364
+ #chatConversationDir(chatId, { create = false } = {}) {
1365
+ const dir = join(this.#dir, 'chats', this.#safeDirComponent(chatId), 'conversation');
1366
+ if (create) this.#ensureConversationDirs(dir);
1367
+ return dir;
1368
+ }
1369
+
1370
+ #chatConversationDirs() {
1371
+ const root = join(this.#dir, 'chats');
1372
+ if (!existsSync(root)) return [];
1373
+ const dirs = [];
1374
+ for (const name of readdirSync(root)) {
1375
+ if (name.startsWith('.')) continue;
1376
+ const chatDir = join(root, name);
1377
+ try { if (!statSync(chatDir).isDirectory()) continue; }
1378
+ catch (err) { if (isPermissionError(err)) continue; throw err; }
1379
+ const conv = join(chatDir, 'conversation');
1380
+ if (existsSync(conv)) dirs.push(conv);
1381
+ }
1382
+ return dirs;
1383
+ }
1384
+
1385
+ #chatMessageDirs(kind, chatId = null) {
1386
+ if (chatId) {
1387
+ const dir = join(this.#chatConversationDir(chatId), kind);
1388
+ return existsSync(dir) ? [dir] : [];
1389
+ }
1390
+ return this.#chatConversationDirs()
1391
+ .map(dir => join(dir, kind))
1392
+ .filter(dir => existsSync(dir));
1393
+ }
1394
+
1395
+ /** Per-chat scoped compact summary path. */
1396
+ #scopedChatCompactPath(chatId, vpId) {
1397
+ if (!chatId || !vpId) return null;
1398
+ const dir = join(this.#chatConversationDir(chatId, { create: true }), 'compact');
1399
+ return join(dir, `${this.#safeIdComponent(vpId)}.md`);
1400
+ }
1401
+
1402
+ /** Read per-(chatId, vpId) compact summary. */
1403
+ readCompactSummaryForChat(chatId, vpId) {
1404
+ const p = this.#scopedChatCompactPath(chatId, vpId);
1405
+ if (!p || !existsSync(p)) return '';
1406
+ try { return readFileSync(p, 'utf8'); } catch { return ''; }
1407
+ }
1408
+
1409
+ /** Rewrite per-(chatId, vpId) compact summary. */
1410
+ replaceCompactSummaryForChat(chatId, vpId, summary) {
1411
+ if (typeof summary !== 'string' || !summary) return;
1412
+ const p = this.#scopedChatCompactPath(chatId, vpId);
1413
+ if (!p) return;
1414
+ try { writeFileSync(p, summary, { encoding: 'utf8', mode: 0o644 }); }
1415
+ catch (err) {
1416
+ if (isPermissionError(err)) {
1417
+ if (!_permissionWarned) {
1418
+ console.warn(`[Yeaft] Cannot write scoped chat compact summary: ${err.code}`);
1419
+ _permissionWarned = true;
1420
+ }
1421
+ } else throw err;
1422
+ }
1423
+ }
1424
+
1425
+ /** Recent messages for a chat — chat mode mirror of loadRecentByGroup. */
1426
+ loadRecentByChat(chatId, turnsLimit = DEFAULT_RECENT_TURNS) {
1427
+ if (!chatId) return [];
1428
+ const all = [
1429
+ ...this.#chatMessageDirs('messages', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1430
+ ...this.#chatMessageDirs('cold', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1431
+ ].sort(compareMessagesBySeq);
1432
+ const filtered = all.filter(m => m && m.chatId === chatId);
1433
+ if (turnsLimit === Infinity || turnsLimit < 0) return pairSanitize(filtered);
1434
+ return pairSanitize(sliceLastNTurns(filtered, turnsLimit));
1435
+ }
1436
+
1437
+ /** VP-scoped chat history — chat-mode mirror of loadGroupHistoryForVp. */
1438
+ loadChatHistoryForVp(chatId, vpId) {
1439
+ if (!chatId || !vpId) return [];
1440
+ const all = [
1441
+ ...this.#chatMessageDirs('messages', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1442
+ ...this.#chatMessageDirs('cold', chatId).flatMap(dir => this.#loadFromDir(dir, Infinity)),
1443
+ ].sort(compareMessagesBySeq);
1444
+ const out = [];
1445
+ for (const m of all) {
1446
+ if (!m || m.chatId !== chatId) continue;
1447
+ if (m._reflection || m.internal || m.systemOnly || m.systemOnlyMessage) continue;
1448
+ if (m.role === 'user') { out.push(m); continue; }
1449
+ if (m.role === 'assistant') {
1450
+ // Chat is 1:1 — every assistant row is "ours".
1451
+ out.push(m);
1452
+ continue;
1453
+ }
1454
+ if (m.role === 'tool') { out.push(m); continue; }
1455
+ }
1456
+ return pairSanitize(out);
1457
+ }
1458
+
1361
1459
  #groupConversationDir(groupId, { create = false } = {}) {
1362
1460
  const dir = join(this.#groupsDir, this.#safeDirComponent(groupId), 'conversation');
1363
1461
  if (create) this.#ensureConversationDirs(dir);
@@ -143,6 +143,12 @@ export function targetToScope(target) {
143
143
  return { kind: 'group-topic', groupId: segs[1], path: segs.slice(3) };
144
144
  }
145
145
  }
146
+ if (segs[0] === 'chat') {
147
+ if (segs.length === 2) return { kind: 'chat', id: segs[1] };
148
+ if (segs.length === 4 && segs[2] === 'vp') {
149
+ return { kind: 'chat-vp', chatId: segs[1], id: segs[3] };
150
+ }
151
+ }
146
152
  throw new Error(`apply.targetToScope: malformed target ${JSON.stringify(target)}`);
147
153
  }
148
154
 
@@ -274,6 +280,8 @@ function scopeRelDir(scope) {
274
280
  case 'group-vp': return `group/${scope.groupId}/vp/${scope.id}`;
275
281
  case 'group-feature': return `group/${scope.groupId}/feature/${scope.id}`;
276
282
  case 'group-topic': return `group/${scope.groupId}/topic/${scope.path.join('/')}`;
283
+ case 'chat': return `chat/${scope.id}`;
284
+ case 'chat-vp': return `chat/${scope.chatId}/vp/${scope.id}`;
277
285
  default: throw new Error(`apply.scopeRelDir: unknown kind ${scope.kind}`);
278
286
  }
279
287
  }
@@ -47,6 +47,9 @@ export function extractTemplateForScope(scope) {
47
47
  if (/^group\/[^/]+\/topic\//.test(scope)) return 'extractTopic';
48
48
  if (/^group\/[^/]+\/user(?:\/|$)/.test(scope)) return 'extractUser';
49
49
  if (scope.startsWith('group/')) return 'extractGroup';
50
+ // Chat-isolated scopes: same template family as groups.
51
+ if (/^chat\/[^/]+\/vp\//.test(scope)) return 'extractVp';
52
+ if (scope.startsWith('chat/')) return 'extractGroup';
50
53
  // Legacy top-level vp/topic scopes (archived to .legacy/ on boot — kept
51
54
  // here defensively in case something still constructs the old strings).
52
55
  if (scope.startsWith('vp/')) return 'extractVp';
@@ -58,13 +58,28 @@ function triageSystem(language) {
58
58
  * @param {{ groupId: string, messages: Array<object> }} args
59
59
  * @returns {Array<{ kind: 'update', scope: string }>}
60
60
  */
61
- export function applyHardRules({ groupId, messages }) {
61
+ export function applyHardRules({ groupId, chatId, messages }) {
62
62
  const out = new Map();
63
63
  const add = (scope) => { if (!out.has(scope)) out.set(scope, { kind: 'update', scope }); };
64
64
 
65
65
  // global user is always in.
66
66
  add('user');
67
67
 
68
+ // chat path takes precedence: chat sessions have no group context.
69
+ if (chatId) {
70
+ add(`chat/${chatId}`);
71
+ for (const m of (messages || [])) {
72
+ if (!m || typeof m !== 'object') continue;
73
+ if (m.role === 'assistant') {
74
+ const vp = m.vpId || (m.author && /^vp:(.+)$/.exec(m.author)?.[1]);
75
+ if (vp && /^[A-Za-z0-9_\-.一-鿿]+$/.test(vp)) {
76
+ add(`chat/${chatId}/vp/${vp}`);
77
+ }
78
+ }
79
+ }
80
+ return Array.from(out.values());
81
+ }
82
+
68
83
  // active group + its per-group user layer, except the virtual _no-group bucket.
69
84
  if (groupId && groupId !== '_no-group') {
70
85
  add(`group/${groupId}`);
package/yeaft/engine.js CHANGED
@@ -306,6 +306,8 @@ export class Engine {
306
306
  #groupId = null;
307
307
  /** @type {string|null} — set when this engine is bound to a specific VP (per-VP fan-out path). */
308
308
  #vpId = null;
309
+ /** @type {string|null} — set when this engine is bound to a chat session (Chat Mode). */
310
+ #chatId = null;
309
311
 
310
312
  /** @type {import('./stats/tool-usage.js').ToolUsageStats|null} — per-tool call/latency counters */
311
313
  #toolStats = null;
@@ -406,7 +408,7 @@ export class Engine {
406
408
  * toolStats?: import('./stats/tool-usage.js').ToolUsageStats,
407
409
  * }} params
408
410
  */
409
- constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null, groupId = null, vpId = null }) {
411
+ constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null, groupId = null, vpId = null, chatId = null }) {
410
412
  this.#adapter = adapter;
411
413
  this.#trace = trace;
412
414
  this.#config = config;
@@ -428,6 +430,7 @@ export class Engine {
428
430
  // callers leave both null → fall back to the global file.
429
431
  this.#groupId = (typeof groupId === 'string' && groupId) ? groupId : null;
430
432
  this.#vpId = (typeof vpId === 'string' && vpId) ? vpId : null;
433
+ this.#chatId = (typeof chatId === 'string' && chatId) ? chatId : null;
431
434
 
432
435
  // PR-L: tool history reflection log. Keyed by traceId so distinct
433
436
  // engine instances don't stomp on each other's jsonl files. When
@@ -972,6 +975,7 @@ export class Engine {
972
975
  const result = runMemoryPreflow(this.#memoryIndex, {
973
976
  userMsg: prompt,
974
977
  groupId: ctx.groupId,
978
+ chatId: ctx.chatId || this.#chatId,
975
979
  vpId: ctx.vpId,
976
980
  });
977
981
  memory.profile = result.profile || '';
@@ -994,6 +998,10 @@ export class Engine {
994
998
  // read ONLY its own summary file. Falling back to legacy compact.md here
995
999
  // leaks another group/VP's summary into every new group turn after one
996
1000
  // post-turn compact writes the session-global file.
1001
+ if (this.#chatId && this.#vpId
1002
+ && typeof this.#conversationStore.readCompactSummaryForChat === 'function') {
1003
+ return this.#conversationStore.readCompactSummaryForChat(this.#chatId, this.#vpId);
1004
+ }
997
1005
  if (this.#groupId && this.#vpId
998
1006
  && typeof this.#conversationStore.readCompactSummaryFor === 'function') {
999
1007
  return this.#conversationStore.readCompactSummaryFor(this.#groupId, this.#vpId);
@@ -1032,8 +1040,9 @@ export class Engine {
1032
1040
  role: 'user',
1033
1041
  content: userContent,
1034
1042
  threadId,
1035
- // Bug 6: stamp groupId so history replay can route by group.
1043
+ // Bug 6: stamp groupId/chatId so history replay can route by container.
1036
1044
  ...(groupId ? { groupId } : {}),
1045
+ ...(this.#chatId ? { chatId: this.#chatId } : {}),
1037
1046
  });
1038
1047
  }
1039
1048
 
@@ -1044,6 +1053,7 @@ export class Engine {
1044
1053
  model: this.#config.model,
1045
1054
  threadId,
1046
1055
  ...(groupId ? { groupId } : {}),
1056
+ ...(this.#chatId ? { chatId: this.#chatId } : {}),
1047
1057
  };
1048
1058
  if (toolCalls && toolCalls.length > 0) {
1049
1059
  assistantMsg.toolCalls = toolCalls;
@@ -1091,12 +1101,16 @@ export class Engine {
1091
1101
  // Legacy / sub-agent callers (no groupId/vpId pair) keep the global
1092
1102
  // loadAll() behaviour so we don't break those flows.
1093
1103
  let messages;
1094
- const scoped = !!(this.#groupId && this.#vpId
1104
+ const scopedChat = !!(this.#chatId && this.#vpId
1105
+ && typeof conversationStore.loadChatHistoryForVp === 'function');
1106
+ const scoped = !scopedChat && !!(this.#groupId && this.#vpId
1095
1107
  && typeof conversationStore.loadGroupHistoryForVp === 'function');
1096
1108
  try {
1097
- messages = scoped
1098
- ? conversationStore.loadGroupHistoryForVp(this.#groupId, this.#vpId)
1099
- : conversationStore.loadAll();
1109
+ messages = scopedChat
1110
+ ? conversationStore.loadChatHistoryForVp(this.#chatId, this.#vpId)
1111
+ : scoped
1112
+ ? conversationStore.loadGroupHistoryForVp(this.#groupId, this.#vpId)
1113
+ : conversationStore.loadAll();
1100
1114
  } catch { return null; }
1101
1115
  if (!Array.isArray(messages) || messages.length === 0) return null;
1102
1116
 
@@ -1212,11 +1226,13 @@ export class Engine {
1212
1226
  // per-VP summary written below is the durable win; physical
1213
1227
  // cold-archival across shared rows is the dream-level orchestrator's
1214
1228
  // job, not post-turn compact's.
1215
- if (!scoped && archiveIds.length > 0) {
1229
+ if (!scoped && !scopedChat && archiveIds.length > 0) {
1216
1230
  conversationStore.moveToColdBatch(archiveIds);
1217
1231
  }
1218
1232
  if (out.compactSummary) {
1219
- if (scoped && typeof conversationStore.replaceCompactSummaryFor === 'function') {
1233
+ if (scopedChat && typeof conversationStore.replaceCompactSummaryForChat === 'function') {
1234
+ conversationStore.replaceCompactSummaryForChat(this.#chatId, this.#vpId, out.compactSummary);
1235
+ } else if (scoped && typeof conversationStore.replaceCompactSummaryFor === 'function') {
1220
1236
  conversationStore.replaceCompactSummaryFor(this.#groupId, this.#vpId, out.compactSummary);
1221
1237
  } else {
1222
1238
  conversationStore.replaceCompactSummary(out.compactSummary);
@@ -167,8 +167,13 @@ export function selectRespondingVps(input) {
167
167
  */
168
168
  function scopeHeading(scope) {
169
169
  if (scope === 'user') return '## Memory: User';
170
- // Nested group scopes first.
171
- let m = /^group\/([^/]+)\/vp\/(.+)$/.exec(scope);
170
+ // Nested chat scopes first.
171
+ let m = /^chat\/([^/]+)\/vp\/(.+)$/.exec(scope);
172
+ if (m) return `## Memory: VP ${m[2]}`;
173
+ m = /^chat\/([^/]+)$/.exec(scope);
174
+ if (m) return `## Memory: Chat ${m[1]}`;
175
+ // Nested group scopes.
176
+ m = /^group\/([^/]+)\/vp\/(.+)$/.exec(scope);
172
177
  if (m) return `## Memory: VP ${m[2]}`;
173
178
  m = /^group\/([^/]+)\/user$/.exec(scope);
174
179
  if (m) return `## Memory: Group ${m[1]} (user)`;
@@ -245,9 +250,12 @@ export function formatPickedForInjection(picked) {
245
250
  * @param {{groupId?: string, vpId?: string, extra?: string[]}} ctx
246
251
  * @returns {string[]}
247
252
  */
248
- export function buildRelevantScopes({ groupId, vpId, extra } = {}) {
253
+ export function buildRelevantScopes({ groupId, chatId, vpId, extra } = {}) {
249
254
  const scopes = ['user'];
250
- if (groupId) {
255
+ if (chatId) {
256
+ scopes.push(`chat/${chatId}`);
257
+ if (vpId) scopes.push(`chat/${chatId}/vp/${vpId}`);
258
+ } else if (groupId) {
251
259
  scopes.push(`group/${groupId}`);
252
260
  scopes.push(`group/${groupId}/user`);
253
261
  if (vpId) scopes.push(`group/${groupId}/vp/${vpId}`);
@@ -286,6 +294,7 @@ export function runMemoryPreflow(index, opts) {
286
294
 
287
295
  const relevantScopes = buildRelevantScopes({
288
296
  groupId: opts.groupId,
297
+ chatId: opts.chatId,
289
298
  vpId: opts.vpId,
290
299
  extra: opts.extraScopes,
291
300
  });
@@ -36,8 +36,14 @@ function getCachePath(yeaftDir) {
36
36
  async function diskCacheAgeMs(yeaftDir) {
37
37
  try {
38
38
  const s = await stat(getCachePath(yeaftDir));
39
+ // Clamp negative ages (clock-skew between filesystem mtime and
40
+ // JS Date.now() can momentarily be a few ms ahead on some CI
41
+ // runners). Returning null here would skip stage-2 and force a
42
+ // network fetch — which broke the deterministic listProviders test
43
+ // by serving the live ~140-provider models.dev payload. A freshly
44
+ // written cache is, by definition, fresh.
39
45
  const age = Date.now() - s.mtimeMs;
40
- return age < 0 ? null : age;
46
+ return age < 0 ? 0 : age;
41
47
  } catch {
42
48
  return null;
43
49
  }
@@ -51,7 +51,7 @@ export const KIND_VALUES = new Set([
51
51
  'fact', 'preference', 'decision', 'lesson', 'relation', 'goal', 'context',
52
52
  ]);
53
53
 
54
- const SCOPE_RE = /^(user|group\/[\w-]+(?:\/(?:user|vp\/[\w-]+|feature\/[\w-]+|topic\/[\w-]+(?:\/[\w-]+)?))?)$/;
54
+ const SCOPE_RE = /^(user|group\/[\w-]+(?:\/(?:user|vp\/[\w-]+|feature\/[\w-]+|topic\/[\w-]+(?:\/[\w-]+)?))?|chat\/[\w-]+(?:\/vp\/[\w-]+)?)$/;
55
55
 
56
56
  /**
57
57
  * Compute a stable id from segment content. Same body + scope + kind →
@@ -62,9 +62,11 @@ export const SCOPE_KINDS = Object.freeze([
62
62
  'group-vp',
63
63
  'group-feature',
64
64
  'group-topic',
65
+ 'chat',
66
+ 'chat-vp',
65
67
  ]);
66
68
 
67
- /** @typedef {'user'|'group'|'group-user'|'group-vp'|'group-feature'|'group-topic'} ScopeKind */
69
+ /** @typedef {'user'|'group'|'group-user'|'group-vp'|'group-feature'|'group-topic'|'chat'|'chat-vp'} ScopeKind */
68
70
 
69
71
  /**
70
72
  * @typedef {Object} Scope
@@ -122,6 +124,18 @@ export function scopeDir(scope) {
122
124
  for (const s of segs) assertSafeSegment(s, 'group-topic.path');
123
125
  return `group/${scope.groupId}/topic/${segs.join('/')}`;
124
126
  }
127
+ case 'chat': {
128
+ if (!scope.id) throw new Error('scopeDir: chat scope requires id');
129
+ assertSafeSegment(scope.id, 'chat.id');
130
+ return `chat/${scope.id}`;
131
+ }
132
+ case 'chat-vp': {
133
+ if (!scope.chatId) throw new Error('scopeDir: chat-vp scope requires chatId');
134
+ if (!scope.id) throw new Error('scopeDir: chat-vp scope requires id');
135
+ assertSafeSegment(scope.chatId, 'chat-vp.chatId');
136
+ assertSafeSegment(scope.id, 'chat-vp.id');
137
+ return `chat/${scope.chatId}/vp/${scope.id}`;
138
+ }
125
139
  default:
126
140
  throw new Error(`scopeDir: unknown kind ${JSON.stringify(scope.kind)}`);
127
141
  }
@@ -187,7 +201,7 @@ export function isValidTopic(scope) {
187
201
  */
188
202
  export function isVpForeign(relPath, currentVpId) {
189
203
  if (!relPath || !currentVpId) return false;
190
- const m = /^group\/[^/]+\/vp\/([^/]+)(?:\/|$)/.exec(relPath);
204
+ const m = /^(?:group|chat)\/[^/]+\/vp\/([^/]+)(?:\/|$)/.exec(relPath);
191
205
  if (!m) return false;
192
206
  return m[1] !== currentVpId;
193
207
  }
@@ -525,6 +539,31 @@ export async function listScopes(opts = {}) {
525
539
  }
526
540
  }
527
541
 
542
+ // chat/<c>/ and chat/<c>/vp/<v>/
543
+ const chatRoot = join(root, 'chat');
544
+ let chats;
545
+ try { chats = await fsp.readdir(chatRoot, { withFileTypes: true }); }
546
+ catch (err) {
547
+ if (err && err.code === 'ENOENT') chats = [];
548
+ else throw err;
549
+ }
550
+ for (const cent of chats) {
551
+ if (!cent.isDirectory()) continue;
552
+ if (cent.name.startsWith('.')) continue;
553
+ if (!isSafeId(cent.name)) continue;
554
+ const c = cent.name;
555
+ out.push({ kind: 'chat', id: c });
556
+ const vpDir = join(chatRoot, c, 'vp');
557
+ let vps;
558
+ try { vps = await fsp.readdir(vpDir, { withFileTypes: true }); }
559
+ catch { vps = []; }
560
+ for (const vent of vps) {
561
+ if (!vent.isDirectory()) continue;
562
+ if (!isSafeId(vent.name)) continue;
563
+ out.push({ kind: 'chat-vp', chatId: c, id: vent.name });
564
+ }
565
+ }
566
+
528
567
  return out;
529
568
  }
530
569