@yeaft/webchat-agent 0.1.592 → 0.1.593

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.592",
3
+ "version": "0.1.593",
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,520 @@
1
+ /**
2
+ * memory/scope-tree.js — DESIGN.md Phase 2 (scoped memory tree).
3
+ *
4
+ * Implements the path-keyed scope tree described in DESIGN.md §2:
5
+ *
6
+ * ~/.yeaft/memory/
7
+ * user/
8
+ * summary.md — paragraph synopsis (Layer A)
9
+ * index.md — markdown table, path-keyed
10
+ * entries/<yyyy-mm-dd>-<slug>.md
11
+ * groups/<groupId>/ — same shape
12
+ * vp/<vpId>/ — same shape
13
+ * tasks/<taskId>/ — same shape, plus archive/
14
+ *
15
+ * This module is concerned ONLY with on-disk shape + atomic writes. It does
16
+ * NOT do any LLM work (extraction, summarisation, dream maintenance) — those
17
+ * are higher layers (compact-orchestrator, dream).
18
+ *
19
+ * Atomicity contract:
20
+ * - Every write goes via `.tmp` + rename to avoid torn reads. A reader
21
+ * mid-rename sees either the old or the new file, never half of either.
22
+ * - `createEntry()` opens with `O_EXCL` so concurrent producers cannot
23
+ * clobber a slug collision; the second writer surfaces `slug_exists`.
24
+ * - `index.md` is rewritten in full (markdown table). Partial-row append
25
+ * would leave a torn header on crash; the whole file is small enough
26
+ * that full rewrite + atomic rename is fine.
27
+ *
28
+ * Concurrency rules (DESIGN.md §9.1):
29
+ * - Two workers writing different entries to the same scope are safe —
30
+ * they write to different files, then each updates index.md via the
31
+ * atomic rename. Last writer wins for the index; both entries are
32
+ * present on disk regardless.
33
+ * - `summary.md` written via the same atomic rename. Workers reading it
34
+ * mid-write see either the previous or the next paragraph.
35
+ *
36
+ * Path discipline:
37
+ * - All scope paths returned by helpers are filesystem-relative paths
38
+ * ROOTED at the memory dir, e.g. `groups/eng/entries/2026-04-21-foo.md`.
39
+ * This matches DESIGN.md §1.2.1 — paths self-document.
40
+ */
41
+
42
+ import {
43
+ promises as fsp,
44
+ existsSync,
45
+ mkdirSync,
46
+ } from 'fs';
47
+ import { join, dirname } from 'path';
48
+ import { homedir } from 'os';
49
+
50
+ /** Default memory root. Tests override via `opts.root`. */
51
+ export const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
52
+
53
+ /** @typedef {'user'|'group'|'vp'|'task'} ScopeKind */
54
+ /** @typedef {{kind: ScopeKind, id?: string}} Scope */
55
+
56
+ /**
57
+ * Compute the scope's path segment relative to the memory root.
58
+ * `user/`, `groups/<id>/`, `vp/<id>/`, `tasks/<id>/`.
59
+ *
60
+ * @param {Scope} scope
61
+ * @returns {string}
62
+ */
63
+ export function scopeDir(scope) {
64
+ if (!scope || typeof scope !== 'object') {
65
+ throw new Error('scopeDir: scope is required');
66
+ }
67
+ switch (scope.kind) {
68
+ case 'user':
69
+ return 'user';
70
+ case 'group':
71
+ if (!scope.id) throw new Error('scopeDir: group scope requires id');
72
+ return `groups/${scope.id}`;
73
+ case 'vp':
74
+ if (!scope.id) throw new Error('scopeDir: vp scope requires id');
75
+ return `vp/${scope.id}`;
76
+ case 'task':
77
+ if (!scope.id) throw new Error('scopeDir: task scope requires id');
78
+ return `tasks/${scope.id}`;
79
+ default:
80
+ throw new Error(`scopeDir: unknown kind ${JSON.stringify(scope.kind)}`);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Atomic write: temp-file + rename. Creates parent directories on demand.
86
+ * The rename is atomic on POSIX filesystems for paths on the same mount.
87
+ *
88
+ * @param {string} absPath
89
+ * @param {string} content
90
+ */
91
+ async function atomicWrite(absPath, content) {
92
+ await fsp.mkdir(dirname(absPath), { recursive: true });
93
+ const tmp = `${absPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
94
+ await fsp.writeFile(tmp, content, 'utf8');
95
+ await fsp.rename(tmp, absPath);
96
+ }
97
+
98
+ /**
99
+ * Slugify a free-form title for use in a filename. Lowercase, ASCII letters
100
+ * + digits + dash. Empty / pathological input returns `entry`.
101
+ *
102
+ * @param {string} title
103
+ * @returns {string}
104
+ */
105
+ export function slugify(title) {
106
+ const raw = (title || '')
107
+ .toString()
108
+ .toLowerCase()
109
+ .normalize('NFKD')
110
+ // eslint-disable-next-line no-misleading-character-class
111
+ .replace(/[̀-ͯ]/g, '') // strip combining marks
112
+ .replace(/[^a-z0-9]+/g, '-')
113
+ .replace(/^-+|-+$/g, '')
114
+ .slice(0, 60);
115
+ return raw || 'entry';
116
+ }
117
+
118
+ /**
119
+ * Format a Date as `yyyy-mm-dd` in UTC. Used in entry filenames.
120
+ *
121
+ * @param {Date} [d=new Date()]
122
+ * @returns {string}
123
+ */
124
+ export function isoDate(d = new Date()) {
125
+ const y = d.getUTCFullYear();
126
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
127
+ const day = String(d.getUTCDate()).padStart(2, '0');
128
+ return `${y}-${m}-${day}`;
129
+ }
130
+
131
+ /**
132
+ * Compute the canonical entry path for a (scope, title, date) triple.
133
+ *
134
+ * @param {Scope} scope
135
+ * @param {string} title
136
+ * @param {Date} [date]
137
+ * @returns {string} relative to memory root
138
+ */
139
+ export function entryPathFor(scope, title, date) {
140
+ return `${scopeDir(scope)}/entries/${isoDate(date)}-${slugify(title)}.md`;
141
+ }
142
+
143
+ // ─── frontmatter ───────────────────────────────────────────────
144
+
145
+ /**
146
+ * Render YAML-ish frontmatter. Keys in deterministic order; string values
147
+ * are JSON-stringified to handle quotes / newlines safely; arrays render as
148
+ * `key: [a, b]`. Unknown values are skipped.
149
+ *
150
+ * @param {Record<string,*>} fm
151
+ * @returns {string}
152
+ */
153
+ function renderFrontmatter(fm) {
154
+ if (!fm || typeof fm !== 'object') return '';
155
+ const order = ['title', 'kind', 'tags', 'source', 'createdAt', 'updatedAt'];
156
+ const seen = new Set();
157
+ const lines = ['---'];
158
+ for (const key of order) {
159
+ if (!(key in fm)) continue;
160
+ seen.add(key);
161
+ lines.push(renderFmLine(key, fm[key]));
162
+ }
163
+ for (const key of Object.keys(fm)) {
164
+ if (seen.has(key)) continue;
165
+ lines.push(renderFmLine(key, fm[key]));
166
+ }
167
+ lines.push('---');
168
+ return lines.filter(Boolean).join('\n');
169
+ }
170
+
171
+ function renderFmLine(key, value) {
172
+ if (value === null || value === undefined) return '';
173
+ if (Array.isArray(value)) {
174
+ const items = value.map(v => (typeof v === 'string' ? v : JSON.stringify(v)));
175
+ return `${key}: [${items.join(', ')}]`;
176
+ }
177
+ if (typeof value === 'string') {
178
+ // Quote when it contains anything non-trivial.
179
+ if (/^[\w\-./:]+$/.test(value)) return `${key}: ${value}`;
180
+ return `${key}: ${JSON.stringify(value)}`;
181
+ }
182
+ return `${key}: ${JSON.stringify(value)}`;
183
+ }
184
+
185
+ /**
186
+ * Parse the frontmatter block from a markdown body. Returns `{frontmatter,
187
+ * body}`; missing frontmatter ⇒ `frontmatter = {}` and `body` is the input.
188
+ * Best-effort parser — accepts the lines this module emits and a few common
189
+ * variants. Does NOT pull in a YAML dep.
190
+ *
191
+ * @param {string} content
192
+ * @returns {{ frontmatter: Record<string,*>, body: string }}
193
+ */
194
+ export function parseEntry(content) {
195
+ if (typeof content !== 'string' || !content.startsWith('---')) {
196
+ return { frontmatter: {}, body: content || '' };
197
+ }
198
+ const end = content.indexOf('\n---', 4);
199
+ if (end === -1) return { frontmatter: {}, body: content };
200
+ const fmText = content.slice(4, end).trim();
201
+ const body = content.slice(end + 4).replace(/^\n+/, '');
202
+ const fm = {};
203
+ for (const rawLine of fmText.split('\n')) {
204
+ const line = rawLine.trim();
205
+ if (!line) continue;
206
+ const m = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
207
+ if (!m) continue;
208
+ const [, key, rest] = m;
209
+ fm[key] = parseFmValue(rest);
210
+ }
211
+ return { frontmatter: fm, body };
212
+ }
213
+
214
+ function parseFmValue(rest) {
215
+ if (rest === '') return '';
216
+ if (rest.startsWith('[') && rest.endsWith(']')) {
217
+ const inner = rest.slice(1, -1).trim();
218
+ if (!inner) return [];
219
+ return inner.split(',').map(s => {
220
+ const t = s.trim();
221
+ if (t.startsWith('"') && t.endsWith('"')) {
222
+ try { return JSON.parse(t); } catch { return t; }
223
+ }
224
+ return t;
225
+ });
226
+ }
227
+ if (rest.startsWith('"') && rest.endsWith('"')) {
228
+ try { return JSON.parse(rest); } catch { return rest.slice(1, -1); }
229
+ }
230
+ return rest;
231
+ }
232
+
233
+ // ─── entries ───────────────────────────────────────────────────
234
+
235
+ /**
236
+ * Create a new entry under (scope, title). Fails with `slug_exists` if the
237
+ * computed path already exists. Returns the relative path written.
238
+ *
239
+ * @param {{
240
+ * scope: Scope,
241
+ * title: string,
242
+ * body: string,
243
+ * tags?: string[],
244
+ * kind?: string,
245
+ * source?: string,
246
+ * date?: Date,
247
+ * root?: string,
248
+ * }} args
249
+ * @returns {Promise<{ path: string, abs: string }>}
250
+ */
251
+ export async function createEntry(args) {
252
+ const { scope, title, body, tags, kind, source, date, root = DEFAULT_MEMORY_ROOT } = args;
253
+ if (!title || typeof title !== 'string') throw new Error('createEntry: title required');
254
+ if (typeof body !== 'string') throw new Error('createEntry: body required (string)');
255
+ const rel = entryPathFor(scope, title, date);
256
+ const abs = join(root, rel);
257
+ await fsp.mkdir(dirname(abs), { recursive: true });
258
+ const fm = renderFrontmatter({
259
+ title,
260
+ kind: kind || 'note',
261
+ tags: Array.isArray(tags) && tags.length ? tags : undefined,
262
+ source: source || undefined,
263
+ createdAt: isoDate(date),
264
+ updatedAt: isoDate(date),
265
+ });
266
+ const content = `${fm}\n\n${body.trim()}\n`;
267
+ // O_EXCL — fail loudly on slug collision (DESIGN.md §9.1 atomicity).
268
+ let handle;
269
+ try {
270
+ handle = await fsp.open(abs, 'wx');
271
+ } catch (err) {
272
+ if (err && err.code === 'EEXIST') {
273
+ const e = new Error('slug_exists');
274
+ e.code = 'slug_exists';
275
+ e.path = rel;
276
+ throw e;
277
+ }
278
+ throw err;
279
+ }
280
+ try {
281
+ await handle.writeFile(content, 'utf8');
282
+ } finally {
283
+ await handle.close();
284
+ }
285
+ return { path: rel, abs };
286
+ }
287
+
288
+ /**
289
+ * Read an entry by its relative path; returns `null` if missing. Throws
290
+ * `acl_blocked` when the caller's `currentVpId` is given and the path is
291
+ * `vp/<other>/...` — the only hard ACL boundary.
292
+ *
293
+ * @param {string} relPath
294
+ * @param {{ root?: string, currentVpId?: string }} [opts]
295
+ * @returns {Promise<{ frontmatter: Record<string,*>, body: string, path: string } | null>}
296
+ */
297
+ export async function readEntry(relPath, opts = {}) {
298
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
299
+ if (!relPath || typeof relPath !== 'string') throw new Error('readEntry: relPath required');
300
+ if (currentVpId && isVpForeign(relPath, currentVpId)) {
301
+ const e = new Error('acl_blocked');
302
+ e.code = 'acl_blocked';
303
+ e.path = relPath;
304
+ throw e;
305
+ }
306
+ const abs = join(root, relPath);
307
+ let raw;
308
+ try {
309
+ raw = await fsp.readFile(abs, 'utf8');
310
+ } catch (err) {
311
+ if (err && err.code === 'ENOENT') return null;
312
+ throw err;
313
+ }
314
+ const { frontmatter, body } = parseEntry(raw);
315
+ return { frontmatter, body: body.replace(/\n+$/, ''), path: relPath };
316
+ }
317
+
318
+ /**
319
+ * @returns {boolean} true iff `relPath` is `vp/<other>/...` (other ≠ currentVpId).
320
+ */
321
+ export function isVpForeign(relPath, currentVpId) {
322
+ if (!relPath || !currentVpId) return false;
323
+ const m = /^vp\/([^/]+)\//.exec(relPath);
324
+ if (!m) return false;
325
+ return m[1] !== currentVpId;
326
+ }
327
+
328
+ // ─── index.md ──────────────────────────────────────────────────
329
+
330
+ /**
331
+ * Index row schema. `path` is the canonical, scope-rooted relative path.
332
+ * `updated` is YYYY-MM-DD UTC. `tags` is a comma-joined string for the
333
+ * markdown column; arrays are accepted as input and normalised.
334
+ *
335
+ * @typedef {{
336
+ * path: string,
337
+ * title: string,
338
+ * tags?: string | string[],
339
+ * kind?: string,
340
+ * updated?: string,
341
+ * }} IndexRow
342
+ */
343
+
344
+ /**
345
+ * Render the markdown table for `index.md` in a scope. Reverse-chronological
346
+ * — newest `updated` first — matching DESIGN.md §9.3 ("append on top").
347
+ *
348
+ * @param {Scope} scope
349
+ * @param {IndexRow[]} rows
350
+ * @returns {string}
351
+ */
352
+ export function renderIndex(scope, rows) {
353
+ const dir = scopeDir(scope);
354
+ const sorted = [...(rows || [])].sort((a, b) => {
355
+ const ax = (b.updated || '').localeCompare(a.updated || '');
356
+ return ax !== 0 ? ax : (a.path || '').localeCompare(b.path || '');
357
+ });
358
+ const lines = [
359
+ `# index — ${dir}`,
360
+ '',
361
+ '| path | title | tags | kind | updated |',
362
+ '| ---- | ----- | ---- | ---- | ------- |',
363
+ ];
364
+ for (const r of sorted) {
365
+ const path = (r.path || '').replace(/\|/g, '\\|');
366
+ const title = (r.title || '').replace(/\|/g, '\\|');
367
+ const tags = Array.isArray(r.tags) ? r.tags.join(',') : (r.tags || '');
368
+ const kind = r.kind || '';
369
+ const updated = r.updated || '';
370
+ lines.push(`| ${path} | ${title} | ${tags} | ${kind} | ${updated} |`);
371
+ }
372
+ return lines.join('\n') + '\n';
373
+ }
374
+
375
+ /**
376
+ * Parse `index.md` markdown table back into an array of rows. Tolerates
377
+ * extra whitespace, missing optional columns, and the index header line.
378
+ * Unknown / malformed lines are skipped silently.
379
+ *
380
+ * @param {string} content
381
+ * @returns {IndexRow[]}
382
+ */
383
+ export function parseIndex(content) {
384
+ if (typeof content !== 'string' || !content) return [];
385
+ const out = [];
386
+ for (const rawLine of content.split('\n')) {
387
+ const line = rawLine.trim();
388
+ if (!line.startsWith('|')) continue;
389
+ if (/^\|\s*-+/.test(line)) continue; // separator row
390
+ const cols = line.split('|').slice(1, -1).map(s => s.trim());
391
+ if (cols.length < 2) continue;
392
+ const [path, title, tags = '', kind = '', updated = ''] = cols;
393
+ if (!path || path === 'path') continue; // header row
394
+ out.push({
395
+ path,
396
+ title,
397
+ tags: tags ? tags.split(',').map(s => s.trim()).filter(Boolean) : [],
398
+ kind: kind || undefined,
399
+ updated: updated || undefined,
400
+ });
401
+ }
402
+ return out;
403
+ }
404
+
405
+ /**
406
+ * Read `index.md` for a scope and return the parsed rows. Missing index
407
+ * returns `[]` (cold-start scope).
408
+ *
409
+ * @param {Scope} scope
410
+ * @param {{ root?: string }} [opts]
411
+ * @returns {Promise<IndexRow[]>}
412
+ */
413
+ export async function readIndex(scope, opts = {}) {
414
+ const { root = DEFAULT_MEMORY_ROOT } = opts;
415
+ const abs = join(root, scopeDir(scope), 'index.md');
416
+ let raw;
417
+ try { raw = await fsp.readFile(abs, 'utf8'); }
418
+ catch (err) {
419
+ if (err && err.code === 'ENOENT') return [];
420
+ throw err;
421
+ }
422
+ return parseIndex(raw);
423
+ }
424
+
425
+ /**
426
+ * Atomically rewrite `index.md` for a scope.
427
+ *
428
+ * @param {Scope} scope
429
+ * @param {IndexRow[]} rows
430
+ * @param {{ root?: string }} [opts]
431
+ */
432
+ export async function writeIndex(scope, rows, opts = {}) {
433
+ const { root = DEFAULT_MEMORY_ROOT } = opts;
434
+ const abs = join(root, scopeDir(scope), 'index.md');
435
+ await atomicWrite(abs, renderIndex(scope, rows));
436
+ }
437
+
438
+ /**
439
+ * Upsert a single row keyed by `path`. Existing row with the same path is
440
+ * replaced; otherwise the row is prepended (kept in reverse-chronological
441
+ * order via `renderIndex`'s sort). Returns the updated row list.
442
+ *
443
+ * @param {Scope} scope
444
+ * @param {IndexRow} row
445
+ * @param {{ root?: string }} [opts]
446
+ * @returns {Promise<IndexRow[]>}
447
+ */
448
+ export async function upsertIndexRow(scope, row, opts = {}) {
449
+ if (!row || !row.path) throw new Error('upsertIndexRow: row.path required');
450
+ const rows = await readIndex(scope, opts);
451
+ const filtered = rows.filter(r => r.path !== row.path);
452
+ filtered.unshift(row);
453
+ await writeIndex(scope, filtered, opts);
454
+ return filtered;
455
+ }
456
+
457
+ /**
458
+ * Cap the rows surfaced to the router (DESIGN.md §9.3). Reverse-chrono;
459
+ * default K = 200. Caller passes the merged set; we trim and return.
460
+ *
461
+ * @param {IndexRow[]} rows
462
+ * @param {number} [k=200]
463
+ * @returns {IndexRow[]}
464
+ */
465
+ export function capIndexRows(rows, k = 200) {
466
+ if (!Array.isArray(rows)) return [];
467
+ if (rows.length <= k) return rows.slice();
468
+ return rows.slice(0, k);
469
+ }
470
+
471
+ // ─── summary.md ────────────────────────────────────────────────
472
+
473
+ /**
474
+ * Read the scope's `summary.md`. Empty / missing → ''.
475
+ *
476
+ * @param {Scope} scope
477
+ * @param {{ root?: string }} [opts]
478
+ * @returns {Promise<string>}
479
+ */
480
+ export async function readSummary(scope, opts = {}) {
481
+ const { root = DEFAULT_MEMORY_ROOT } = opts;
482
+ const abs = join(root, scopeDir(scope), 'summary.md');
483
+ try { return (await fsp.readFile(abs, 'utf8')).trim(); }
484
+ catch (err) {
485
+ if (err && err.code === 'ENOENT') return '';
486
+ throw err;
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Atomically rewrite the scope's `summary.md`. The body is trimmed; empty
492
+ * input writes an empty file (callers that wanted "delete summary" can use
493
+ * `fs.unlink` directly — we don't surface that here).
494
+ *
495
+ * @param {Scope} scope
496
+ * @param {string} body
497
+ * @param {{ root?: string }} [opts]
498
+ */
499
+ export async function writeSummary(scope, body, opts = {}) {
500
+ const { root = DEFAULT_MEMORY_ROOT } = opts;
501
+ const abs = join(root, scopeDir(scope), 'summary.md');
502
+ await atomicWrite(abs, `${(body || '').trim()}\n`);
503
+ }
504
+
505
+ // ─── ensure scope on disk ──────────────────────────────────────
506
+
507
+ /**
508
+ * Best-effort: ensure the scope's directory and an empty `entries/` exist.
509
+ * Idempotent. Use at boot or on first write to avoid ENOENT cascades.
510
+ *
511
+ * @param {Scope} scope
512
+ * @param {{ root?: string }} [opts]
513
+ */
514
+ export function ensureScopeSync(scope, opts = {}) {
515
+ const { root = DEFAULT_MEMORY_ROOT } = opts;
516
+ const dir = join(root, scopeDir(scope));
517
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
518
+ const entries = join(dir, 'entries');
519
+ if (!existsSync(entries)) mkdirSync(entries, { recursive: true });
520
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * router/vp-planner.js — DESIGN.md Phase 3a (router per-VP plans).
3
+ *
4
+ * The legacy `intent-classifier.js` decides "which thread continues this
5
+ * turn" — single-VP, single-plan. The multi-VP redesign (DESIGN.md §1.2.1)
6
+ * generalises that into a `plans[]` array: one plan per VP that should act
7
+ * this turn, in execution order. This module is the per-VP planner.
8
+ *
9
+ * Phase 3a scope: schema + validation + sequential fan-out runner. The LLM
10
+ * call itself is wired in Phase 3b along with `priorPlan` continuity. Until
11
+ * then, callers either (a) construct plans directly from override paths, or
12
+ * (b) wrap the legacy single-plan classifier and call `wrapLegacyDecision`.
13
+ *
14
+ * Non-goals here:
15
+ * - parallel fan-out (Phase 3.5).
16
+ * - thinking-mode resolution (handled at the dispatcher level alongside
17
+ * the UI > Router > VP > Global precedence chain — DESIGN.md §9.16).
18
+ * - `priorPlan` skip-router heuristic (Phase 3b).
19
+ *
20
+ * Shape contract (DESIGN.md §1.2.1):
21
+ *
22
+ * {
23
+ * action: 'continue' | 'switch_vp' | 'fork_task' | 'join_task' |
24
+ * 'broadcast' | 'noop',
25
+ * targetTaskId: string | null,
26
+ * plans: [
27
+ * {
28
+ * vpId: string,
29
+ * forwardQuery: { userOriginal: string, intent: string },
30
+ * preselect: { memoryPaths: string[], taskIds: string[] },
31
+ * thinking: 'high' | 'max' | null,
32
+ * thinkingReason: string,
33
+ * }
34
+ * ],
35
+ * reason: string,
36
+ * }
37
+ */
38
+
39
+ import { isVpForeign } from '../memory/scope-tree.js';
40
+
41
+ /** @typedef {{ userOriginal: string, intent: string }} ForwardQuery */
42
+ /** @typedef {{ memoryPaths: string[], taskIds: string[] }} Preselect */
43
+ /** @typedef {{
44
+ * vpId: string,
45
+ * forwardQuery: ForwardQuery,
46
+ * preselect: Preselect,
47
+ * thinking: 'high'|'max'|null,
48
+ * thinkingReason: string,
49
+ * }} VpPlan
50
+ */
51
+ /** @typedef {{
52
+ * action: 'continue'|'switch_vp'|'fork_task'|'join_task'|'broadcast'|'noop',
53
+ * targetTaskId: string | null,
54
+ * plans: VpPlan[],
55
+ * reason: string,
56
+ * }} RouterDecisionV2
57
+ */
58
+
59
+ const ALLOWED_ACTIONS = new Set([
60
+ 'continue', 'switch_vp', 'fork_task', 'join_task', 'broadcast', 'noop',
61
+ ]);
62
+
63
+ const ALLOWED_THINKING = new Set([null, 'high', 'max']);
64
+
65
+ /**
66
+ * Validate + canonicalise a router decision. Throws on truly malformed
67
+ * input (we want loud failures during Phase 3 wiring), but tolerates
68
+ * missing optional fields by filling defaults.
69
+ *
70
+ * @param {*} raw
71
+ * @returns {RouterDecisionV2}
72
+ */
73
+ export function validateDecision(raw) {
74
+ if (!raw || typeof raw !== 'object') {
75
+ throw new Error('validateDecision: decision must be an object');
76
+ }
77
+ const action = ALLOWED_ACTIONS.has(raw.action) ? raw.action : 'continue';
78
+ const targetTaskId = (typeof raw.targetTaskId === 'string' && raw.targetTaskId)
79
+ ? raw.targetTaskId : null;
80
+ if (!Array.isArray(raw.plans)) {
81
+ throw new Error('validateDecision: plans must be an array');
82
+ }
83
+ const plans = raw.plans.map(validatePlan);
84
+ const reason = typeof raw.reason === 'string' ? raw.reason : '';
85
+ return { action, targetTaskId, plans, reason };
86
+ }
87
+
88
+ /**
89
+ * @param {*} raw
90
+ * @returns {VpPlan}
91
+ */
92
+ export function validatePlan(raw) {
93
+ if (!raw || typeof raw !== 'object') {
94
+ throw new Error('validatePlan: plan must be an object');
95
+ }
96
+ if (typeof raw.vpId !== 'string' || !raw.vpId) {
97
+ throw new Error('validatePlan: vpId required');
98
+ }
99
+ const fq = raw.forwardQuery && typeof raw.forwardQuery === 'object'
100
+ ? raw.forwardQuery : {};
101
+ const userOriginal = typeof fq.userOriginal === 'string' ? fq.userOriginal : '';
102
+ const intent = typeof fq.intent === 'string' ? fq.intent : '';
103
+ const pre = raw.preselect && typeof raw.preselect === 'object'
104
+ ? raw.preselect : {};
105
+ const memoryPaths = Array.isArray(pre.memoryPaths)
106
+ ? pre.memoryPaths.filter(p => typeof p === 'string' && p)
107
+ : [];
108
+ const taskIds = Array.isArray(pre.taskIds)
109
+ ? pre.taskIds.filter(t => typeof t === 'string' && t)
110
+ : [];
111
+ const thinkingRaw = raw.thinking === undefined ? null : raw.thinking;
112
+ const thinking = ALLOWED_THINKING.has(thinkingRaw) ? thinkingRaw : null;
113
+ const thinkingReason = typeof raw.thinkingReason === 'string'
114
+ ? raw.thinkingReason : '';
115
+ return {
116
+ vpId: raw.vpId,
117
+ forwardQuery: { userOriginal, intent },
118
+ preselect: { memoryPaths, taskIds },
119
+ thinking,
120
+ thinkingReason,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Strip `vp/<other>/` paths from a plan's `preselect.memoryPaths`. The
126
+ * planner runs BEFORE the worker so this is the right place to enforce
127
+ * the cross-VP private-memory hard block (DESIGN.md §2.2). Returns a new
128
+ * plan; original is not mutated.
129
+ *
130
+ * @param {VpPlan} plan
131
+ * @returns {VpPlan}
132
+ */
133
+ export function stripForeignVpPaths(plan) {
134
+ if (!plan) return plan;
135
+ const own = plan.vpId;
136
+ const filtered = plan.preselect.memoryPaths.filter(p => !isVpForeign(p, own));
137
+ if (filtered.length === plan.preselect.memoryPaths.length) return plan;
138
+ return {
139
+ ...plan,
140
+ preselect: { ...plan.preselect, memoryPaths: filtered },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Produce a default single-plan decision for "explicit @vp" or "no router
146
+ * needed" paths. The dispatcher uses this when it has decided not to call
147
+ * the LLM router (DESIGN.md §1.2.1 scenario A — explicit @vp; or §9.15
148
+ * priorPlan skip).
149
+ *
150
+ * @param {{
151
+ * vpId: string,
152
+ * userOriginal: string,
153
+ * intent?: string,
154
+ * memoryPaths?: string[],
155
+ * taskIds?: string[],
156
+ * targetTaskId?: string | null,
157
+ * thinking?: 'high'|'max'|null,
158
+ * thinkingReason?: string,
159
+ * action?: RouterDecisionV2['action'],
160
+ * reason?: string,
161
+ * }} args
162
+ * @returns {RouterDecisionV2}
163
+ */
164
+ export function buildDirectDecision(args) {
165
+ const {
166
+ vpId, userOriginal, intent = '',
167
+ memoryPaths = [], taskIds = [],
168
+ targetTaskId = null,
169
+ thinking = null, thinkingReason = '',
170
+ action = 'continue', reason = 'direct',
171
+ } = args || {};
172
+ if (!vpId) throw new Error('buildDirectDecision: vpId required');
173
+ return validateDecision({
174
+ action,
175
+ targetTaskId,
176
+ plans: [{
177
+ vpId,
178
+ forwardQuery: { userOriginal, intent },
179
+ preselect: { memoryPaths, taskIds },
180
+ thinking,
181
+ thinkingReason,
182
+ }],
183
+ reason,
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Translate a legacy `intent-classifier` single-thread decision (action +
189
+ * targetThreadId) into the V2 plans schema. We treat the old `targetThreadId`
190
+ * as the `vpId` because — in the multi-VP redesign — every "thread" is a VP
191
+ * (groups are sessions, see DESIGN.md §0.1). Callers that still produce
192
+ * legacy decisions can pipe them through this until Phase 3b.
193
+ *
194
+ * Mapping:
195
+ * continue / switch → continue / switch_vp (single-VP plan)
196
+ * fork → fork_task (single-VP plan)
197
+ * anything else → continue
198
+ *
199
+ * @param {{
200
+ * action?: string,
201
+ * targetThreadId?: string,
202
+ * reason?: string,
203
+ * source?: string,
204
+ * }} legacy
205
+ * @param {string} userOriginal
206
+ * @returns {RouterDecisionV2}
207
+ */
208
+ export function wrapLegacyDecision(legacy, userOriginal = '') {
209
+ if (!legacy || typeof legacy !== 'object') {
210
+ return validateDecision({ action: 'noop', targetTaskId: null, plans: [], reason: '' });
211
+ }
212
+ const vpId = typeof legacy.targetThreadId === 'string' ? legacy.targetThreadId : '';
213
+ if (!vpId) {
214
+ return validateDecision({ action: 'noop', targetTaskId: null, plans: [], reason: legacy.reason || '' });
215
+ }
216
+ let action = 'continue';
217
+ if (legacy.action === 'switch') action = 'switch_vp';
218
+ else if (legacy.action === 'fork') action = 'fork_task';
219
+ else if (legacy.action === 'continue') action = 'continue';
220
+ return validateDecision({
221
+ action,
222
+ targetTaskId: null,
223
+ reason: legacy.reason || '',
224
+ plans: [{
225
+ vpId,
226
+ forwardQuery: { userOriginal, intent: '' },
227
+ preselect: { memoryPaths: [], taskIds: [] },
228
+ thinking: null,
229
+ thinkingReason: '',
230
+ }],
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Sequential fan-out runner. Calls `runOne(plan, index, prior)` for each
236
+ * plan in order, awaiting each before starting the next. The previous
237
+ * plans' results are passed via `prior` so a later plan can read what an
238
+ * earlier plan emitted (DESIGN.md §1.2.1 — "ordering is load bearing").
239
+ *
240
+ * Plans whose `vpId` is missing from `groupMemberIds` (when the caller
241
+ * supplies that whitelist) are skipped with a logged `skipped:not_member`
242
+ * entry — never silently routed to a non-member.
243
+ *
244
+ * Errors from `runOne` are caught, recorded, and the loop continues; the
245
+ * report holds whatever each plan produced. The dispatcher decides whether
246
+ * to surface or retry.
247
+ *
248
+ * @param {VpPlan[]} plans
249
+ * @param {(plan: VpPlan, index: number, prior: any[]) => Promise<*>} runOne
250
+ * @param {{ groupMemberIds?: string[] }} [opts]
251
+ * @returns {Promise<{ results: any[], errors: Array<{ index: number, error: Error }> }>}
252
+ */
253
+ export async function runPlansSequential(plans, runOne, opts = {}) {
254
+ if (!Array.isArray(plans)) throw new Error('runPlansSequential: plans array required');
255
+ if (typeof runOne !== 'function') throw new Error('runPlansSequential: runOne fn required');
256
+ const memberSet = Array.isArray(opts.groupMemberIds)
257
+ ? new Set(opts.groupMemberIds) : null;
258
+ const results = [];
259
+ const errors = [];
260
+ const prior = [];
261
+ for (let i = 0; i < plans.length; i += 1) {
262
+ const plan = plans[i];
263
+ if (memberSet && !memberSet.has(plan.vpId)) {
264
+ const skip = { index: i, vpId: plan.vpId, skipped: 'not_member' };
265
+ results.push(skip);
266
+ prior.push(skip);
267
+ continue;
268
+ }
269
+ try {
270
+ const out = await runOne(plan, i, prior);
271
+ results.push(out);
272
+ prior.push(out);
273
+ } catch (err) {
274
+ errors.push({ index: i, error: err });
275
+ // Insert a sentinel into prior so a later plan can see "the previous
276
+ // VP errored" rather than nothing — useful when a fallback VP is
277
+ // queued behind a primary.
278
+ prior.push({ index: i, vpId: plan.vpId, error: err });
279
+ }
280
+ }
281
+ return { results, errors };
282
+ }