@yeaft/webchat-agent 1.0.348 → 1.0.350

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.
Files changed (41) hide show
  1. package/local-runtime/version.json +1 -1
  2. package/local-runtime/web/app.bundle.js +73 -73
  3. package/local-runtime/web/app.bundle.js.gz +0 -0
  4. package/local-runtime/web/index.html +1 -1
  5. package/package.json +1 -1
  6. package/yeaft/conversation/persist.js +4 -0
  7. package/yeaft/dream/apply.js +59 -30
  8. package/yeaft/dream/output-snapshot.js +8 -4
  9. package/yeaft/dream/prompts/consolidate-topics.md +31 -0
  10. package/yeaft/dream/prompts/create.md +8 -8
  11. package/yeaft/dream/prompts/index.js +4 -2
  12. package/yeaft/dream/prompts/merge-topics.md +35 -0
  13. package/yeaft/dream/prompts/triage-pass1.md +2 -2
  14. package/yeaft/dream/prompts/triage-pass2.md +4 -2
  15. package/yeaft/dream/prompts/update.md +16 -14
  16. package/yeaft/dream/runner.js +69 -13
  17. package/yeaft/dream/segment-extract.js +16 -13
  18. package/yeaft/dream/session-wiring.js +2 -2
  19. package/yeaft/dream/snapshot.js +3 -3
  20. package/yeaft/dream/topic-consolidation.js +316 -0
  21. package/yeaft/dream/triage.js +7 -2
  22. package/yeaft/engine.js +237 -239
  23. package/yeaft/memory/ams-registry.js +42 -61
  24. package/yeaft/memory/ams.js +17 -9
  25. package/yeaft/memory/budget.js +15 -18
  26. package/yeaft/memory/content-backfill.js +118 -0
  27. package/yeaft/memory/index-db.js +10 -3
  28. package/yeaft/memory/keywords.js +25 -7
  29. package/yeaft/memory/preflow.js +26 -9
  30. package/yeaft/memory/segment-store.js +44 -8
  31. package/yeaft/memory/segment-sync.js +8 -4
  32. package/yeaft/memory/segment.js +10 -3
  33. package/yeaft/memory/store.js +88 -38
  34. package/yeaft/memory/summary-store.js +3 -3
  35. package/yeaft/memory/topic-redirect.js +28 -0
  36. package/yeaft/router/continuity.js +12 -3
  37. package/yeaft/session.js +18 -19
  38. package/yeaft/sessions/pre-flow.js +7 -3
  39. package/yeaft/sub-agent/runner.js +10 -1
  40. package/yeaft/work-center/bridge.js +1 -0
  41. package/yeaft/work-center/runner.js +33 -4
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * mergeByTarget() → per-target actions
19
19
  * for each merged target:
20
- * applyMergedTarget() (snapshot + UPDATE/CREATE + atomic write)
20
+ * applyMergedTarget() (snapshot + canonical content UPDATE/CREATE)
21
21
  *
22
22
  * bookkeep:
23
23
  * for each processed session:
@@ -41,7 +41,7 @@
41
41
  import { existsSync } from 'fs';
42
42
  import { join } from 'path';
43
43
 
44
- import { listScopes, readSummary } from '../memory/store.js';
44
+ import { listScopes, readContent, readSummary } from '../memory/store.js';
45
45
  import {
46
46
  DEFAULT_LIMITS,
47
47
  } from './limits.js';
@@ -51,6 +51,7 @@ import { triageGroupSegments } from './triage.js';
51
51
  import { mergeByTarget } from './merge.js';
52
52
  import { applyMergedTarget } from './apply.js';
53
53
  import { extractAndWriteMemorySegments } from './segment-extract.js';
54
+ import { consolidateSessionTopics } from './topic-consolidation.js';
54
55
  import { tsForBackup, pruneOldSnapshots } from './snapshot.js';
55
56
 
56
57
  /**
@@ -64,7 +65,7 @@ import { tsForBackup, pruneOldSnapshots } from './snapshot.js';
64
65
  * @property {(sessionId: string, sinceMessageId: string|null) => Promise<Array<object>>} [loadSessionDiff]
65
66
  * @property {(sessionId: string, sinceMessageId: string|null) => Promise<Array<object>>} [loadGroupDiff] — legacy alias for loadSessionDiff
66
67
  * @property {(sessionId: string, beforeMessageId: string|null, count: number) => Promise<Array<object>>} loadOverlapPreamble
67
- * @property {() => Promise<Array<{path:string, summary:string}>>} [listTopicSummaries]
68
+ * @property {(sessionId:string) => Promise<Array<{path:string, summary:string}>>} [listTopicSummaries]
68
69
  * @property {(target: string) => Promise<Array<{path:string, summary:string}>>} [siblingTopicsFor]
69
70
  * @property {import('../memory/index-db.js').SegmentIndex|null} [segmentIndex] — optional derived FTS segment index to sync after segment writes
70
71
  * @property {(event: object) => void} [onProgress]
@@ -98,8 +99,8 @@ export async function runDream(opts) {
98
99
  const processedSessions = [];
99
100
 
100
101
  // 2. per-session: skip / segment / triage
101
- // Topic summaries are resolved inside the per-session loop
102
- // them inside the per-session loop instead of once up front.
102
+ // Topic summaries are resolved inside the per-session loop so every Session
103
+ // sees its complete current canonical catalog.
103
104
  const resolveTopicSummaries = async (sessionId) => {
104
105
  if (opts.listTopicSummaries) {
105
106
  return await safeCall(() => opts.listTopicSummaries(sessionId), []);
@@ -128,7 +129,25 @@ export async function runDream(opts) {
128
129
  && beforeCount > 0;
129
130
 
130
131
  if (newCount === 0 && !rerunScopedManual) {
131
- sessionsReport.push({ sessionId, new: 0, status: 'skipped', reason: 'no-new-messages' });
132
+ const topics = await resolveTopicSummaries(sessionId);
133
+ if (opts.manual && topics.length >= 2) {
134
+ try {
135
+ const result = await consolidateSessionTopics({
136
+ root: opts.root,
137
+ sessionId,
138
+ topics,
139
+ llm: dreamLlmForSession(opts.llm, sessionId),
140
+ language: opts.language,
141
+ ts,
142
+ segmentIndex: opts.segmentIndex || null,
143
+ });
144
+ sessionsReport.push({ sessionId, new: 0, status: 'consolidated', ...result });
145
+ } catch (err) {
146
+ sessionsReport.push({ sessionId, new: 0, status: 'error', phase: 'topic-consolidation', error: err.message });
147
+ }
148
+ } else {
149
+ sessionsReport.push({ sessionId, new: 0, status: 'skipped', reason: 'no-new-messages' });
150
+ }
132
151
  continue;
133
152
  }
134
153
  if (!opts.manual && newCount < limits.MIN_NEW_PER_GROUP) {
@@ -163,6 +182,7 @@ export async function runDream(opts) {
163
182
  try {
164
183
  const topicSummaries = await resolveTopicSummaries(sessionId);
165
184
  actions = await triageGroupSegments({
185
+ root: opts.root,
166
186
  sessionId,
167
187
  segments,
168
188
  topicSummaries,
@@ -213,6 +233,7 @@ export async function runDream(opts) {
213
233
  nowIso: opts.nowIso || (() => nowIso),
214
234
  onProgress,
215
235
  siblingTopicsFor: opts.siblingTopicsFor,
236
+ segmentIndex: opts.segmentIndex || null,
216
237
  language: opts.language,
217
238
  });
218
239
  await clearDreamError(opts.root, merged.target);
@@ -238,9 +259,10 @@ export async function runDream(opts) {
238
259
  }
239
260
  }
240
261
 
241
- // 5. extract atomic H2 memory segments. The apply step above keeps the
242
- // coarse summary layer (`summary.md`); this step keeps bounded, evidence-
243
- // backed current details in segment-formatted `memory.md`.
262
+ // 5. Extract atomic H2 evidence segments. Apply owns canonical `content.md`
263
+ // plus the short catalog `summary.md`; this step owns only provenance-backed
264
+ // segment records in `memory.md`, so the two writers never overwrite each
265
+ // other's representation.
244
266
  const segmentReports = [];
245
267
  for (const triage of sessionTriages) {
246
268
  const appliedTargets = new Set(targetsReport.filter(r => r.status === 'done').map(r => r.target));
@@ -272,7 +294,37 @@ export async function runDream(opts) {
272
294
  }
273
295
  }
274
296
 
275
- // 6. bookkeep only when at least one apply for this session's actions
297
+ // 6. Consolidate the complete topic catalog after new content and evidence
298
+ // have landed. This is the only stage allowed to retire duplicate scopes.
299
+ const topicConsolidation = [];
300
+ const consolidatedSessionIds = [...new Set(sessionTriages.map(triage => triage.sessionId))];
301
+ for (const sessionId of consolidatedSessionIds) {
302
+ try {
303
+ const topics = await resolveTopicSummaries(sessionId);
304
+ onProgress({ phase: 'topic-consolidation', sessionId, status: 'running', topics: topics.length });
305
+ const result = await consolidateSessionTopics({
306
+ root: opts.root,
307
+ sessionId,
308
+ topics,
309
+ llm: dreamLlmForSession(opts.llm, sessionId),
310
+ language: opts.language,
311
+ ts,
312
+ segmentIndex: opts.segmentIndex || null,
313
+ });
314
+ topicConsolidation.push({ sessionId, status: 'done', ...result });
315
+ onProgress({ phase: 'topic-consolidation', sessionId, status: 'done', ...result });
316
+ } catch (err) {
317
+ topicConsolidation.push({ sessionId, status: 'error', error: err.message });
318
+ onProgress({ phase: 'topic-consolidation', sessionId, status: 'error', error: err.message });
319
+ await writeDreamError(opts.root, `sessions/${sessionId}`, {
320
+ phase: 'topic-consolidation',
321
+ message: err.message,
322
+ stack: err.stack,
323
+ });
324
+ }
325
+ }
326
+
327
+ // 7. bookkeep — only when at least one apply for this session's actions
276
328
  // succeeded. We use a permissive policy: if ANY merged-target apply
277
329
  // succeeded for a session's contributed actions, advance that session's
278
330
  // cursor. (If everything errored, we keep the cursor so next run
@@ -292,7 +344,7 @@ export async function runDream(opts) {
292
344
  }
293
345
  }
294
346
 
295
- // 6. prune backups
347
+ // 8. prune backups
296
348
  const pruned = await pruneOldSnapshots(opts.root, limits.DREAM_BACKUP_KEEP);
297
349
 
298
350
  const duration = Date.now() - startedAt;
@@ -311,6 +363,7 @@ export async function runDream(opts) {
311
363
  sessions: sessionsReport,
312
364
  targets: targetsReport,
313
365
  memorySegments: segmentReports,
366
+ topicConsolidation,
314
367
  backups: pruned,
315
368
  ts,
316
369
  };
@@ -360,10 +413,13 @@ async function defaultListTopicSummaries(root, sessionId, language) {
360
413
  const all = await listScopes({ root });
361
414
  const out = [];
362
415
  for (const sc of all) {
363
- if (sc.kind !== 'group-topic') continue;
416
+ if (sc.kind !== 'session-topic') continue;
364
417
  if (sc.sessionId !== sessionId) continue;
365
418
  const summary = await readSummary(sc, { root, language });
366
- out.push({ path: sc.path.join('/'), summary });
419
+ const content = summary ? '' : await readContent(sc, { root });
420
+ const catalogText = String(summary || content || '').trim();
421
+ if (!catalogText) continue;
422
+ out.push({ path: sc.path.join('/'), summary: catalogText });
367
423
  }
368
424
  return out;
369
425
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * dream/segment-extract.js.
3
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.
4
+ * Extract atomic evidence segments for scopes touched by a Dream pass and
5
+ * persist them through memory/segment-store. Dream Apply separately maintains
6
+ * canonical content.md and the short catalog summary.md.
7
7
  */
8
8
 
9
9
  import { readScope, writeScope } from '../memory/segment-store.js';
@@ -12,7 +12,6 @@ import { makeSegment } from '../memory/segment.js';
12
12
  import { render, extractTemplateForScope } from './prompts/index.js';
13
13
  import { parseJsonSafe } from './triage.js';
14
14
 
15
- const MAX_TARGETS = 24;
16
15
  const MAX_MESSAGES = 80;
17
16
  const MAX_BODY_CHARS = 1200;
18
17
  const MAX_SEGMENTS_PER_SCOPE = 64;
@@ -50,6 +49,7 @@ export async function extractAndWriteMemorySegments(opts) {
50
49
 
51
50
  const messages = normalizeMessages(opts.messages || []);
52
51
  if (messages.length === 0) return { scopes: 0, segments: 0, errors: [] };
52
+ const allowedSourceIds = new Set(messages.map(message => message.id));
53
53
 
54
54
  const targetScopes = normalizeTargetScopes(opts.sessionId, opts.targets || []);
55
55
  const now = opts.nowIso ? opts.nowIso() : new Date().toISOString();
@@ -57,7 +57,7 @@ export async function extractAndWriteMemorySegments(opts) {
57
57
  let scopeCount = 0;
58
58
  const errors = [];
59
59
 
60
- for (const scope of targetScopes.slice(0, MAX_TARGETS)) {
60
+ for (const scope of targetScopes) {
61
61
  let extracted = [];
62
62
  try {
63
63
  extracted = await extractScopeSegments({
@@ -67,6 +67,7 @@ export async function extractAndWriteMemorySegments(opts) {
67
67
  llm: opts.llm,
68
68
  language: opts.language,
69
69
  now,
70
+ allowedSourceIds,
70
71
  });
71
72
  } catch (err) {
72
73
  errors.push({ scope, error: err.message, rawSnippet: err.rawSnippet || '' });
@@ -90,7 +91,7 @@ export async function extractAndWriteMemorySegments(opts) {
90
91
  return { scopes: scopeCount, segments: segmentCount, errors };
91
92
  }
92
93
 
93
- async function extractScopeSegments({ scope, sessionId, messages, llm, language, now }) {
94
+ async function extractScopeSegments({ scope, sessionId, messages, llm, language, now, allowedSourceIds }) {
94
95
  const template = extractTemplateForScope(scope);
95
96
  const base = render(template, templateVarsForScope(scope, sessionId), { language });
96
97
  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.`;
@@ -98,7 +99,7 @@ async function extractScopeSegments({ scope, sessionId, messages, llm, language,
98
99
  const firstParsed = parseJsonSafe(firstRaw);
99
100
  if (Array.isArray(firstParsed)) {
100
101
  return firstParsed
101
- .map(item => normalizeExtractedSegment({ item, scope, now }))
102
+ .map(item => normalizeExtractedSegment({ item, scope, now, allowedSourceIds }))
102
103
  .filter(Boolean);
103
104
  }
104
105
 
@@ -112,7 +113,7 @@ async function extractScopeSegments({ scope, sessionId, messages, llm, language,
112
113
  }
113
114
 
114
115
  return retryParsed
115
- .map(item => normalizeExtractedSegment({ item, scope, now }))
116
+ .map(item => normalizeExtractedSegment({ item, scope, now, allowedSourceIds }))
116
117
  .filter(Boolean);
117
118
  }
118
119
 
@@ -142,14 +143,14 @@ function normalizeMessages(messages) {
142
143
  return messages
143
144
  .filter(m => m && typeof m === 'object')
144
145
  .slice(-MAX_MESSAGES)
145
- .map((m, index) => ({
146
- id: String(m.id || m.messageId || `dream_msg_${index}`),
146
+ .map(m => ({
147
+ id: String(m.id || m.messageId || '').trim(),
147
148
  role: String(m.role || m.type || 'unknown'),
148
149
  vpId: typeof m.vpId === 'string' ? m.vpId : '',
149
150
  body: String(m.body || m.content || '').slice(0, MAX_BODY_CHARS),
150
151
  kind: String(m.kind || ''),
151
152
  }))
152
- .filter(m => m.body.trim());
153
+ .filter(m => m.id && m.body.trim());
153
154
  }
154
155
 
155
156
  function renderMessages(messages) {
@@ -162,13 +163,15 @@ function renderMessages(messages) {
162
163
  })), null, 2);
163
164
  }
164
165
 
165
- function normalizeExtractedSegment({ item, scope, now }) {
166
+ function normalizeExtractedSegment({ item, scope, now, allowedSourceIds }) {
166
167
  if (!item || typeof item !== 'object') return null;
167
168
  const body = String(item.body || item.content || item.summary || '').trim();
168
169
  if (!body) return null;
169
170
  const kind = VALID_KINDS.has(String(item.kind || '')) ? String(item.kind) : 'context';
170
171
  const tags = Array.isArray(item.tags) ? item.tags.map(t => String(t).trim()).filter(Boolean) : [];
171
- const sourceMessages = normalizeSourceMessageIds(item.sourceMessages);
172
+ const sourceMessages = [...new Set(normalizeSourceMessageIds(item.sourceMessages))];
173
+ if (sourceMessages.length === 0) return null;
174
+ if (sourceMessages.some(id => !allowedSourceIds.has(id))) return null;
172
175
  return makeSegment({
173
176
  scope,
174
177
  kind,
@@ -32,8 +32,8 @@
32
32
  * targets, error, skipped, skippedReason } }
33
33
  * dream_progress: runner-emitted phase events (`start`/`load-diff`/`triage`/
34
34
  * `merge`/`apply`/`done`). The `apply/done` variant carries
35
- * `kind, memoryMdPreview, summaryMdPreview, memoryMdLength,
36
- * summaryMdLength` (see apply.js).
35
+ * `kind, contentMdPreview, summaryMdPreview, contentMdLength,
36
+ * summaryMdLength` plus legacy memoryMd* debug aliases (see apply.js).
37
37
  *
38
38
  * `sessionId` may be inherited via `stampDreamScope()` when a scope is active.
39
39
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dream/snapshot.js.
3
3
  *
4
- * Pre-Apply backup of memory.md + summary.md to
4
+ * Pre-Apply backup of content.md + memory.md + summary.md to
5
5
  * `~/.yeaft/memory/.dream-bak/<ts>/<scope-path>/`. The runner takes a
6
6
  * snapshot once per merged target before Apply mutates it; that snapshot
7
7
  * is the unit of rollback in case of LLM error or write failure.
@@ -29,7 +29,7 @@ export function tsForBackup(d = new Date()) {
29
29
  }
30
30
 
31
31
  /**
32
- * Snapshot a single scope's memory.md + summary.md into
32
+ * Snapshot a single scope's content.md + memory.md + summary.md into
33
33
  * `<root>/.dream-bak/<ts>/<scopeRelDir>/`. Missing source files are
34
34
  * skipped silently; the destination dir is always created so that an
35
35
  * absent snapshot is still distinguishable from "didn't run".
@@ -45,7 +45,7 @@ export async function snapshotScope(root, ts, scopeRelDir) {
45
45
  const dstDir = join(root, BACKUP_DIRNAME, ts, scopeRelDir);
46
46
  await fsp.mkdir(dstDir, { recursive: true });
47
47
  const copied = [];
48
- for (const name of ['memory.md', 'summary.md']) {
48
+ for (const name of ['content.md', 'memory.md', 'summary.md', 'summary.zh.md']) {
49
49
  const s = join(srcDir, name);
50
50
  if (!existsSync(s)) continue;
51
51
  const d = join(dstDir, name);
@@ -0,0 +1,316 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ renameSync,
5
+ rmdirSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+
11
+ import { readContent, readMemory, readSummary } from '../memory/store.js';
12
+ import { readScope, writeScope } from '../memory/segment-store.js';
13
+ import { computeSegmentId } from '../memory/segment.js';
14
+ import { syncScope } from '../memory/segment-sync.js';
15
+ import {
16
+ normalizeTopicPath,
17
+ resolveTopicRedirect,
18
+ TOPIC_REDIRECT_FILE,
19
+ } from '../memory/topic-redirect.js';
20
+ import { render } from './prompts/index.js';
21
+ import { parseJsonSafe } from './triage.js';
22
+ import { snapshotScope } from './snapshot.js';
23
+
24
+ const MAX_TOPICS_PER_BATCH = 240;
25
+ const MAX_CONSOLIDATION_GROUPS_PER_SESSION = 12;
26
+
27
+ function topicConsolidationSystem(language) {
28
+ return String(language || '').toLowerCase().startsWith('zh')
29
+ ? '你是 Dream 记忆主题整理阶段。只回复严格 JSON,不要输出说明文字或 markdown fence。'
30
+ : 'You are the Dream memory topic-consolidation stage. Reply with strict JSON only, without prose or markdown fences.';
31
+ }
32
+
33
+ export async function consolidateSessionTopics(opts) {
34
+ if (!opts?.root || !opts?.sessionId || typeof opts.llm !== 'function') {
35
+ throw new Error('consolidateSessionTopics: root, sessionId, and llm are required');
36
+ }
37
+ const topics = normalizeTopics(normalizeTopics(opts.topics).map(topic => ({
38
+ ...topic,
39
+ path: resolveTopicRedirect(opts.root, opts.sessionId, topic.path),
40
+ })).filter(topic => topic.path));
41
+ if (topics.length < 2) {
42
+ return { considered: topics.length, checked: topics.length, merged: 0, groups: [] };
43
+ }
44
+
45
+ const groups = [];
46
+ const batchStep = topics.length <= MAX_TOPICS_PER_BATCH
47
+ ? MAX_TOPICS_PER_BATCH
48
+ : MAX_TOPICS_PER_BATCH - Math.min(40, MAX_TOPICS_PER_BATCH - 1);
49
+ for (let start = 0; start < topics.length; start += batchStep) {
50
+ const batch = topics.length <= MAX_TOPICS_PER_BATCH
51
+ ? topics
52
+ : buildOverlappingBatch(topics, start, MAX_TOPICS_PER_BATCH);
53
+ if (batch.length < 2) continue;
54
+ const parsed = parseJsonSafe(await opts.llm({
55
+ pass: 'topic-consolidation',
56
+ system: topicConsolidationSystem(opts.language),
57
+ prompt: render('consolidateTopics', {
58
+ topics: batch.map(topic => `- ${topic.path}: ${topic.summary}`).join('\n'),
59
+ }, { language: opts.language }),
60
+ }));
61
+ if (!Array.isArray(parsed?.groups)) continue;
62
+ groups.push(...validateGroups(parsed.groups, topics));
63
+ }
64
+
65
+ const applied = [];
66
+ for (const group of nonOverlappingGroups(groups).slice(0, MAX_CONSOLIDATION_GROUPS_PER_SESSION)) {
67
+ const result = await applyConsolidationGroup(group, opts);
68
+ if (result) applied.push(result);
69
+ }
70
+ return {
71
+ considered: topics.length,
72
+ checked: topics.length,
73
+ merged: applied.reduce((count, group) => count + group.merged.length, 0),
74
+ groups: applied,
75
+ };
76
+ }
77
+
78
+ function normalizeTopics(topics) {
79
+ const byPath = new Map();
80
+ for (const topic of Array.isArray(topics) ? topics : []) {
81
+ const path = normalizeTopicPath(topic?.path);
82
+ if (!path || byPath.has(path)) continue;
83
+ byPath.set(path, { path, summary: String(topic?.summary || '').trim() });
84
+ }
85
+ return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
86
+ }
87
+
88
+ function buildOverlappingBatch(topics, start, limit) {
89
+ const batch = topics.slice(start, start + limit);
90
+ if (start === 0 || batch.length >= limit) return batch;
91
+ const overlap = Math.min(40, start, limit - batch.length);
92
+ return [...topics.slice(start - overlap, start), ...batch];
93
+ }
94
+
95
+ function validateGroups(groups, topics) {
96
+ const known = new Set(topics.map(topic => topic.path));
97
+ const out = [];
98
+ for (const group of groups) {
99
+ const canonical = normalizeTopicPath(group?.canonical);
100
+ const members = [...new Set([
101
+ canonical,
102
+ ...(Array.isArray(group?.merge) ? group.merge.map(normalizeTopicPath) : []),
103
+ ].filter(path => known.has(path)))];
104
+ if (!known.has(canonical) || members.length < 2) continue;
105
+ out.push({ canonical, merge: members.filter(path => path !== canonical) });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function nonOverlappingGroups(groups) {
111
+ const claimed = new Set();
112
+ const out = [];
113
+ for (const group of groups) {
114
+ const members = [group.canonical, ...group.merge];
115
+ if (members.some(path => claimed.has(path))) continue;
116
+ members.forEach(path => claimed.add(path));
117
+ out.push(group);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ async function applyConsolidationGroup(group, opts) {
123
+ const records = await Promise.all([group.canonical, ...group.merge].map(path => readTopicRecord(path, opts)));
124
+ const available = records.filter(record => record.content || record.summary || record.memory);
125
+ if (available.length < 2) return null;
126
+
127
+ const canonical = records[0];
128
+ const raw = await opts.llm({
129
+ pass: 'topic-merge',
130
+ system: topicConsolidationSystem(opts.language),
131
+ prompt: render('mergeTopics', {
132
+ canonical: canonical.path,
133
+ topicContents: available.map(record => [
134
+ `## ${record.path}`,
135
+ record.content || record.memory || record.summary,
136
+ ].join('\n')).join('\n\n'),
137
+ }, { language: opts.language }),
138
+ });
139
+ const parsed = parseJsonSafe(raw);
140
+ const mergedContent = String(parsed?.content_md || '').trim();
141
+ const mergedSummary = String(parsed?.summary_md || '').trim();
142
+ if (!mergedContent) return null;
143
+
144
+ for (const record of available) await snapshotScope(opts.root, opts.ts, record.scope);
145
+ const mergedSegments = dedupeSegments(
146
+ available.flatMap(record => record.segments),
147
+ canonical.scope,
148
+ );
149
+ const transaction = stageConsolidationTransaction({
150
+ root: opts.root,
151
+ sessionId: opts.sessionId,
152
+ canonical: canonical.path,
153
+ mergedContent,
154
+ mergedSummary,
155
+ mergedSegments,
156
+ duplicates: available.slice(1).map(record => record.path),
157
+ ts: opts.ts,
158
+ fileOps: opts.fileOps,
159
+ });
160
+
161
+ try {
162
+ transaction.activate();
163
+ if (opts.segmentIndex) {
164
+ syncScope(opts.root, opts.segmentIndex, canonical.scope);
165
+ for (const record of available.slice(1)) {
166
+ opts.segmentIndex.deleteScope(record.scope);
167
+ syncScope(opts.root, opts.segmentIndex, record.scope);
168
+ }
169
+ }
170
+ transaction.commit();
171
+ removeEmptyDirectory(join(opts.root, '.topic-consolidation'));
172
+ } catch (error) {
173
+ try {
174
+ transaction.rollback();
175
+ removeEmptyDirectory(join(opts.root, '.topic-consolidation'));
176
+ } catch (rollbackError) {
177
+ error.rollbackError = rollbackError;
178
+ }
179
+ if (opts.segmentIndex) {
180
+ for (const record of available) {
181
+ try { syncScope(opts.root, opts.segmentIndex, record.scope); } catch { /* best effort */ }
182
+ }
183
+ }
184
+ throw error;
185
+ }
186
+
187
+ return { canonical: canonical.path, merged: available.slice(1).map(record => record.path) };
188
+ }
189
+
190
+ async function readTopicRecord(path, opts) {
191
+ const scopeObject = { kind: 'session-topic', sessionId: opts.sessionId, path: path.split('/') };
192
+ const scope = `sessions/${opts.sessionId}/topic/${path}`;
193
+ return {
194
+ path,
195
+ scope,
196
+ content: await readContent(scopeObject, { root: opts.root }),
197
+ memory: await readMemory(scopeObject, { root: opts.root }),
198
+ summary: await readSummary(scopeObject, { root: opts.root, language: opts.language }),
199
+ segments: readScope(opts.root, scope),
200
+ };
201
+ }
202
+
203
+ function dedupeSegments(segments, canonicalScope) {
204
+ const out = new Map();
205
+ for (const segment of segments) {
206
+ if (!segment?.id || !segment?.body) continue;
207
+ const key = `${segment.kind || 'context'}\u0000${segment.body.trim().toLowerCase()}`;
208
+ const current = out.get(key);
209
+ const sourceMessages = [...new Set([
210
+ ...(current?.sourceMessages || []),
211
+ ...(segment.sourceMessages || []),
212
+ ].map(String).filter(Boolean))];
213
+ out.set(key, { ...(current || segment), scope: canonicalScope, sourceMessages });
214
+ }
215
+ return [...out.values()].map(segment => ({
216
+ ...segment,
217
+ id: computeSegmentId({
218
+ scope: canonicalScope,
219
+ kind: segment.kind || 'context',
220
+ body: segment.body,
221
+ }),
222
+ }));
223
+ }
224
+
225
+ function stageConsolidationTransaction({ root, sessionId, canonical, mergedContent, mergedSummary, mergedSegments, duplicates, ts, fileOps }) {
226
+ const topicRoot = join(root, 'sessions', sessionId, 'topic');
227
+ const nonce = `${String(ts || Date.now()).replace(/[^a-zA-Z0-9_-]/g, '-')}-${Math.random().toString(36).slice(2, 10)}`;
228
+ const transactionRoot = join(root, '.topic-consolidation', nonce);
229
+ const stagedRoot = join(transactionRoot, 'staged');
230
+ const backupRoot = join(transactionRoot, 'backup');
231
+ const operations = [];
232
+
233
+ stageFile(join(stagedRoot, canonical, 'content.md'), `${mergedContent}\n`);
234
+ stageFile(join(stagedRoot, canonical, 'summary.md'), `${mergedSummary}\n`);
235
+ writeScope(stagedRoot, canonical, mergedSegments);
236
+ operations.push({ scope: canonical, file: 'content.md', desired: join(stagedRoot, canonical, 'content.md') });
237
+ operations.push({ scope: canonical, file: 'summary.md', desired: join(stagedRoot, canonical, 'summary.md') });
238
+ operations.push({ scope: canonical, file: 'memory.md', desired: join(stagedRoot, canonical, 'memory.md') });
239
+ operations.push({ scope: canonical, file: TOPIC_REDIRECT_FILE, desired: null });
240
+
241
+ for (const duplicate of duplicates) {
242
+ const redirectPath = join(stagedRoot, duplicate, TOPIC_REDIRECT_FILE);
243
+ stageFile(redirectPath, `${JSON.stringify({ version: 1, canonical }, null, 2)}\n`);
244
+ operations.push({ scope: duplicate, file: 'content.md', desired: null });
245
+ operations.push({ scope: duplicate, file: 'summary.md', desired: null });
246
+ operations.push({ scope: duplicate, file: 'memory.md', desired: null });
247
+ operations.push({ scope: duplicate, file: TOPIC_REDIRECT_FILE, desired: redirectPath });
248
+ }
249
+
250
+ const renameFile = typeof fileOps?.renameSync === 'function'
251
+ ? fileOps.renameSync
252
+ : renameSync;
253
+ const applied = [];
254
+ let closed = false;
255
+ return {
256
+ activate() {
257
+ for (const operation of operations) {
258
+ const live = join(topicRoot, operation.scope, operation.file);
259
+ const backup = join(backupRoot, operation.scope, operation.file);
260
+ const state = {
261
+ ...operation,
262
+ live,
263
+ backup,
264
+ hadLive: existsSync(live),
265
+ liveMoved: false,
266
+ desiredActivated: false,
267
+ };
268
+ // Register before the first live mutation. If staged activation fails
269
+ // after live moved to backup, rollback still owns this operation.
270
+ applied.push(state);
271
+ if (state.hadLive) {
272
+ mkdirSync(dirname(backup), { recursive: true });
273
+ renameFile(live, backup);
274
+ state.liveMoved = true;
275
+ }
276
+ if (operation.desired) {
277
+ mkdirSync(dirname(live), { recursive: true });
278
+ renameFile(operation.desired, live);
279
+ state.desiredActivated = true;
280
+ }
281
+ }
282
+ },
283
+ commit() {
284
+ if (closed) return;
285
+ closed = true;
286
+ rmSync(transactionRoot, { recursive: true, force: true });
287
+ },
288
+ rollback() {
289
+ if (closed) return;
290
+ let firstError = null;
291
+ for (const operation of applied.slice().reverse()) {
292
+ try {
293
+ if (operation.desiredActivated) rmSync(operation.live, { force: true });
294
+ if (operation.liveMoved && existsSync(operation.backup)) {
295
+ mkdirSync(dirname(operation.live), { recursive: true });
296
+ renameFile(operation.backup, operation.live);
297
+ }
298
+ } catch (error) {
299
+ if (!firstError) firstError = error;
300
+ }
301
+ }
302
+ if (firstError) throw firstError;
303
+ rmSync(transactionRoot, { recursive: true, force: true });
304
+ closed = true;
305
+ },
306
+ };
307
+ }
308
+
309
+ function removeEmptyDirectory(path) {
310
+ try { rmdirSync(path); } catch { /* absent or not empty */ }
311
+ }
312
+
313
+ function stageFile(path, content) {
314
+ mkdirSync(dirname(path), { recursive: true });
315
+ writeFileSync(path, content, 'utf8');
316
+ }
@@ -38,6 +38,7 @@ import { isValidTopic } from '../memory/store.js';
38
38
 
39
39
  import { truncateMessage } from './segment.js';
40
40
  import { render } from './prompts/index.js';
41
+ import { resolveTopicRedirect } from '../memory/topic-redirect.js';
41
42
 
42
43
  function triageSystem(language) {
43
44
  return String(language || '').toLowerCase().startsWith('zh')
@@ -182,7 +183,10 @@ export async function classifySoft({ sessionId, messages, topicSummaries, llm, l
182
183
  const segs = path.split('/').filter(Boolean);
183
184
  if (!sessionId || sessionId === '_no-session') continue;
184
185
  if (!isValidTopic({ kind: 'session-topic', sessionId, path: segs })) continue;
185
- const scope = `sessions/${sessionId}/topic/${segs.join('/')}`;
186
+ const redirected = args.root
187
+ ? resolveTopicRedirect(args.root, sessionId, segs.join('/'))
188
+ : segs.join('/');
189
+ const scope = `sessions/${sessionId}/topic/${redirected}`;
186
190
  if (pass2.decision === 'match') {
187
191
  out.push({ kind: 'update', scope });
188
192
  } else if (pass2.decision === 'new') {
@@ -223,13 +227,14 @@ export async function triageOneSegment(args) {
223
227
  * }} args
224
228
  * @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
225
229
  */
226
- export async function triageGroupSegments({ sessionId, segments, topicSummaries, llm, onProgress, language }) {
230
+ export async function triageGroupSegments({ root, sessionId, segments, topicSummaries, llm, onProgress, language }) {
227
231
  let acc = [];
228
232
  let i = 0;
229
233
  for (const seg of (segments || [])) {
230
234
  i += 1;
231
235
  if (onProgress) onProgress({ phase: 'triage', sessionId, segment: i, total: segments.length });
232
236
  const segActions = await triageOneSegment({
237
+ root,
233
238
  sessionId,
234
239
  messages: seg.messages,
235
240
  topicSummaries,