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