@yeaft/webchat-agent 0.1.965 → 0.1.966

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.965",
3
+ "version": "0.1.966",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -10,17 +10,17 @@
10
10
  * for each session with newCount ≥ MIN_NEW_PER_GROUP (auto)
11
11
  * or > 0 (manual)
12
12
  * or prior messages in a scoped manual session rerun:
13
- * loadDiff() via opts.loadGroupDiff(sessionId, sinceId)
13
+ * loadDiff() via opts.loadSessionDiff(sessionId, sinceId)
14
14
  * applyOverlap() via opts.loadOverlapPreamble(...)
15
15
  * segment() segmentDiff(...)
16
- * triageGroupSegments() group-local actions[]
16
+ * triage segments session-local actions[]
17
17
  *
18
18
  * mergeByTarget() → per-target actions
19
19
  * for each merged target:
20
20
  * applyMergedTarget() (snapshot + UPDATE/CREATE + atomic write)
21
21
  *
22
22
  * bookkeep:
23
- * for each processed group:
23
+ * for each processed session:
24
24
  * session .dream-state ←
25
25
  * { lastDreamMessageId: tail of real diff,
26
26
  * lastDreamAt: nowIso,
@@ -50,6 +50,7 @@ import { segmentDiff, truncateMessage, estimateMessagesTokens } from './segment.
50
50
  import { triageGroupSegments } from './triage.js';
51
51
  import { mergeByTarget } from './merge.js';
52
52
  import { applyMergedTarget } from './apply.js';
53
+ import { extractAndWriteMemorySegments } from './segment-extract.js';
53
54
  import { tsForBackup, pruneOldSnapshots } from './snapshot.js';
54
55
 
55
56
  /**
@@ -58,12 +59,14 @@ import { tsForBackup, pruneOldSnapshots } from './snapshot.js';
58
59
  * @property {boolean} [manual=false] — manual trigger overrides newCount<20 skip
59
60
  * @property {string[]} [scopeFilter] — optional: only dream these targets; scoped manual session triggers rerun the current session when there are prior messages but no new cursor delta ('*' allowed)
60
61
  * @property {(req: {pass:string, prompt:string, system:string}) => Promise<string>} llm
61
- * @property {() => Promise<Array<string>>} listSessions — return all session ids (incl. '_no-group')
62
- * @property {(sessionId: string) => Promise<number>} countMessages — total message count for a group
63
- * @property {(sessionId: string, sinceMessageId: string|null) => Promise<Array<object>>} loadGroupDiff
62
+ * @property {() => Promise<Array<string>>} listSessions — return all session ids (incl. '_no-session')
63
+ * @property {(sessionId: string) => Promise<number>} countMessages — total message count for a session
64
+ * @property {(sessionId: string, sinceMessageId: string|null) => Promise<Array<object>>} [loadSessionDiff]
65
+ * @property {(sessionId: string, sinceMessageId: string|null) => Promise<Array<object>>} [loadGroupDiff] — legacy alias for loadSessionDiff
64
66
  * @property {(sessionId: string, beforeMessageId: string|null, count: number) => Promise<Array<object>>} loadOverlapPreamble
65
67
  * @property {() => Promise<Array<{path:string, summary:string}>>} [listTopicSummaries]
66
68
  * @property {(target: string) => Promise<Array<{path:string, summary:string}>>} [siblingTopicsFor]
69
+ * @property {import('../memory/index-db.js').SegmentIndex|null} [segmentIndex] — optional derived FTS segment index to sync after segment writes
67
70
  * @property {(event: object) => void} [onProgress]
68
71
  * @property {object} [limits] — override DEFAULT_LIMITS
69
72
  * @property {() => string} [nowIso]
@@ -86,7 +89,7 @@ export async function runDream(opts) {
86
89
 
87
90
  onProgress({ phase: 'start', manual: !!opts.manual, ts });
88
91
 
89
- // 1. enumerate groups
92
+ // 1. enumerate sessions
90
93
  const sessionIds = await safeCall(opts.listSessions, []);
91
94
  const filter = Array.isArray(opts.scopeFilter) ? new Set(opts.scopeFilter) : null;
92
95
  const sessionFilter = deriveSessionFilter(filter);
@@ -135,7 +138,8 @@ export async function runDream(opts) {
135
138
 
136
139
  onProgress({ phase: 'load-diff', sessionId });
137
140
  const diffCursor = rerunScopedManual ? null : state.lastDreamMessageId;
138
- const diffNew = await safeCall(() => opts.loadGroupDiff(sessionId, diffCursor), []);
141
+ const loadDiff = opts.loadSessionDiff || opts.loadGroupDiff;
142
+ const diffNew = await safeCall(() => loadDiff(sessionId, diffCursor), []);
139
143
  if (!diffNew || diffNew.length === 0) {
140
144
  sessionsReport.push({ sessionId, new: newCount, status: 'skipped', reason: 'empty-diff' });
141
145
  continue;
@@ -233,9 +237,42 @@ export async function runDream(opts) {
233
237
  }
234
238
  }
235
239
 
236
- // 5. bookkeep only when at least one apply for this group's actions
240
+ // 5. extract atomic H2 memory segments. The apply step above keeps the
241
+ // coarse summary layer (`summary.md`); this step keeps bounded, evidence-
242
+ // backed current details in segment-formatted `memory.md`.
243
+ const segmentReports = [];
244
+ for (const triage of sessionTriages) {
245
+ const appliedTargets = new Set(targetsReport.filter(r => r.status === 'done').map(r => r.target));
246
+ const targets = triage.actions.map(a => a.scope).filter(scope => appliedTargets.has(scope));
247
+ if (targets.length === 0) continue;
248
+ try {
249
+ onProgress({ phase: 'extract-segments', sessionId: triage.sessionId, status: 'running', targets: targets.length });
250
+ const r = await extractAndWriteMemorySegments({
251
+ root: opts.root,
252
+ sessionId: triage.sessionId,
253
+ messages: triage.diff,
254
+ targets,
255
+ llm: opts.llm,
256
+ language: opts.language,
257
+ nowIso: opts.nowIso || (() => nowIso),
258
+ segmentIndex: opts.segmentIndex || null,
259
+ });
260
+ segmentReports.push({ sessionId: triage.sessionId, status: 'done', ...r });
261
+ onProgress({ phase: 'extract-segments', sessionId: triage.sessionId, status: 'done', ...r });
262
+ } catch (err) {
263
+ segmentReports.push({ sessionId: triage.sessionId, status: 'error', error: err.message });
264
+ onProgress({ phase: 'extract-segments', sessionId: triage.sessionId, status: 'error', error: err.message });
265
+ await writeDreamError(opts.root, `sessions/${triage.sessionId}`, {
266
+ phase: 'extract-segments',
267
+ message: err.message,
268
+ stack: err.stack,
269
+ });
270
+ }
271
+ }
272
+
273
+ // 6. bookkeep — only when at least one apply for this session's actions
237
274
  // succeeded. We use a permissive policy: if ANY merged-target apply
238
- // succeeded for a group's contributed actions, advance that group's
275
+ // succeeded for a session's contributed actions, advance that session's
239
276
  // cursor. (If everything errored, we keep the cursor so next run
240
277
  // retries.)
241
278
  const successfulTargets = new Set(targetsReport.filter(r => r.status === 'done').map(r => r.target));
@@ -271,6 +308,7 @@ export async function runDream(opts) {
271
308
  durationMs: duration,
272
309
  sessions: sessionsReport,
273
310
  targets: targetsReport,
311
+ memorySegments: segmentReports,
274
312
  backups: pruned,
275
313
  ts,
276
314
  };
@@ -0,0 +1,239 @@
1
+ /**
2
+ * dream/segment-extract.js.
3
+ *
4
+ * Minimal H2 Dream bridge: extract atomic memory segments for the session
5
+ * scopes touched by a Dream pass and persist them through memory/segment-store.
6
+ * The existing apply path still maintains summary.md as the coarse layer.
7
+ */
8
+
9
+ import { readScope, writeScope } from '../memory/segment-store.js';
10
+ import { syncScope } from '../memory/segment-sync.js';
11
+ import { makeSegment } from '../memory/segment.js';
12
+ import { render, extractTemplateForScope } from './prompts/index.js';
13
+ import { parseJsonSafe } from './triage.js';
14
+
15
+ const MAX_TARGETS = 24;
16
+ const MAX_MESSAGES = 80;
17
+ const MAX_BODY_CHARS = 1200;
18
+ const MAX_SEGMENTS_PER_SCOPE = 64;
19
+ const RECENT_MESSAGE_COUNT = 8;
20
+ const VALID_KINDS = new Set(['fact', 'preference', 'decision', 'lesson', 'relation', 'goal', 'context']);
21
+
22
+ /**
23
+ * @param {{
24
+ * root: string,
25
+ * sessionId: string,
26
+ * messages: Array<object>,
27
+ * targets?: string[],
28
+ * llm: Function,
29
+ * language?: string,
30
+ * nowIso?: Function,
31
+ * segmentIndex?: import('../memory/index-db.js').SegmentIndex|null,
32
+ * }} opts
33
+ */
34
+ export async function extractAndWriteMemorySegments(opts) {
35
+ if (!opts || !opts.root) throw new Error('extractAndWriteMemorySegments: root required');
36
+ if (!opts.sessionId) throw new Error('extractAndWriteMemorySegments: sessionId required');
37
+ if (typeof opts.llm !== 'function') throw new Error('extractAndWriteMemorySegments: llm required');
38
+
39
+ const messages = normalizeMessages(opts.messages || []);
40
+ if (messages.length === 0) return { scopes: 0, segments: 0, errors: [] };
41
+
42
+ const targetScopes = normalizeTargetScopes(opts.sessionId, opts.targets || []);
43
+ const now = opts.nowIso ? opts.nowIso() : new Date().toISOString();
44
+ let segmentCount = 0;
45
+ let scopeCount = 0;
46
+ const errors = [];
47
+
48
+ for (const scope of targetScopes.slice(0, MAX_TARGETS)) {
49
+ let extracted = [];
50
+ try {
51
+ extracted = await extractScopeSegments({
52
+ scope,
53
+ sessionId: opts.sessionId,
54
+ messages,
55
+ llm: opts.llm,
56
+ language: opts.language,
57
+ now,
58
+ });
59
+ } catch (err) {
60
+ errors.push({ scope, error: err.message, rawSnippet: err.rawSnippet || '' });
61
+ }
62
+
63
+ const recent = scope === `sessions/${opts.sessionId}`
64
+ ? [buildRecentSegment({ scope, messages, now })]
65
+ : [];
66
+ if (extracted.length === 0 && recent.length === 0) continue;
67
+
68
+ const nextSegments = mergeSegments(readScope(opts.root, scope), [...extracted, ...recent]);
69
+ writeScope(opts.root, scope, nextSegments);
70
+ if (opts.segmentIndex) syncScope(opts.root, opts.segmentIndex, scope);
71
+ segmentCount += extracted.length + recent.length;
72
+ scopeCount += 1;
73
+ }
74
+
75
+ return { scopes: scopeCount, segments: segmentCount, errors };
76
+ }
77
+
78
+ async function extractScopeSegments({ scope, sessionId, messages, llm, language, now }) {
79
+ const template = extractTemplateForScope(scope);
80
+ const base = render(template, templateVarsForScope(scope, sessionId), { language });
81
+ const prompt = `${base}\n\nTarget scope: ${scope}\n\nConversation diff, oldest first:\n${renderMessages(messages)}\n\nReturn only the JSON array. Do not wrap it in Markdown.`;
82
+ const firstRaw = await llm({ pass: 'extract-segments', prompt, system: extractSystem(language) });
83
+ const firstParsed = parseJsonSafe(firstRaw);
84
+ if (Array.isArray(firstParsed)) {
85
+ return firstParsed
86
+ .map(item => normalizeExtractedSegment({ item, scope, now }))
87
+ .filter(Boolean);
88
+ }
89
+
90
+ const retryPrompt = `${prompt}\n\nYour previous output was malformed JSON. Previous output snippet:\n${rawSnippet(firstRaw)}\n\nRetry now. Return only a strict JSON array.`;
91
+ const retryRaw = await llm({ pass: 'extract-segments-retry', prompt: retryPrompt, system: extractSystem(language) });
92
+ const retryParsed = parseJsonSafe(retryRaw);
93
+ if (!Array.isArray(retryParsed)) {
94
+ const err = new Error(`extract-segments: malformed JSON for ${scope}`);
95
+ err.rawSnippet = rawSnippet(retryRaw || firstRaw);
96
+ throw err;
97
+ }
98
+
99
+ return retryParsed
100
+ .map(item => normalizeExtractedSegment({ item, scope, now }))
101
+ .filter(Boolean);
102
+ }
103
+
104
+ function templateVarsForScope(scope, sessionId) {
105
+ const vars = { sessionId, vpId: '', topicId: '' };
106
+ const vpMatch = /^sessions\/[^/]+\/vp\/(.+)$/.exec(scope);
107
+ if (vpMatch) vars.vpId = vpMatch[1];
108
+ const topicMatch = /^sessions\/[^/]+\/topic\/(.+)$/.exec(scope);
109
+ if (topicMatch) vars.topicId = topicMatch[1];
110
+ return vars;
111
+ }
112
+
113
+ function normalizeTargetScopes(sessionId, targets) {
114
+ const out = new Set(['user', `sessions/${sessionId}`, `sessions/${sessionId}/user`]);
115
+ for (const target of targets) {
116
+ if (typeof target !== 'string') continue;
117
+ const clean = target.trim();
118
+ if (!clean) continue;
119
+ if (clean === 'user' || clean.startsWith(`sessions/${sessionId}/`) || clean === `sessions/${sessionId}`) {
120
+ out.add(clean);
121
+ }
122
+ }
123
+ return [...out];
124
+ }
125
+
126
+ function normalizeMessages(messages) {
127
+ return messages
128
+ .filter(m => m && typeof m === 'object')
129
+ .slice(-MAX_MESSAGES)
130
+ .map((m, index) => ({
131
+ id: String(m.id || m.messageId || `dream_msg_${index}`),
132
+ role: String(m.role || m.type || 'unknown'),
133
+ vpId: typeof m.vpId === 'string' ? m.vpId : '',
134
+ body: String(m.body || m.content || '').slice(0, MAX_BODY_CHARS),
135
+ kind: String(m.kind || ''),
136
+ }))
137
+ .filter(m => m.body.trim());
138
+ }
139
+
140
+ function renderMessages(messages) {
141
+ return JSON.stringify(messages.map(m => ({
142
+ id: m.id,
143
+ role: m.role,
144
+ vpId: m.vpId || undefined,
145
+ kind: m.kind || undefined,
146
+ body: m.body,
147
+ })), null, 2);
148
+ }
149
+
150
+ function normalizeExtractedSegment({ item, scope, now }) {
151
+ if (!item || typeof item !== 'object') return null;
152
+ const body = String(item.body || item.content || item.summary || '').trim();
153
+ if (!body) return null;
154
+ const kind = VALID_KINDS.has(String(item.kind || '')) ? String(item.kind) : 'context';
155
+ const tags = Array.isArray(item.tags) ? item.tags.map(t => String(t).trim()).filter(Boolean) : [];
156
+ const sourceMessages = Array.isArray(item.sourceMessages)
157
+ ? item.sourceMessages.map(id => String(id).trim()).filter(Boolean)
158
+ : [];
159
+ return makeSegment({
160
+ scope,
161
+ kind,
162
+ tags: [...new Set(tags)],
163
+ sourceMessages: [...new Set(sourceMessages)],
164
+ createdAt: typeof item.createdAt === 'string' ? item.createdAt : now,
165
+ updatedAt: now,
166
+ body,
167
+ });
168
+ }
169
+
170
+ function buildRecentSegment({ scope, messages, now }) {
171
+ const recentMessages = messages.slice(-RECENT_MESSAGE_COUNT);
172
+ const body = [
173
+ 'Recent session details from the latest Dream pass:',
174
+ ...recentMessages.map(m => `- ${m.id} ${m.role}${m.vpId ? `/${m.vpId}` : ''}: ${oneLine(m.body)}`),
175
+ ].join('\n');
176
+ return makeSegment({
177
+ scope,
178
+ kind: 'context',
179
+ tags: ['recent', 'current'],
180
+ sourceMessages: recentMessages.map(m => m.id),
181
+ createdAt: now,
182
+ updatedAt: now,
183
+ body,
184
+ });
185
+ }
186
+
187
+ function mergeSegments(existing, incoming) {
188
+ const incomingRecent = incoming.filter(isRecentSegment).slice(-1);
189
+ const incomingPermanent = incoming.filter(seg => !isRecentSegment(seg));
190
+ const byKey = new Map();
191
+
192
+ for (const seg of existing) {
193
+ if (isRecentSegment(seg)) continue;
194
+ byKey.set(segmentMergeKey(seg), seg);
195
+ }
196
+ for (const seg of incomingPermanent) {
197
+ byKey.set(segmentMergeKey(seg), seg);
198
+ }
199
+
200
+ const permanent = [...byKey.values()]
201
+ .sort((a, b) => segmentTime(b).localeCompare(segmentTime(a)))
202
+ .slice(0, MAX_SEGMENTS_PER_SCOPE)
203
+ .sort((a, b) => segmentTime(a).localeCompare(segmentTime(b)) || String(a.id).localeCompare(String(b.id)));
204
+
205
+ return [...permanent, ...incomingRecent];
206
+ }
207
+
208
+ function segmentMergeKey(seg) {
209
+ const sources = Array.isArray(seg.sourceMessages)
210
+ ? seg.sourceMessages.map(String).filter(Boolean).sort().join(',')
211
+ : '';
212
+ const tagFamily = Array.isArray(seg.tags)
213
+ ? seg.tags.map(String).filter(t => t && t !== 'recent' && t !== 'current').sort().join(',')
214
+ : '';
215
+ if (sources) return `src:${seg.kind || 'context'}:${tagFamily}:${sources}`;
216
+ return `id:${seg.id}`;
217
+ }
218
+
219
+ function isRecentSegment(seg) {
220
+ return Array.isArray(seg.tags) && seg.tags.includes('recent');
221
+ }
222
+
223
+ function segmentTime(seg) {
224
+ return String(seg.updatedAt || seg.createdAt || '');
225
+ }
226
+
227
+ function rawSnippet(raw) {
228
+ return String(raw || '').replace(/\s+/g, ' ').trim().slice(0, 500);
229
+ }
230
+
231
+ function oneLine(text) {
232
+ return String(text || '').replace(/\s+/g, ' ').trim().slice(0, 280);
233
+ }
234
+
235
+ function extractSystem(language) {
236
+ return String(language || '').toLowerCase().startsWith('zh')
237
+ ? '你是 Yeaft Dream 记忆抽取器。只输出严格 JSON 数组,不要 Markdown。保留具体事实、决策、偏好、当前状态和证据 message id。'
238
+ : 'You are the Yeaft Dream memory extractor. Return only a strict JSON array, no Markdown. Preserve concrete facts, decisions, preferences, current status, and evidence message ids.';
239
+ }
@@ -61,10 +61,26 @@ export function buildRunDreamOpts(session, onProgress) {
61
61
  // Legacy disk fallback for pre-session transcript directories. New writes and
62
62
  // Dream's primary source use `sessions/<sessionId>/conversation`.
63
63
  const legacySessionConversationsRoot = join(yeaftDir, 'groups');
64
+ const loadSessionDiff = async (sessionId, sinceId) => {
65
+ try {
66
+ const messages = loadSessionConversationMessages([sessionConversationsRoot, legacySessionConversationsRoot], sessionId);
67
+ const out = [];
68
+ let started = !sinceId;
69
+ for (const m of messages) {
70
+ if (!started) {
71
+ if (m.id === sinceId) started = true;
72
+ continue;
73
+ }
74
+ out.push(translateSessionConversationMessage(m));
75
+ }
76
+ return out;
77
+ } catch { return []; }
78
+ };
64
79
 
65
80
  return {
66
81
  root: memoryRoot,
67
82
  language: session.config?.language || 'en',
83
+ segmentIndex: session.memoryIndex || null,
68
84
  llm: makeLlm(session),
69
85
  listSessions: async () => {
70
86
  try { return listConversationSessions([sessionConversationsRoot, legacySessionConversationsRoot]); }
@@ -74,21 +90,9 @@ export function buildRunDreamOpts(session, onProgress) {
74
90
  try { return loadSessionConversationMessages([sessionConversationsRoot, legacySessionConversationsRoot], sessionId).length; }
75
91
  catch { return 0; }
76
92
  },
77
- loadGroupDiff: async (sessionId, sinceId) => {
78
- try {
79
- const messages = loadSessionConversationMessages([sessionConversationsRoot, legacySessionConversationsRoot], sessionId);
80
- const out = [];
81
- let started = !sinceId;
82
- for (const m of messages) {
83
- if (!started) {
84
- if (m.id === sinceId) started = true;
85
- continue;
86
- }
87
- out.push(translateSessionConversationMessage(m));
88
- }
89
- return out;
90
- } catch { return []; }
91
- },
93
+ loadSessionDiff,
94
+ /** Legacy alias for older Dream runner callers; primary name is loadSessionDiff. */
95
+ loadGroupDiff: loadSessionDiff,
92
96
  loadOverlapPreamble: async (sessionId, beforeId, n) => {
93
97
  try {
94
98
  const messages = loadSessionConversationMessages([sessionConversationsRoot, legacySessionConversationsRoot], sessionId);
package/yeaft/engine.js CHANGED
@@ -18,7 +18,8 @@
18
18
  */
19
19
 
20
20
  import { randomUUID } from 'crypto';
21
- import { resolve as resolvePath } from 'path';
21
+ import { promises as fsp } from 'fs';
22
+ import { join, resolve as resolvePath } from 'path';
22
23
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
23
24
  import { LLMContextError, LLMAbortError } from './llm/adapter.js';
24
25
  import { runMemoryPreflow, buildRelevantScopes } from './sessions/pre-flow.js';
@@ -596,6 +597,14 @@ export class Engine {
596
597
  return { user: user || '', session: session || '', vp: vp || '' };
597
598
  }
598
599
 
600
+ async #loadSessionTopicLabels(sessionId, limit = 8) {
601
+ if (!this.#yeaftDir || !sessionId) return [];
602
+ const topicRoot = join(this.#yeaftDir, 'memory', 'sessions', sessionId, 'topic');
603
+ const labels = [];
604
+ await collectTopicLabels(topicRoot, '', labels, limit).catch(() => {});
605
+ return labels;
606
+ }
607
+
599
608
  /**
600
609
  * Prepare the per-turn AMS for the active group. Idempotent and safe
601
610
  * to call when the AMS registry isn't wired (returns null).
@@ -1338,7 +1347,7 @@ export class Engine {
1338
1347
  * string-prompt shape (no regression for existing callers).
1339
1348
  * @yields {EngineEvent}
1340
1349
  */
1341
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
1350
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
1342
1351
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1343
1352
  yield {
1344
1353
  type: 'error',
@@ -1401,7 +1410,7 @@ export class Engine {
1401
1410
 
1402
1411
  try {
1403
1412
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
1404
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, threadId: this.#currentThreadId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy });
1413
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, threadId: this.#currentThreadId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy });
1405
1414
  } finally {
1406
1415
  if (signal) {
1407
1416
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -1421,7 +1430,7 @@ export class Engine {
1421
1430
  * in a try/finally without indenting the whole loop.
1422
1431
  * @private
1423
1432
  */
1424
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null }) {
1433
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null }) {
1425
1434
 
1426
1435
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
1427
1436
  ? collabToolPolicy
@@ -1513,10 +1522,14 @@ export class Engine {
1513
1522
  // Structured per-turn scope summary: session + vp + members + envelope routing
1514
1523
  // info. Long-form scope content lives in AMS — this block carries
1515
1524
  // only IDs + tiny labels. (Feature scope retired 2026-05-13.)
1525
+ const activeSessionTopics = Array.isArray(sessionTopics)
1526
+ ? sessionTopics
1527
+ : await this.#loadSessionTopicLabels(sessionId);
1516
1528
  const activeScope = {
1517
1529
  sessionId: sessionId || '',
1518
- vpId: ownVpIdForAms || '',
1519
- members: Array.isArray(sessionMembers) ? sessionMembers : [],
1530
+ sessionMember: ownVpIdForAms || '',
1531
+ sessionMembers: Array.isArray(sessionMembers) ? sessionMembers : [],
1532
+ sessionTopics: activeSessionTopics,
1520
1533
  envelope: inboundEnvelope || null,
1521
1534
  };
1522
1535
 
@@ -2907,3 +2920,22 @@ export class Engine {
2907
2920
  }
2908
2921
  }
2909
2922
  }
2923
+
2924
+ async function collectTopicLabels(dir, prefix, labels, limit) {
2925
+ if (labels.length >= limit) return;
2926
+ let entries;
2927
+ try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
2928
+ const hasMemory = entries.some(entry => entry.isFile() && entry.name === 'memory.md');
2929
+ const hasSummary = entries.some(entry => entry.isFile() && entry.name === 'summary.md');
2930
+ if (prefix && (hasMemory || hasSummary)) labels.push(prefix);
2931
+ if (labels.length >= limit) return;
2932
+ const dirs = entries
2933
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
2934
+ .map(entry => entry.name)
2935
+ .sort();
2936
+ for (const name of dirs) {
2937
+ const nextPrefix = prefix ? `${prefix}/${name}` : name;
2938
+ await collectTopicLabels(join(dir, name), nextPrefix, labels, limit);
2939
+ if (labels.length >= limit) return;
2940
+ }
2941
+ }
@@ -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-]+)?))?|chat\/[\w-]+(?:\/vp\/[\w-]+)?|session\/[\w-]+(?:\/vp\/[\w-]+)?)$/;
54
+ const SCOPE_RE = /^(user|group\/[\w-]+(?:\/(?:user|vp\/[\w-]+|feature\/[\w-]+|topic\/[\w-]+(?:\/[\w-]+)?))?|sessions\/[\w-]+(?:\/(?:user|vp\/[\w-]+|feature\/[\w-]+|topic\/[\w-]+(?:\/[\w-]+)?))?|chat\/[\w-]+(?:\/vp\/[\w-]+)?|session\/[\w-]+(?:\/vp\/[\w-]+)?)$/;
55
55
 
56
56
  /**
57
57
  * Compute a stable id from segment content. Same body + scope + kind →
package/yeaft/prompts.js CHANGED
@@ -272,8 +272,9 @@ export function normalizePromptLanguage(language) {
272
272
  * Active Scope params (DESIGN-PROMPT §3 ④):
273
273
  * @param {object} [activeScope] — structured scope summary for this turn
274
274
  * @param {string} [activeScope.sessionId]
275
- * @param {string} [activeScope.vpId]
276
- * @param {string[]} [activeScope.members] current session roster
275
+ * @param {string} [activeScope.sessionMember]
276
+ * @param {string[]} [activeScope.sessionMembers] current session roster
277
+ * @param {string[]} [activeScope.sessionTopics] bounded topic labels for this session
277
278
  * @param {object} [activeScope.envelope] inbound routing info (sender, intent)
278
279
  *
279
280
  * @param {{
@@ -571,10 +572,11 @@ const OMNI_PERSONA_ZH = `你是全能助手,一个跨领域、偏执行的通
571
572
  *
572
573
  * Schema:
573
574
  * ## active_scope
574
- * session: <sessionId> (omitted when missing)
575
- * vp: <vpId> (omitted when missing)
576
- * members: <vpId>, <vpId> (omitted when missing)
577
- * envelope: from=<sender> intent=<intent> (omitted when no envelope)
575
+ * session_id: <sessionId> (omitted when missing)
576
+ * session_member: <vpId> (omitted when missing)
577
+ * session_members: <vpId>, <vpId> (omitted when missing)
578
+ * session_topics: <topic>, <topic> (omitted when missing)
579
+ * envelope: from=<sender> intent=<intent> (omitted when no envelope)
578
580
  *
579
581
  * Returns '' when the input has no useful field — we don't emit an empty
580
582
  * header. (`featureId`/`featureTitle` fields were removed 2026-05-13 along
@@ -582,8 +584,9 @@ const OMNI_PERSONA_ZH = `你是全能助手,一个跨领域、偏执行的通
582
584
  *
583
585
  * @param {object} [activeScope]
584
586
  * @param {string} [activeScope.sessionId]
585
- * @param {string} [activeScope.vpId]
586
- * @param {string[]} [activeScope.members] current session roster
587
+ * @param {string} [activeScope.sessionMember]
588
+ * @param {string[]} [activeScope.sessionMembers] current session roster
589
+ * @param {string[]} [activeScope.sessionTopics] bounded topic labels
587
590
  * @param {object} [activeScope.envelope] inbound routing summary
588
591
  * @param {object} lang
589
592
  * @returns {string}
@@ -595,15 +598,16 @@ function renderActiveScope(activeScope, lang) {
595
598
  const session = typeof activeScope.sessionId === 'string' && activeScope.sessionId.trim()
596
599
  ? activeScope.sessionId.trim()
597
600
  : '';
598
- if (session) lines.push(`session: ${session}`);
601
+ if (session) lines.push(`session_id: ${session}`);
599
602
 
600
- const vp = typeof activeScope.vpId === 'string' && activeScope.vpId.trim()
601
- ? activeScope.vpId.trim()
602
- : '';
603
- if (vp) lines.push(`vp: ${vp}`);
603
+ const sessionMember = firstNonEmptyString(activeScope.sessionMember, activeScope.vpId);
604
+ if (sessionMember) lines.push(`session_member: ${sessionMember}`);
605
+
606
+ const membersLine = renderSessionMembersLine(activeScope.sessionMembers || activeScope.members);
607
+ if (membersLine) lines.push(`session_members: ${membersLine}`);
604
608
 
605
- const membersLine = renderSessionMembersLine(activeScope.members);
606
- if (membersLine) lines.push(`members: ${membersLine}`);
609
+ const topicsLine = renderSessionMembersLine(activeScope.sessionTopics);
610
+ if (topicsLine) lines.push(`session_topics: ${topicsLine}`);
607
611
 
608
612
  const envLine = renderEnvelopeLine(activeScope.envelope);
609
613
  if (envLine) lines.push(`envelope: ${envLine}`);
@@ -614,6 +618,13 @@ function renderActiveScope(activeScope, lang) {
614
618
  }
615
619
 
616
620
 
621
+ function firstNonEmptyString(...values) {
622
+ for (const value of values) {
623
+ if (typeof value === 'string' && value.trim()) return value.trim();
624
+ }
625
+ return '';
626
+ }
627
+
617
628
  function renderSessionMembersLine(members) {
618
629
  if (!Array.isArray(members)) return '';
619
630
  const clean = [];