@yeaft/webchat-agent 0.1.628 → 0.1.630
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 +1 -1
- package/unify/dream-v2/apply.js +271 -0
- package/unify/dream-v2/limits.js +46 -0
- package/unify/dream-v2/merge.js +87 -0
- package/unify/dream-v2/runner.js +267 -0
- package/unify/dream-v2/schedule.js +73 -0
- package/unify/dream-v2/segment.js +191 -0
- package/unify/dream-v2/snapshot.js +80 -0
- package/unify/dream-v2/state.js +177 -0
- package/unify/dream-v2/triage.js +287 -0
- package/unify/memory/migrate-r6-to-v2.js +462 -0
- package/unify/memory/store-v2.js +402 -0
package/package.json
CHANGED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/apply.js — DESIGN-v2 §16 + §17.2.
|
|
3
|
+
*
|
|
4
|
+
* Execute one merged-target action: either UPDATE an existing scope's
|
|
5
|
+
* memory.md/summary.md, or CREATE a new one (currently topic-only).
|
|
6
|
+
*
|
|
7
|
+
* The flow per target:
|
|
8
|
+
* 1. snapshot existing files into .dream-bak/<ts>/<scope>/
|
|
9
|
+
* 2. if total content fits, run UPDATE (or CREATE) once
|
|
10
|
+
* 3. if it doesn't, batch the sources by group (segment.batchSourcesForApply)
|
|
11
|
+
* and chain the LLM calls — each batch's output becomes the next
|
|
12
|
+
* batch's `current memory.md`. The prompt threads "this is batch K
|
|
13
|
+
* of N" so the LLM doesn't think previous content was lost.
|
|
14
|
+
* 4. write tmp + rename for memory.md and summary.md
|
|
15
|
+
* 5. update the per-scope dream-state marker inside memory.md
|
|
16
|
+
*
|
|
17
|
+
* The LLM call is injected (`opts.llm`). Snapshots are injectable too
|
|
18
|
+
* (`opts.snapshot`) to allow tests to skip the .dream-bak side-effect.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { promises as fsp } from 'fs';
|
|
22
|
+
import { join, dirname } from 'path';
|
|
23
|
+
|
|
24
|
+
import { writeMemory, writeSummary, readMemory, readSummary } from '../memory/store-v2.js';
|
|
25
|
+
import { withDreamMarker } from './state.js';
|
|
26
|
+
import { batchSourcesForApply, needsBatchedApply, truncateMessage } from './segment.js';
|
|
27
|
+
import { snapshotScope } from './snapshot.js';
|
|
28
|
+
import { parseJsonSafe } from './triage.js';
|
|
29
|
+
|
|
30
|
+
const SYSTEM = `You are the Apply stage of a dream pipeline. You rewrite a single scope's memory.md and summary.md based on recent group conversations. Reply with strict JSON only — no prose, no fences.`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the UPDATE prompt body. Accepts the current scope state +
|
|
34
|
+
* one or more `(groupId, diff)` source blocks.
|
|
35
|
+
*
|
|
36
|
+
* @param {{
|
|
37
|
+
* target: string,
|
|
38
|
+
* memoryMd: string,
|
|
39
|
+
* summaryMd: string,
|
|
40
|
+
* sources: Array<{ groupId: string, diff: Array<object> }>,
|
|
41
|
+
* batchInfo?: { index: number, total: number },
|
|
42
|
+
* }} ctx
|
|
43
|
+
*/
|
|
44
|
+
export function buildUpdatePrompt(ctx) {
|
|
45
|
+
const lines = [];
|
|
46
|
+
lines.push('You are updating an existing memory scope.');
|
|
47
|
+
lines.push('');
|
|
48
|
+
lines.push(`Scope: ${ctx.target}`);
|
|
49
|
+
if (ctx.batchInfo && ctx.batchInfo.total > 1) {
|
|
50
|
+
lines.push(`This is batch ${ctx.batchInfo.index} of ${ctx.batchInfo.total}.`);
|
|
51
|
+
lines.push('Earlier batches have already been folded into the current memory.md below.');
|
|
52
|
+
}
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push('Current memory.md:');
|
|
55
|
+
lines.push('"""');
|
|
56
|
+
lines.push(ctx.memoryMd || '');
|
|
57
|
+
lines.push('"""');
|
|
58
|
+
lines.push('');
|
|
59
|
+
lines.push('Current summary.md:');
|
|
60
|
+
lines.push('"""');
|
|
61
|
+
lines.push(ctx.summaryMd || '');
|
|
62
|
+
lines.push('"""');
|
|
63
|
+
lines.push('');
|
|
64
|
+
lines.push('Recent conversations:');
|
|
65
|
+
for (const src of (ctx.sources || [])) {
|
|
66
|
+
lines.push('');
|
|
67
|
+
lines.push(`[group/${src.groupId}]`);
|
|
68
|
+
for (const m of (src.diff || [])) {
|
|
69
|
+
const head = `[${m.role || 'message'}${m.kind === 'overlap' ? ' (already processed)' : ''}]`;
|
|
70
|
+
lines.push(head);
|
|
71
|
+
lines.push(truncateMessage(m.body || ''));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push('Task:');
|
|
76
|
+
lines.push('- Extract from these conversations what is relevant to THIS scope.');
|
|
77
|
+
lines.push('- Integrate it into memory.md (reorganize sections if needed).');
|
|
78
|
+
lines.push('- Drop stale or contradicted entries.');
|
|
79
|
+
lines.push('- Rewrite summary.md (1–3 sentences).');
|
|
80
|
+
lines.push('- The same conversations are being processed for OTHER scopes too.');
|
|
81
|
+
lines.push(' Only handle what is relevant here. Ignore the rest.');
|
|
82
|
+
lines.push('');
|
|
83
|
+
lines.push('Hard rules:');
|
|
84
|
+
lines.push('- Never read or reference any other scope\'s files.');
|
|
85
|
+
lines.push('- Never modify VP system-prompt, group charter, or user preferences.');
|
|
86
|
+
lines.push('- If something contradicts a charter, annotate with');
|
|
87
|
+
lines.push(' "⚠️ contradicts charter — verify which is current" and continue.');
|
|
88
|
+
lines.push('');
|
|
89
|
+
lines.push('Reply with strict JSON of the shape:');
|
|
90
|
+
lines.push('{ "memory_md": "...", "summary_md": "..." }');
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the CREATE prompt body. Used when a target scope does not yet
|
|
96
|
+
* exist on disk (currently only topic/<...>).
|
|
97
|
+
*
|
|
98
|
+
* @param {{
|
|
99
|
+
* target: string,
|
|
100
|
+
* sources: Array<{ groupId: string, diff: Array<object> }>,
|
|
101
|
+
* siblingTopics?: Array<{ path: string, summary: string }>,
|
|
102
|
+
* }} ctx
|
|
103
|
+
*/
|
|
104
|
+
export function buildCreatePrompt(ctx) {
|
|
105
|
+
const lines = [];
|
|
106
|
+
lines.push('You are creating a new memory scope from scratch.');
|
|
107
|
+
lines.push('');
|
|
108
|
+
lines.push(`Scope path: ${ctx.target} (must be ≤2 levels)`);
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push('Source conversations:');
|
|
111
|
+
for (const src of (ctx.sources || [])) {
|
|
112
|
+
lines.push('');
|
|
113
|
+
lines.push(`[group/${src.groupId}]`);
|
|
114
|
+
for (const m of (src.diff || [])) {
|
|
115
|
+
const head = `[${m.role || 'message'}${m.kind === 'overlap' ? ' (already processed)' : ''}]`;
|
|
116
|
+
lines.push(head);
|
|
117
|
+
lines.push(truncateMessage(m.body || ''));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
lines.push('');
|
|
121
|
+
if (ctx.siblingTopics && ctx.siblingTopics.length > 0) {
|
|
122
|
+
lines.push('For tone reference, sibling/parent topic summaries:');
|
|
123
|
+
for (const t of ctx.siblingTopics) {
|
|
124
|
+
lines.push(` - ${t.path}: ${oneLine(t.summary)}`);
|
|
125
|
+
}
|
|
126
|
+
lines.push('');
|
|
127
|
+
}
|
|
128
|
+
lines.push('Task:');
|
|
129
|
+
lines.push('1. Write memory.md from scratch with reasonable section structure.');
|
|
130
|
+
lines.push('2. Write summary.md (1–3 sentences).');
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push('Reply with strict JSON of the shape:');
|
|
133
|
+
lines.push('{ "memory_md": "...", "summary_md": "..." }');
|
|
134
|
+
return lines.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Translate `target` like 'group/g-eng' or 'topic/sci/phys' to a Scope
|
|
139
|
+
* understood by store-v2. Throws if the path is malformed.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} target
|
|
142
|
+
* @returns {{ kind: string, id?: string, path?: string[] }}
|
|
143
|
+
*/
|
|
144
|
+
export function targetToScope(target) {
|
|
145
|
+
if (!target || typeof target !== 'string') throw new Error('apply.targetToScope: target required');
|
|
146
|
+
if (target === 'user') return { kind: 'user' };
|
|
147
|
+
const segs = target.split('/').filter(Boolean);
|
|
148
|
+
const head = segs[0];
|
|
149
|
+
if (head === 'vp' && segs.length === 2) return { kind: 'vp', id: segs[1] };
|
|
150
|
+
if (head === 'group' && segs.length === 2) return { kind: 'group', id: segs[1] };
|
|
151
|
+
if (head === 'feature' && segs.length === 2) return { kind: 'feature', id: segs[1] };
|
|
152
|
+
if (head === 'topic' && (segs.length === 2 || segs.length === 3)) {
|
|
153
|
+
return { kind: 'topic', path: segs.slice(1) };
|
|
154
|
+
}
|
|
155
|
+
throw new Error(`apply.targetToScope: malformed target ${JSON.stringify(target)}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run a single merged-target apply. Returns a record of what happened
|
|
160
|
+
* for the runner's debug-panel feed.
|
|
161
|
+
*
|
|
162
|
+
* @param {{
|
|
163
|
+
* target: string,
|
|
164
|
+
* kind: 'update'|'create',
|
|
165
|
+
* sources: Array<{ groupId: string, diff: any }>,
|
|
166
|
+
* }} merged
|
|
167
|
+
* @param {{
|
|
168
|
+
* root: string,
|
|
169
|
+
* ts: string, // shared timestamp folder
|
|
170
|
+
* llm: (req: { pass: string, prompt: string, system: string }) => Promise<string>,
|
|
171
|
+
* limits?: { MAX_APPLY_TOKENS?: number },
|
|
172
|
+
* snapshot?: typeof snapshotScope,
|
|
173
|
+
* nowIso?: () => string,
|
|
174
|
+
* onProgress?: (event: object) => void,
|
|
175
|
+
* siblingTopicsFor?: (target: string) => Promise<Array<{path:string, summary:string}>>,
|
|
176
|
+
* }} opts
|
|
177
|
+
*/
|
|
178
|
+
export async function applyMergedTarget(merged, opts) {
|
|
179
|
+
if (!opts || !opts.root) throw new Error('apply.applyMergedTarget: opts.root required');
|
|
180
|
+
if (!opts.llm) throw new Error('apply.applyMergedTarget: opts.llm required');
|
|
181
|
+
const ts = opts.ts || new Date().toISOString().replace(/[:.]/g, '-');
|
|
182
|
+
const snapFn = opts.snapshot || snapshotScope;
|
|
183
|
+
const nowIso = opts.nowIso ? opts.nowIso() : new Date().toISOString();
|
|
184
|
+
const scope = targetToScope(merged.target);
|
|
185
|
+
const scopeDirRel = scopeRelDir(scope);
|
|
186
|
+
|
|
187
|
+
if (opts.onProgress) opts.onProgress({ phase: 'apply', target: merged.target, status: 'snapshot' });
|
|
188
|
+
await snapFn(opts.root, ts, scopeDirRel);
|
|
189
|
+
|
|
190
|
+
let memoryMd = await readMemory(scope, { root: opts.root });
|
|
191
|
+
let summaryMd = await readSummary(scope, { root: opts.root });
|
|
192
|
+
|
|
193
|
+
if (merged.kind === 'create' && (memoryMd || summaryMd)) {
|
|
194
|
+
// Race / partial state: the scope already exists. Treat as update —
|
|
195
|
+
// safer than overwriting arbitrary bytes.
|
|
196
|
+
merged = { ...merged, kind: 'update' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let batchesUsed = 0;
|
|
200
|
+
const maxApply = (opts.limits && opts.limits.MAX_APPLY_TOKENS) || undefined;
|
|
201
|
+
|
|
202
|
+
if (merged.kind === 'create') {
|
|
203
|
+
const siblings = opts.siblingTopicsFor ? await opts.siblingTopicsFor(merged.target) : [];
|
|
204
|
+
const prompt = buildCreatePrompt({
|
|
205
|
+
target: merged.target,
|
|
206
|
+
sources: merged.sources,
|
|
207
|
+
siblingTopics: siblings,
|
|
208
|
+
});
|
|
209
|
+
if (opts.onProgress) opts.onProgress({ phase: 'apply', target: merged.target, status: 'llm', batch: 1, of: 1 });
|
|
210
|
+
const raw = await opts.llm({ pass: 'create', prompt, system: SYSTEM });
|
|
211
|
+
const parsed = parseJsonSafe(raw);
|
|
212
|
+
if (!parsed || typeof parsed.memory_md !== 'string') {
|
|
213
|
+
throw new Error(`apply: CREATE returned malformed JSON for ${merged.target}`);
|
|
214
|
+
}
|
|
215
|
+
memoryMd = parsed.memory_md;
|
|
216
|
+
summaryMd = typeof parsed.summary_md === 'string' ? parsed.summary_md : '';
|
|
217
|
+
batchesUsed = 1;
|
|
218
|
+
} else {
|
|
219
|
+
// UPDATE — possibly batched.
|
|
220
|
+
const batches = needsBatchedApply(
|
|
221
|
+
{ memoryMd, summaryMd, sources: merged.sources },
|
|
222
|
+
maxApply,
|
|
223
|
+
)
|
|
224
|
+
? batchSourcesForApply({ memoryMd, summaryMd, sources: merged.sources }, maxApply)
|
|
225
|
+
: [merged.sources];
|
|
226
|
+
|
|
227
|
+
let i = 0;
|
|
228
|
+
for (const batch of batches) {
|
|
229
|
+
i += 1;
|
|
230
|
+
const prompt = buildUpdatePrompt({
|
|
231
|
+
target: merged.target,
|
|
232
|
+
memoryMd,
|
|
233
|
+
summaryMd,
|
|
234
|
+
sources: batch,
|
|
235
|
+
batchInfo: { index: i, total: batches.length },
|
|
236
|
+
});
|
|
237
|
+
if (opts.onProgress) opts.onProgress({ phase: 'apply', target: merged.target, status: 'llm', batch: i, of: batches.length });
|
|
238
|
+
const raw = await opts.llm({ pass: 'update', prompt, system: SYSTEM });
|
|
239
|
+
const parsed = parseJsonSafe(raw);
|
|
240
|
+
if (!parsed || typeof parsed.memory_md !== 'string') {
|
|
241
|
+
throw new Error(`apply: UPDATE batch ${i} returned malformed JSON for ${merged.target}`);
|
|
242
|
+
}
|
|
243
|
+
memoryMd = parsed.memory_md;
|
|
244
|
+
if (typeof parsed.summary_md === 'string') summaryMd = parsed.summary_md;
|
|
245
|
+
}
|
|
246
|
+
batchesUsed = batches.length;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Stamp the per-scope dream marker, then atomically write both files.
|
|
250
|
+
const stamped = withDreamMarker(memoryMd, { lastDreamAt: nowIso });
|
|
251
|
+
await writeMemory(scope, stamped, { root: opts.root });
|
|
252
|
+
await writeSummary(scope, summaryMd || '', { root: opts.root });
|
|
253
|
+
|
|
254
|
+
if (opts.onProgress) opts.onProgress({ phase: 'apply', target: merged.target, status: 'done', batches: batchesUsed });
|
|
255
|
+
return { target: merged.target, kind: merged.kind, batches: batchesUsed };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── helpers ──────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
function scopeRelDir(scope) {
|
|
261
|
+
switch (scope.kind) {
|
|
262
|
+
case 'user': return 'user';
|
|
263
|
+
case 'vp': return `vp/${scope.id}`;
|
|
264
|
+
case 'group': return `group/${scope.id}`;
|
|
265
|
+
case 'feature': return `feature/${scope.id}`;
|
|
266
|
+
case 'topic': return `topic/${scope.path.join('/')}`;
|
|
267
|
+
default: throw new Error(`apply.scopeRelDir: unknown kind ${scope.kind}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function oneLine(s) { return String(s || '').replace(/\s+/g, ' ').trim().slice(0, 200); }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/limits.js — DESIGN-v2 §18 constants.
|
|
3
|
+
*
|
|
4
|
+
* Centralised so tests and runtime share one source of truth. Exposed
|
|
5
|
+
* as both named exports and a `DEFAULT_LIMITS` object for `runDream()`
|
|
6
|
+
* callers that want to override one knob without restating the rest.
|
|
7
|
+
*
|
|
8
|
+
* `loadLimitsFromConfig(config)` merges a `~/.yeaft/config.json`
|
|
9
|
+
* `unify.dream` block on top of defaults; unknown keys are ignored,
|
|
10
|
+
* malformed values fall back to default.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const DREAM_INTERVAL_HOURS = 12;
|
|
14
|
+
export const DREAM_OVERLAP = 3;
|
|
15
|
+
export const MIN_NEW_PER_GROUP = 20;
|
|
16
|
+
export const MAX_SINGLE_MESSAGE_CHARS = 8000;
|
|
17
|
+
export const MAX_DIFF_TOKENS_PER_TRIAGE = 60000;
|
|
18
|
+
export const MAX_APPLY_TOKENS = 80000;
|
|
19
|
+
export const DREAM_BACKUP_KEEP = 7;
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
22
|
+
DREAM_INTERVAL_HOURS,
|
|
23
|
+
DREAM_OVERLAP,
|
|
24
|
+
MIN_NEW_PER_GROUP,
|
|
25
|
+
MAX_SINGLE_MESSAGE_CHARS,
|
|
26
|
+
MAX_DIFF_TOKENS_PER_TRIAGE,
|
|
27
|
+
MAX_APPLY_TOKENS,
|
|
28
|
+
DREAM_BACKUP_KEEP,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Merge a config object's `unify.dream` block on top of defaults.
|
|
33
|
+
* @param {object} [config]
|
|
34
|
+
*/
|
|
35
|
+
export function loadLimitsFromConfig(config) {
|
|
36
|
+
const out = { ...DEFAULT_LIMITS };
|
|
37
|
+
const ud = config && config.unify && config.unify.dream;
|
|
38
|
+
if (!ud || typeof ud !== 'object') return out;
|
|
39
|
+
for (const k of Object.keys(out)) {
|
|
40
|
+
if (Object.prototype.hasOwnProperty.call(ud, k)) {
|
|
41
|
+
const v = ud[k];
|
|
42
|
+
if (Number.isFinite(v) && v > 0) out[k] = v;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/merge.js — DESIGN-v2 §15.
|
|
3
|
+
*
|
|
4
|
+
* Pure-code stage between Triage and Apply.
|
|
5
|
+
*
|
|
6
|
+
* Triage emits per-group action lists; the runner needs to flip that
|
|
7
|
+
* into a per-target source list so Apply runs once per target rather
|
|
8
|
+
* than once per (group × target) pair. (User scope in particular would
|
|
9
|
+
* be rewritten dozens of times if we didn't merge.)
|
|
10
|
+
*
|
|
11
|
+
* Input shape (one entry per group that crossed the newCount threshold):
|
|
12
|
+
*
|
|
13
|
+
* [
|
|
14
|
+
* {
|
|
15
|
+
* groupId: 'g-eng',
|
|
16
|
+
* diff: [<message>, ...], // the per-group source diff
|
|
17
|
+
* // (already truncated/segmented if needed)
|
|
18
|
+
* actions: [
|
|
19
|
+
* { kind: 'update', scope: 'group/g-eng' },
|
|
20
|
+
* { kind: 'update', scope: 'vp/zhang-san' },
|
|
21
|
+
* { kind: 'update', scope: 'user' },
|
|
22
|
+
* { kind: 'create', scope: 'topic/life/parenting' },
|
|
23
|
+
* ...
|
|
24
|
+
* ],
|
|
25
|
+
* },
|
|
26
|
+
* ...
|
|
27
|
+
* ]
|
|
28
|
+
*
|
|
29
|
+
* Output shape:
|
|
30
|
+
*
|
|
31
|
+
* [
|
|
32
|
+
* { target: 'user',
|
|
33
|
+
* kind: 'update', // 'update' wins over 'create' if any group says update
|
|
34
|
+
* sources: [
|
|
35
|
+
* { groupId: 'g-eng', diff: [...] },
|
|
36
|
+
* { groupId: 'g-life', diff: [...] },
|
|
37
|
+
* ],
|
|
38
|
+
* },
|
|
39
|
+
* { target: 'topic/life/parenting',
|
|
40
|
+
* kind: 'create', // create only if every contributing group said create
|
|
41
|
+
* sources: [{ groupId: 'g-life', diff: [...] }],
|
|
42
|
+
* },
|
|
43
|
+
* ...
|
|
44
|
+
* ]
|
|
45
|
+
*
|
|
46
|
+
* Determinism contract:
|
|
47
|
+
* - Targets are returned sorted alphabetically by target path.
|
|
48
|
+
* - Within a target, sources are sorted by groupId.
|
|
49
|
+
* This makes the debug-panel output predictable across runs.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Merge per-group triage outputs into per-target apply units.
|
|
54
|
+
*
|
|
55
|
+
* @param {Array<{ groupId: string, diff: any, actions: Array<{ kind: 'update'|'create', scope: string }> }>} groupTriages
|
|
56
|
+
* @returns {Array<{ target: string, kind: 'update'|'create', sources: Array<{ groupId: string, diff: any }> }>}
|
|
57
|
+
*/
|
|
58
|
+
export function mergeByTarget(groupTriages) {
|
|
59
|
+
const byTarget = new Map();
|
|
60
|
+
for (const g of (groupTriages || [])) {
|
|
61
|
+
const groupId = g && g.groupId;
|
|
62
|
+
const diff = g && g.diff;
|
|
63
|
+
const actions = Array.isArray(g && g.actions) ? g.actions : [];
|
|
64
|
+
if (!groupId) continue;
|
|
65
|
+
for (const a of actions) {
|
|
66
|
+
if (!a || !a.scope) continue;
|
|
67
|
+
const k = a.kind === 'create' ? 'create' : 'update';
|
|
68
|
+
let entry = byTarget.get(a.scope);
|
|
69
|
+
if (!entry) {
|
|
70
|
+
entry = { target: a.scope, kind: k, sources: [] };
|
|
71
|
+
byTarget.set(a.scope, entry);
|
|
72
|
+
}
|
|
73
|
+
// 'update' wins: any contributing group that already considers
|
|
74
|
+
// the scope existing means we treat the apply as an update.
|
|
75
|
+
if (k === 'update') entry.kind = 'update';
|
|
76
|
+
// Avoid duplicate (target, group) pairs — should never happen
|
|
77
|
+
// in normal triage but be defensive.
|
|
78
|
+
if (!entry.sources.some(s => s.groupId === groupId)) {
|
|
79
|
+
entry.sources.push({ groupId, diff });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const out = Array.from(byTarget.values());
|
|
84
|
+
out.sort((a, b) => a.target.localeCompare(b.target));
|
|
85
|
+
for (const e of out) e.sources.sort((a, b) => a.groupId.localeCompare(b.groupId));
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/runner.js — DESIGN-v2 §13.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates one full dream pass:
|
|
5
|
+
*
|
|
6
|
+
* trigger
|
|
7
|
+
* ↓
|
|
8
|
+
* enumerateGroups() via opts.listGroups()
|
|
9
|
+
* ↓
|
|
10
|
+
* for each group with newCount ≥ MIN_NEW_PER_GROUP (auto)
|
|
11
|
+
* or > 0 (manual):
|
|
12
|
+
* loadDiff() via opts.loadGroupDiff(groupId, sinceId)
|
|
13
|
+
* applyOverlap() via opts.loadOverlapPreamble(...)
|
|
14
|
+
* segment() segmentDiff(...)
|
|
15
|
+
* triageGroupSegments() → group-local actions[]
|
|
16
|
+
*
|
|
17
|
+
* mergeByTarget() → per-target actions
|
|
18
|
+
* for each merged target:
|
|
19
|
+
* applyMergedTarget() (snapshot + UPDATE/CREATE + atomic write)
|
|
20
|
+
*
|
|
21
|
+
* bookkeep:
|
|
22
|
+
* for each processed group:
|
|
23
|
+
* group .dream-state ←
|
|
24
|
+
* { lastDreamMessageId: tail of real diff,
|
|
25
|
+
* lastDreamAt: nowIso,
|
|
26
|
+
* messageCount: <after> }
|
|
27
|
+
*
|
|
28
|
+
* pruneOldSnapshots()
|
|
29
|
+
*
|
|
30
|
+
* onProgress emits dream_progress events that the web bridge forwards
|
|
31
|
+
* as `unify_output` messages so the debug panel can render live state
|
|
32
|
+
* (DESIGN-v2 §19.4). All events flow through the same channel; no new
|
|
33
|
+
* WebSocket message type is introduced.
|
|
34
|
+
*
|
|
35
|
+
* Everything that touches a shell of the system (LLM, message store,
|
|
36
|
+
* scope listing, topic tree) is injected. The default exports below
|
|
37
|
+
* are pure orchestration.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { existsSync } from 'fs';
|
|
41
|
+
import { join } from 'path';
|
|
42
|
+
|
|
43
|
+
import { listScopes, readSummary } from '../memory/store-v2.js';
|
|
44
|
+
import {
|
|
45
|
+
DEFAULT_LIMITS,
|
|
46
|
+
} from './limits.js';
|
|
47
|
+
import { readGroupState, writeGroupState } from './state.js';
|
|
48
|
+
import { segmentDiff, truncateMessage, estimateMessagesTokens } from './segment.js';
|
|
49
|
+
import { triageGroupSegments } from './triage.js';
|
|
50
|
+
import { mergeByTarget } from './merge.js';
|
|
51
|
+
import { applyMergedTarget } from './apply.js';
|
|
52
|
+
import { tsForBackup, pruneOldSnapshots } from './snapshot.js';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} RunDreamOpts
|
|
56
|
+
* @property {string} root — memory root, e.g. ~/.yeaft/memory
|
|
57
|
+
* @property {boolean} [manual=false] — manual trigger overrides newCount<20 skip
|
|
58
|
+
* @property {string[]} [scopeFilter] — optional: only dream these targets (still respects newCount per group; '*' allowed)
|
|
59
|
+
* @property {(req: {pass:string, prompt:string, system:string}) => Promise<string>} llm
|
|
60
|
+
* @property {() => Promise<Array<string>>} listGroups — return all group ids (incl. '_no-group')
|
|
61
|
+
* @property {(groupId: string) => Promise<number>} countMessages — total message count for a group
|
|
62
|
+
* @property {(groupId: string, sinceMessageId: string|null) => Promise<Array<object>>} loadGroupDiff
|
|
63
|
+
* @property {(groupId: string, beforeMessageId: string|null, count: number) => Promise<Array<object>>} loadOverlapPreamble
|
|
64
|
+
* @property {() => Promise<Array<{path:string, summary:string}>>} [listTopicSummaries]
|
|
65
|
+
* @property {(target: string) => Promise<Array<{path:string, summary:string}>>} [siblingTopicsFor]
|
|
66
|
+
* @property {(event: object) => void} [onProgress]
|
|
67
|
+
* @property {object} [limits] — override DEFAULT_LIMITS
|
|
68
|
+
* @property {() => string} [nowIso]
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run one dream pass. Returns a structured report consumed by the
|
|
73
|
+
* debug panel (and tests).
|
|
74
|
+
*
|
|
75
|
+
* @param {RunDreamOpts} opts
|
|
76
|
+
*/
|
|
77
|
+
export async function runDream(opts) {
|
|
78
|
+
if (!opts || !opts.root) throw new Error('runDream: opts.root required');
|
|
79
|
+
if (!opts.llm) throw new Error('runDream: opts.llm required');
|
|
80
|
+
const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) };
|
|
81
|
+
const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : () => {};
|
|
82
|
+
const nowIso = (opts.nowIso ? opts.nowIso() : new Date().toISOString());
|
|
83
|
+
const ts = tsForBackup(new Date(nowIso));
|
|
84
|
+
const startedAt = Date.now();
|
|
85
|
+
|
|
86
|
+
onProgress({ phase: 'start', manual: !!opts.manual, ts });
|
|
87
|
+
|
|
88
|
+
// 1. enumerate groups
|
|
89
|
+
const groupIds = await safeCall(opts.listGroups, []);
|
|
90
|
+
const filter = Array.isArray(opts.scopeFilter) ? new Set(opts.scopeFilter) : null;
|
|
91
|
+
const groupsReport = [];
|
|
92
|
+
const groupTriages = [];
|
|
93
|
+
const processedGroups = [];
|
|
94
|
+
|
|
95
|
+
// 2. per-group: skip / segment / triage
|
|
96
|
+
const topicSummaries = opts.listTopicSummaries
|
|
97
|
+
? await safeCall(opts.listTopicSummaries, [])
|
|
98
|
+
: await defaultListTopicSummaries(opts.root).catch(() => []);
|
|
99
|
+
|
|
100
|
+
for (const groupId of groupIds) {
|
|
101
|
+
// NOTE: scopeFilter is applied at the merge/apply stage, not here.
|
|
102
|
+
// A filter like ['user'] still requires triaging every group so their
|
|
103
|
+
// hard-rule actions can contribute to the user target.
|
|
104
|
+
const state = await readGroupState(opts.root, groupId);
|
|
105
|
+
const beforeCount = await safeCall(() => opts.countMessages(groupId), 0);
|
|
106
|
+
const newCount = Math.max(0, beforeCount - (state.messageCount || 0));
|
|
107
|
+
|
|
108
|
+
if (newCount === 0) {
|
|
109
|
+
groupsReport.push({ groupId, new: 0, status: 'skipped', reason: 'no-new-messages' });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!opts.manual && newCount < limits.MIN_NEW_PER_GROUP) {
|
|
113
|
+
groupsReport.push({ groupId, new: newCount, status: 'skipped', reason: 'below-threshold' });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
onProgress({ phase: 'load-diff', groupId });
|
|
118
|
+
const diffNew = await safeCall(() => opts.loadGroupDiff(groupId, state.lastDreamMessageId), []);
|
|
119
|
+
if (!diffNew || diffNew.length === 0) {
|
|
120
|
+
groupsReport.push({ groupId, new: newCount, status: 'skipped', reason: 'empty-diff' });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const overlapMessages = state.lastDreamMessageId
|
|
124
|
+
? await safeCall(
|
|
125
|
+
() => opts.loadOverlapPreamble
|
|
126
|
+
? opts.loadOverlapPreamble(groupId, state.lastDreamMessageId, limits.DREAM_OVERLAP)
|
|
127
|
+
: [],
|
|
128
|
+
[],
|
|
129
|
+
)
|
|
130
|
+
: [];
|
|
131
|
+
const taggedOverlap = overlapMessages.map(m => ({ ...m, kind: 'overlap', body: truncateMessage(m.body || '') }));
|
|
132
|
+
const taggedNew = diffNew.map(m => ({ ...m, kind: 'new', body: truncateMessage(m.body || '') }));
|
|
133
|
+
const fullDiff = [...taggedOverlap, ...taggedNew];
|
|
134
|
+
|
|
135
|
+
const segments = segmentDiff(fullDiff, limits.MAX_DIFF_TOKENS_PER_TRIAGE, limits.DREAM_OVERLAP);
|
|
136
|
+
onProgress({ phase: 'triage', groupId, status: 'running', segments: segments.length });
|
|
137
|
+
|
|
138
|
+
let actions;
|
|
139
|
+
try {
|
|
140
|
+
actions = await triageGroupSegments({
|
|
141
|
+
groupId,
|
|
142
|
+
segments,
|
|
143
|
+
topicSummaries,
|
|
144
|
+
llm: opts.llm,
|
|
145
|
+
onProgress,
|
|
146
|
+
});
|
|
147
|
+
} catch (err) {
|
|
148
|
+
groupsReport.push({ groupId, new: newCount, status: 'error', error: err.message });
|
|
149
|
+
onProgress({ phase: 'triage', groupId, status: 'error', error: err.message });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
onProgress({ phase: 'triage', groupId, status: 'done', actions: actions.length });
|
|
154
|
+
groupTriages.push({ groupId, diff: fullDiff, actions });
|
|
155
|
+
|
|
156
|
+
const tailId = lastMessageId(diffNew);
|
|
157
|
+
processedGroups.push({ groupId, tailId, beforeCount, newCount, segments: segments.length, actions: actions.length });
|
|
158
|
+
groupsReport.push({ groupId, new: newCount, segments: segments.length, actions: actions.length, status: 'triaged' });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 3. merge
|
|
162
|
+
const mergedTargets = mergeByTarget(groupTriages);
|
|
163
|
+
const targetsToApply = filter && filter.size > 0 && !filter.has('*')
|
|
164
|
+
? mergedTargets.filter(t => filter.has(t.target))
|
|
165
|
+
: mergedTargets;
|
|
166
|
+
|
|
167
|
+
onProgress({ phase: 'merge', targets: targetsToApply.length });
|
|
168
|
+
|
|
169
|
+
// 4. apply
|
|
170
|
+
const targetsReport = [];
|
|
171
|
+
for (const merged of targetsToApply) {
|
|
172
|
+
try {
|
|
173
|
+
const r = await applyMergedTarget(merged, {
|
|
174
|
+
root: opts.root,
|
|
175
|
+
ts,
|
|
176
|
+
llm: opts.llm,
|
|
177
|
+
limits,
|
|
178
|
+
nowIso: opts.nowIso || (() => nowIso),
|
|
179
|
+
onProgress,
|
|
180
|
+
siblingTopicsFor: opts.siblingTopicsFor,
|
|
181
|
+
});
|
|
182
|
+
targetsReport.push({ ...r, sources: merged.sources.length, status: 'done' });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
targetsReport.push({
|
|
185
|
+
target: merged.target,
|
|
186
|
+
kind: merged.kind,
|
|
187
|
+
sources: merged.sources.length,
|
|
188
|
+
status: 'error',
|
|
189
|
+
error: err.message,
|
|
190
|
+
});
|
|
191
|
+
onProgress({ phase: 'apply', target: merged.target, status: 'error', error: err.message });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 5. bookkeep — only when at least one apply for this group's actions
|
|
196
|
+
// succeeded. We use a permissive policy: if ANY merged-target apply
|
|
197
|
+
// succeeded for a group's contributed actions, advance that group's
|
|
198
|
+
// cursor. (If everything errored, we keep the cursor so next run
|
|
199
|
+
// retries.)
|
|
200
|
+
const successfulTargets = new Set(targetsReport.filter(r => r.status === 'done').map(r => r.target));
|
|
201
|
+
for (const pg of processedGroups) {
|
|
202
|
+
const contributed = (groupTriages.find(g => g.groupId === pg.groupId) || { actions: [] })
|
|
203
|
+
.actions.map(a => a.scope);
|
|
204
|
+
const anySuccess = contributed.some(t => successfulTargets.has(t));
|
|
205
|
+
if (!anySuccess) continue;
|
|
206
|
+
if (pg.tailId) {
|
|
207
|
+
await writeGroupState(opts.root, pg.groupId, {
|
|
208
|
+
lastDreamMessageId: pg.tailId,
|
|
209
|
+
lastDreamAt: nowIso,
|
|
210
|
+
messageCount: pg.beforeCount,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 6. prune backups
|
|
216
|
+
const pruned = await pruneOldSnapshots(opts.root, limits.DREAM_BACKUP_KEEP);
|
|
217
|
+
|
|
218
|
+
const duration = Date.now() - startedAt;
|
|
219
|
+
onProgress({
|
|
220
|
+
phase: 'done',
|
|
221
|
+
groups: processedGroups.length,
|
|
222
|
+
targets: targetsReport.length,
|
|
223
|
+
duration,
|
|
224
|
+
backupsKept: pruned.kept.length,
|
|
225
|
+
backupsRemoved: pruned.removed.length,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
startedAt: nowIso,
|
|
230
|
+
durationMs: duration,
|
|
231
|
+
groups: groupsReport,
|
|
232
|
+
targets: targetsReport,
|
|
233
|
+
backups: pruned,
|
|
234
|
+
ts,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ─── helpers ──────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function lastMessageId(messages) {
|
|
241
|
+
if (!Array.isArray(messages) || messages.length === 0) return null;
|
|
242
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
243
|
+
if (messages[i] && messages[i].id) return messages[i].id;
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function safeCall(fn, fallback) {
|
|
249
|
+
try {
|
|
250
|
+
if (typeof fn !== 'function') return fallback;
|
|
251
|
+
const v = await fn();
|
|
252
|
+
return v == null ? fallback : v;
|
|
253
|
+
} catch {
|
|
254
|
+
return fallback;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function defaultListTopicSummaries(root) {
|
|
259
|
+
const all = await listScopes({ root });
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const sc of all) {
|
|
262
|
+
if (sc.kind !== 'topic') continue;
|
|
263
|
+
const summary = await readSummary(sc, { root });
|
|
264
|
+
out.push({ path: sc.path.join('/'), summary });
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|