@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.628",
3
+ "version": "0.1.630",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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
+ }