@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.
- package/connection/message-router.js +21 -1
- package/conversation.js +79 -6
- package/history.js +22 -59
- package/package.json +1 -1
- package/providers/acp-client.js +135 -0
- package/providers/base.js +41 -0
- package/providers/claude-code.js +62 -1
- package/providers/copilot-models.js +17 -0
- package/providers/copilot.js +681 -176
- package/yeaft/chats/chat-store.js +224 -0
- package/yeaft/conversation/persist.js +98 -0
- package/yeaft/dream-v2/apply.js +8 -0
- package/yeaft/dream-v2/prompts/index.js +3 -0
- package/yeaft/dream-v2/triage.js +16 -1
- package/yeaft/engine.js +24 -8
- package/yeaft/groups/pre-flow.js +13 -4
- package/yeaft/llm/models-dev.js +7 -1
- package/yeaft/memory/segment.js +1 -1
- package/yeaft/memory/store-v2.js +41 -2
- package/yeaft/web-bridge.js +326 -3
|
@@ -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);
|
package/yeaft/dream-v2/apply.js
CHANGED
|
@@ -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';
|
package/yeaft/dream-v2/triage.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
1098
|
-
? conversationStore.
|
|
1099
|
-
:
|
|
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 (
|
|
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);
|
package/yeaft/groups/pre-flow.js
CHANGED
|
@@ -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
|
|
171
|
-
let m = /^
|
|
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 (
|
|
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
|
});
|
package/yeaft/llm/models-dev.js
CHANGED
|
@@ -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 ?
|
|
46
|
+
return age < 0 ? 0 : age;
|
|
41
47
|
} catch {
|
|
42
48
|
return null;
|
|
43
49
|
}
|
package/yeaft/memory/segment.js
CHANGED
|
@@ -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 →
|
package/yeaft/memory/store-v2.js
CHANGED
|
@@ -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
|
|