@yeaft/webchat-agent 1.0.349 → 1.0.351
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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +91 -97
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/dream/apply.js +59 -30
- package/yeaft/dream/output-snapshot.js +8 -4
- package/yeaft/dream/prompts/consolidate-topics.md +31 -0
- package/yeaft/dream/prompts/create.md +8 -8
- package/yeaft/dream/prompts/index.js +4 -2
- package/yeaft/dream/prompts/merge-topics.md +35 -0
- package/yeaft/dream/prompts/triage-pass1.md +2 -2
- package/yeaft/dream/prompts/triage-pass2.md +4 -2
- package/yeaft/dream/prompts/update.md +16 -14
- package/yeaft/dream/runner.js +69 -13
- package/yeaft/dream/segment-extract.js +16 -13
- package/yeaft/dream/session-wiring.js +2 -2
- package/yeaft/dream/snapshot.js +3 -3
- package/yeaft/dream/topic-consolidation.js +316 -0
- package/yeaft/dream/triage.js +7 -2
- package/yeaft/engine.js +221 -236
- package/yeaft/memory/ams-registry.js +42 -61
- package/yeaft/memory/ams.js +17 -9
- package/yeaft/memory/budget.js +15 -18
- package/yeaft/memory/content-backfill.js +118 -0
- package/yeaft/memory/index-db.js +10 -3
- package/yeaft/memory/keywords.js +25 -7
- package/yeaft/memory/preflow.js +26 -9
- package/yeaft/memory/segment-store.js +44 -8
- package/yeaft/memory/segment-sync.js +8 -4
- package/yeaft/memory/segment.js +10 -3
- package/yeaft/memory/store.js +88 -38
- package/yeaft/memory/summary-store.js +3 -3
- package/yeaft/memory/topic-redirect.js +28 -0
- package/yeaft/session.js +18 -19
- package/yeaft/sessions/pre-flow.js +7 -3
- package/yeaft/sub-agent/runner.js +10 -1
- package/yeaft/work-center/bridge.js +1 -0
- package/yeaft/work-center/runner.js +33 -4
package/yeaft/dream/runner.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*
|
|
18
18
|
* mergeByTarget() → per-target actions
|
|
19
19
|
* for each merged target:
|
|
20
|
-
* applyMergedTarget() (snapshot + UPDATE/CREATE
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
242
|
-
//
|
|
243
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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 !== '
|
|
416
|
+
if (sc.kind !== 'session-topic') continue;
|
|
364
417
|
if (sc.sessionId !== sessionId) continue;
|
|
365
418
|
const summary = await readSummary(sc, { root, language });
|
|
366
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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(
|
|
146
|
-
id: String(m.id || m.messageId ||
|
|
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,
|
|
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
|
*/
|
package/yeaft/dream/snapshot.js
CHANGED
|
@@ -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
|
+
}
|
package/yeaft/dream/triage.js
CHANGED
|
@@ -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
|
|
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,
|