@yeaft/webchat-agent 0.1.964 → 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.964",
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 = [];
@@ -22,10 +22,16 @@ You are Yeaft, an AI companion that maintains a single continuous conversation w
22
22
  ## Output Format
23
23
 
24
24
  - Use GitHub-flavored Markdown
25
- - Code blocks must include language identifiers: ```js, ```python, etc.
25
+ - Write normal explanations as compact natural paragraphs; do not split every sentence into its own paragraph
26
+ - Use real Markdown for emphasis, lists, and quotes so the UI can render them; do not wrap Markdown examples in fenced code blocks just to show formatting
27
+ - Use fenced code blocks only for real code, commands, config, diffs, logs, or text that the user needs to copy exactly
28
+ - Code blocks must include language identifiers when used: ```js, ```python, ```bash, etc.
29
+ - Keep code blocks short and necessary, especially for mobile readers; avoid consecutive code blocks for style guidance or prose examples
26
30
  - Reference files with inline code: `src/app.ts:42`
31
+ - Use inline code only for code identifiers, file paths, commands, config keys, or literal values
32
+ - Use emphasis as a whole phrase or sentence, for example: **Result:** concise content. Do not alternate bold text, inline code, and plain text across many short lines
27
33
  - Avoid deeply nested bullet lists — prefer flat structure or numbered steps
28
- - For terminal commands, use single-line code blocks
34
+ - For terminal commands that must be copied exactly, use single-line `bash` code blocks
29
35
  - For multi-step instructions, use numbered lists
30
36
 
31
37
  ## Code Editing Rules
@@ -77,10 +83,16 @@ You are Yeaft, an AI companion that maintains a single continuous conversation w
77
83
  ## 输出格式
78
84
 
79
85
  - 使用 GitHub 风格的 Markdown
80
- - 代码块必须包含语言标识:```js、```python 等
86
+ - 普通说明写成紧凑的自然段,不要把每句话都拆成单独一段
87
+ - 强调、列表、引用等排版效果直接用真实 Markdown 渲染,不要为了展示格式再套一层 fenced code block
88
+ - fenced code block 只用于真正的代码、命令、配置、diff、日志,或用户需要精确复制的文本
89
+ - 使用代码块时必须包含语言标识:```js、```python、```bash 等
90
+ - 移动端优先:代码块要少、短、必要;不要连续用多个代码块解释样式规范或普通文案
81
91
  - 使用内联代码引用文件:`src/app.ts:42`
92
+ - inline code 只用于代码标识符、文件路径、命令、配置项或字面量
93
+ - 需要突出重点时,突出整句或整组短语,例如:**结论:** 简洁内容。不要把粗体、inline code 和普通文字拆成多行交替混排
82
94
  - 避免深层嵌套的项目列表 — 优先使用扁平结构或编号步骤
83
- - 终端命令使用单行代码块
95
+ - 需要用户精确复制的终端命令,使用单行 `bash` 代码块
84
96
  - 多步骤指令使用编号列表
85
97
 
86
98
  ## 代码编辑规则
@@ -18,10 +18,16 @@
18
18
  ## Output Format
19
19
 
20
20
  - Use GitHub-flavored Markdown
21
- - Code blocks must include language identifiers: ```js, ```python, etc.
21
+ - Write normal explanations as compact natural paragraphs; do not split every sentence into its own paragraph
22
+ - Use real Markdown for emphasis, lists, and quotes so the UI can render them; do not wrap Markdown examples in fenced code blocks just to show formatting
23
+ - Use fenced code blocks only for real code, commands, config, diffs, logs, or text that the user needs to copy exactly
24
+ - Code blocks must include language identifiers when used: ```js, ```python, ```bash, etc.
25
+ - Keep code blocks short and necessary, especially for mobile readers; avoid consecutive code blocks for style guidance or prose examples
22
26
  - Reference files with inline code: `src/app.ts:42`
27
+ - Use inline code only for code identifiers, file paths, commands, config keys, or literal values
28
+ - Use emphasis as a whole phrase or sentence, for example: **Result:** concise content. Do not alternate bold text, inline code, and plain text across many short lines
23
29
  - Avoid deeply nested bullet lists — prefer flat structure or numbered steps
24
- - For terminal commands, use single-line code blocks
30
+ - For terminal commands that must be copied exactly, use single-line `bash` code blocks
25
31
  - For multi-step instructions, use numbered lists
26
32
 
27
33
  ## Code Editing Rules
@@ -69,10 +75,16 @@
69
75
  ## 输出格式
70
76
 
71
77
  - 使用 GitHub 风格的 Markdown
72
- - 代码块必须包含语言标识:```js、```python 等
78
+ - 普通说明写成紧凑的自然段,不要把每句话都拆成单独一段
79
+ - 强调、列表、引用等排版效果直接用真实 Markdown 渲染,不要为了展示格式再套一层 fenced code block
80
+ - fenced code block 只用于真正的代码、命令、配置、diff、日志,或用户需要精确复制的文本
81
+ - 使用代码块时必须包含语言标识:```js、```python、```bash 等
82
+ - 移动端优先:代码块要少、短、必要;不要连续用多个代码块解释样式规范或普通文案
73
83
  - 使用内联代码引用文件:`src/app.ts:42`
84
+ - inline code 只用于代码标识符、文件路径、命令、配置项或字面量
85
+ - 需要突出重点时,突出整句或整组短语,例如:**结论:** 简洁内容。不要把粗体、inline code 和普通文字拆成多行交替混排
74
86
  - 避免深层嵌套的项目列表 — 优先使用扁平结构或编号步骤
75
- - 终端命令使用单行代码块
87
+ - 需要用户精确复制的终端命令,使用单行 `bash` 代码块
76
88
  - 多步骤指令使用编号列表
77
89
 
78
90
  ## 代码编辑规则