@yeaft/webchat-agent 0.1.629 → 0.1.631
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/config.js +9 -0
- 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/engine.js +38 -4
- package/unify/memory/recall-v2.js +258 -0
package/package.json
CHANGED
package/unify/config.js
CHANGED
|
@@ -217,6 +217,8 @@ function loadLegacyConfig(dir, overrides) {
|
|
|
217
217
|
maxContinueTurns: overrides.maxContinueTurns ?? fileConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
|
|
218
218
|
// task-318: legacy path never had the `unify` section — defaults.
|
|
219
219
|
unify: normaliseUnifySection(null),
|
|
220
|
+
// DESIGN-v2 feature flag — opt-in via override only on legacy path.
|
|
221
|
+
memoryV2: !!overrides.memoryV2,
|
|
220
222
|
providers: null,
|
|
221
223
|
primaryModel: null,
|
|
222
224
|
fastModel: null,
|
|
@@ -313,6 +315,13 @@ export function loadConfig(overrides = {}) {
|
|
|
313
315
|
// don't pollute the flat config namespace used by chat/crew code.
|
|
314
316
|
unify: normaliseUnifySection(jsonConfig.unify),
|
|
315
317
|
|
|
318
|
+
// DESIGN-v2 feature flag. When true the engine routes recall through
|
|
319
|
+
// memory/recall-v2.js (per-scope memory.md + summary.md) and reads from
|
|
320
|
+
// the v2 store layout. Defaults to false during the rollout; PR-E flips
|
|
321
|
+
// the default and deletes the legacy paths.
|
|
322
|
+
memoryV2: overrides.memoryV2 !== undefined ? !!overrides.memoryV2
|
|
323
|
+
: (jsonConfig.memoryV2 !== undefined ? !!jsonConfig.memoryV2 : false),
|
|
324
|
+
|
|
316
325
|
// Legacy fields (null when using config.json)
|
|
317
326
|
apiKey: overrides.apiKey || null,
|
|
318
327
|
openaiApiKey: null,
|
|
@@ -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
|
+
}
|