@yeaft/webchat-agent 0.1.592 → 0.1.594
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/memory/scope-tree.js +520 -0
- package/unify/prompts.js +40 -1
- package/unify/router/continuity.js +108 -0
- package/unify/router/thinking.js +78 -0
- package/unify/router/vp-planner.js +341 -0
- package/unify/templates/harness/router-handoff.md +34 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/unify/prompts.js
CHANGED
|
@@ -163,6 +163,8 @@ const RAW_TEMPLATES = {
|
|
|
163
163
|
// buildRouterPrompt callers will simply omit the section.
|
|
164
164
|
harnessWorkerShape: readTemplate('harness/worker-shape.md', { required: false }),
|
|
165
165
|
harnessRouterShape: readTemplate('harness/router-shape.md', { required: false }),
|
|
166
|
+
// Phase 3b — coordinator harness rule for inter-VP forwarding.
|
|
167
|
+
harnessRouterHandoff: readTemplate('harness/router-handoff.md', { required: false }),
|
|
166
168
|
};
|
|
167
169
|
|
|
168
170
|
/**
|
|
@@ -768,6 +770,39 @@ export function buildWorkerPrompt(params = {}) {
|
|
|
768
770
|
return parts.join('\n\n');
|
|
769
771
|
}
|
|
770
772
|
|
|
773
|
+
/**
|
|
774
|
+
* Render the previous turn's router plan as a `## prior_plan` block, so
|
|
775
|
+
* the router can decide whether to extend it or start fresh
|
|
776
|
+
* (DESIGN.md §9.15). Returns '' when there is no prior plan to render.
|
|
777
|
+
*
|
|
778
|
+
* @param {object|null|undefined} priorPlan
|
|
779
|
+
* @param {'en'|'zh'} [language='en']
|
|
780
|
+
* @returns {string}
|
|
781
|
+
*/
|
|
782
|
+
export function renderPriorPlan(priorPlan, language = 'en') {
|
|
783
|
+
if (!priorPlan || typeof priorPlan !== 'object') return '';
|
|
784
|
+
const header = language === 'zh' ? '## 上一轮 plan' : '## prior_plan';
|
|
785
|
+
const lines = [];
|
|
786
|
+
if (priorPlan.vpId) lines.push(`vpId: ${priorPlan.vpId}`);
|
|
787
|
+
const fq = priorPlan.forwardQuery;
|
|
788
|
+
if (fq && (fq.userOriginal || fq.intent)) {
|
|
789
|
+
if (fq.intent) lines.push(`intent: ${fq.intent}`);
|
|
790
|
+
if (fq.userOriginal) lines.push(`userOriginal: ${fq.userOriginal}`);
|
|
791
|
+
}
|
|
792
|
+
const pre = priorPlan.preselect;
|
|
793
|
+
if (pre) {
|
|
794
|
+
if (Array.isArray(pre.memoryPaths) && pre.memoryPaths.length) {
|
|
795
|
+
lines.push(`memoryPaths: ${pre.memoryPaths.join(', ')}`);
|
|
796
|
+
}
|
|
797
|
+
if (Array.isArray(pre.taskIds) && pre.taskIds.length) {
|
|
798
|
+
lines.push(`taskIds: ${pre.taskIds.join(', ')}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (priorPlan.thinking) lines.push(`thinking: ${priorPlan.thinking}`);
|
|
802
|
+
if (!lines.length) return '';
|
|
803
|
+
return `${header}\n${lines.join('\n')}`;
|
|
804
|
+
}
|
|
805
|
+
|
|
771
806
|
/**
|
|
772
807
|
* Router prompt entry point (DESIGN.md Phase 1).
|
|
773
808
|
*
|
|
@@ -780,12 +815,13 @@ export function buildWorkerPrompt(params = {}) {
|
|
|
780
815
|
* language?: 'en'|'zh',
|
|
781
816
|
* summaries?: {user?: string, group?: string, vp?: string},
|
|
782
817
|
* routerContext?: string,
|
|
818
|
+
* priorPlan?: object|null,
|
|
783
819
|
* includeShape?: boolean,
|
|
784
820
|
* }} params
|
|
785
821
|
* @returns {string}
|
|
786
822
|
*/
|
|
787
823
|
export function buildRouterPrompt(params = {}) {
|
|
788
|
-
const { language = 'en', summaries, routerContext, includeShape = true } = params;
|
|
824
|
+
const { language = 'en', summaries, routerContext, priorPlan, includeShape = true } = params;
|
|
789
825
|
const parts = [];
|
|
790
826
|
|
|
791
827
|
if (includeShape) {
|
|
@@ -796,6 +832,9 @@ export function buildRouterPrompt(params = {}) {
|
|
|
796
832
|
const summaryBlock = renderLayerASummaries(summaries, language);
|
|
797
833
|
if (summaryBlock) parts.push(summaryBlock);
|
|
798
834
|
|
|
835
|
+
const priorBlock = renderPriorPlan(priorPlan, language);
|
|
836
|
+
if (priorBlock) parts.push(priorBlock);
|
|
837
|
+
|
|
799
838
|
if (typeof routerContext === 'string' && routerContext.trim()) {
|
|
800
839
|
parts.push(routerContext.trim());
|
|
801
840
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router/continuity.js — DESIGN.md §9.15 priorPlan carry-back.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3b scope:
|
|
5
|
+
* - `attachRouterPlan(message, plan)` — write the plan as `_meta.routerPlan`
|
|
6
|
+
* on the assistant message that produced it.
|
|
7
|
+
* - `extractPriorPlan(messages, vpId)` — find the most recent assistant
|
|
8
|
+
* message belonging to the given VP and return its `_meta.routerPlan`.
|
|
9
|
+
* - `stripMetaForWire(messages)` — drop `_meta` before sending to the LLM
|
|
10
|
+
* (it's bookkeeping, never model-visible).
|
|
11
|
+
*
|
|
12
|
+
* The skip-router heuristic (§9.15 #1) is intentionally NOT implemented in
|
|
13
|
+
* Phase 3b — DESIGN.md §8 line 391 says "do NOT ship the skip-router
|
|
14
|
+
* heuristic yet". We just plumb the metadata; the dispatcher can decide.
|
|
15
|
+
*
|
|
16
|
+
* Per-VP attribution: an assistant message belongs to a VP when its
|
|
17
|
+
* `_meta.routerPlan.vpId` matches; we never guess from content. First turn
|
|
18
|
+
* of a fresh group has no priorPlan — that is the expected cold-start.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** @typedef {{
|
|
22
|
+
* vpId: string,
|
|
23
|
+
* forwardQuery?: { userOriginal?: string, intent?: string },
|
|
24
|
+
* preselect?: { memoryPaths?: string[], taskIds?: string[] },
|
|
25
|
+
* thinking?: 'high'|'max'|null,
|
|
26
|
+
* thinkingReason?: string,
|
|
27
|
+
* }} RouterPlanLike
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Attach a router plan to an assistant message. Mutates `message` in place
|
|
32
|
+
* and returns it. We mutate (rather than clone) because the caller is the
|
|
33
|
+
* engine appending to its own `conversationMessages` array — cloning would
|
|
34
|
+
* just discard the work.
|
|
35
|
+
*
|
|
36
|
+
* Tool messages do not carry plans (no plan attached to a tool result).
|
|
37
|
+
*
|
|
38
|
+
* @param {object} message
|
|
39
|
+
* @param {RouterPlanLike|null|undefined} plan
|
|
40
|
+
* @returns {object}
|
|
41
|
+
*/
|
|
42
|
+
export function attachRouterPlan(message, plan) {
|
|
43
|
+
if (!message || typeof message !== 'object') return message;
|
|
44
|
+
if (message.role !== 'assistant') return message;
|
|
45
|
+
if (!plan || typeof plan !== 'object' || !plan.vpId) return message;
|
|
46
|
+
message._meta = message._meta || {};
|
|
47
|
+
message._meta.routerPlan = {
|
|
48
|
+
vpId: plan.vpId,
|
|
49
|
+
forwardQuery: plan.forwardQuery
|
|
50
|
+
? {
|
|
51
|
+
userOriginal: plan.forwardQuery.userOriginal || '',
|
|
52
|
+
intent: plan.forwardQuery.intent || '',
|
|
53
|
+
} : undefined,
|
|
54
|
+
preselect: plan.preselect
|
|
55
|
+
? {
|
|
56
|
+
memoryPaths: Array.isArray(plan.preselect.memoryPaths)
|
|
57
|
+
? [...plan.preselect.memoryPaths] : [],
|
|
58
|
+
taskIds: Array.isArray(plan.preselect.taskIds)
|
|
59
|
+
? [...plan.preselect.taskIds] : [],
|
|
60
|
+
} : undefined,
|
|
61
|
+
thinking: plan.thinking ?? null,
|
|
62
|
+
thinkingReason: plan.thinkingReason || '',
|
|
63
|
+
};
|
|
64
|
+
return message;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Walk `messages` from the end, return the most recent assistant message's
|
|
69
|
+
* `_meta.routerPlan` whose `vpId` matches. Returns null if none found —
|
|
70
|
+
* that's a cold start, not an error.
|
|
71
|
+
*
|
|
72
|
+
* @param {object[]} messages
|
|
73
|
+
* @param {string} vpId
|
|
74
|
+
* @returns {RouterPlanLike | null}
|
|
75
|
+
*/
|
|
76
|
+
export function extractPriorPlan(messages, vpId) {
|
|
77
|
+
if (!Array.isArray(messages) || !vpId) return null;
|
|
78
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
79
|
+
const m = messages[i];
|
|
80
|
+
if (!m || m.role !== 'assistant') continue;
|
|
81
|
+
const plan = m._meta && m._meta.routerPlan;
|
|
82
|
+
if (plan && plan.vpId === vpId) return plan;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Return a copy of the messages array with `_meta` stripped from every
|
|
89
|
+
* message. The serialisers (anthropic/openai-responses) read this; it is
|
|
90
|
+
* NEVER part of the wire payload. Cheap because we only shallow-clone the
|
|
91
|
+
* messages that actually have `_meta`.
|
|
92
|
+
*
|
|
93
|
+
* @param {object[]} messages
|
|
94
|
+
* @returns {object[]}
|
|
95
|
+
*/
|
|
96
|
+
export function stripMetaForWire(messages) {
|
|
97
|
+
if (!Array.isArray(messages)) return messages;
|
|
98
|
+
let mutated = false;
|
|
99
|
+
const out = messages.map(m => {
|
|
100
|
+
if (m && typeof m === 'object' && '_meta' in m) {
|
|
101
|
+
mutated = true;
|
|
102
|
+
const { _meta, ...rest } = m;
|
|
103
|
+
return rest;
|
|
104
|
+
}
|
|
105
|
+
return m;
|
|
106
|
+
});
|
|
107
|
+
return mutated ? out : messages;
|
|
108
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router/thinking.js — DESIGN.md §9.16 thinking-mode precedence chain.
|
|
3
|
+
*
|
|
4
|
+
* Resolves the final `thinking` value the engine should pass to the
|
|
5
|
+
* adapter, given the four signal sources:
|
|
6
|
+
*
|
|
7
|
+
* 1. UI override (highest) — submitOptions / topbar selector
|
|
8
|
+
* 2. Router plan — — per-plan thinking field
|
|
9
|
+
* 3. VP default — — vp/<id>/role.md frontmatter
|
|
10
|
+
* 4. Global default (lowest) — config.thinking.default
|
|
11
|
+
*
|
|
12
|
+
* Allowed values: `'high' | 'max' | null`. (`null` ⇒ adapter drops the
|
|
13
|
+
* field; provider-specific normalisation happens at the adapter via
|
|
14
|
+
* `models.js#normalizeEffort`.)
|
|
15
|
+
*
|
|
16
|
+
* Continuity rule (§9.16): when no UI override is in force AND the router
|
|
17
|
+
* did not change its recommendation versus the prior plan, keep the prior
|
|
18
|
+
* plan's value. Anthropic prompt cache keys include the thinking field;
|
|
19
|
+
* unstable values cause prefix re-encoding every turn.
|
|
20
|
+
*
|
|
21
|
+
* The `allowRouterEscalate: false` config gate hard-blocks the router
|
|
22
|
+
* from bumping below→`max`. UI overrides bypass that gate (they're the
|
|
23
|
+
* user's direct intent, not a heuristic).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ALLOWED = new Set([null, 'high', 'max']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {*} v
|
|
30
|
+
* @returns {'high'|'max'|null}
|
|
31
|
+
*/
|
|
32
|
+
function clean(v) {
|
|
33
|
+
if (v === undefined) return null;
|
|
34
|
+
return ALLOWED.has(v) ? v : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {{
|
|
39
|
+
* uiOverride?: 'high'|'max'|null,
|
|
40
|
+
* routerPlan?: 'high'|'max'|null,
|
|
41
|
+
* priorPlan?: 'high'|'max'|null,
|
|
42
|
+
* vpDefault?: 'high'|'max'|null,
|
|
43
|
+
* globalDefault?: 'high'|'max'|null,
|
|
44
|
+
* allowRouterEscalate?: boolean,
|
|
45
|
+
* }} signals
|
|
46
|
+
* @returns {{ value: 'high'|'max'|null, source: 'ui'|'router'|'prior'|'vp'|'global'|'default' }}
|
|
47
|
+
*/
|
|
48
|
+
export function resolveThinking(signals = {}) {
|
|
49
|
+
const ui = clean(signals.uiOverride);
|
|
50
|
+
if (ui) return { value: ui, source: 'ui' };
|
|
51
|
+
|
|
52
|
+
const router = clean(signals.routerPlan);
|
|
53
|
+
const prior = clean(signals.priorPlan);
|
|
54
|
+
const vp = clean(signals.vpDefault);
|
|
55
|
+
const global_ = clean(signals.globalDefault);
|
|
56
|
+
const escalateOk = signals.allowRouterEscalate !== false;
|
|
57
|
+
|
|
58
|
+
// Continuity: if router agrees with prior or is silent, prefer prior to
|
|
59
|
+
// keep the cache key stable.
|
|
60
|
+
if (router && prior && router === prior) {
|
|
61
|
+
return { value: prior, source: 'prior' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (router) {
|
|
65
|
+
// allowRouterEscalate=false hard-blocks router from emitting 'max'
|
|
66
|
+
// when the baseline is 'high'.
|
|
67
|
+
const baseline = prior || vp || global_ || 'high';
|
|
68
|
+
if (!escalateOk && router === 'max' && baseline !== 'max') {
|
|
69
|
+
return { value: baseline, source: prior ? 'prior' : (vp ? 'vp' : 'global') };
|
|
70
|
+
}
|
|
71
|
+
return { value: router, source: 'router' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (prior) return { value: prior, source: 'prior' };
|
|
75
|
+
if (vp) return { value: vp, source: 'vp' };
|
|
76
|
+
if (global_) return { value: global_, source: 'global' };
|
|
77
|
+
return { value: 'high', source: 'default' };
|
|
78
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
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
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Parallel fan-out runner (Phase 3.5). Calls `runOne(plan, index)` for each
|
|
286
|
+
* plan concurrently, with optional `concurrency` cap. Results are returned
|
|
287
|
+
* in input order regardless of completion order. Errors from `runOne` are
|
|
288
|
+
* caught per-plan and DO NOT abort siblings (DESIGN.md §9.1 — concurrent
|
|
289
|
+
* VP turns must be independent).
|
|
290
|
+
*
|
|
291
|
+
* NOTE: parallel mode loses the `prior[]` channel that the sequential
|
|
292
|
+
* runner provides. Callers that need plan N to read plan N-1's output must
|
|
293
|
+
* use `runPlansSequential`. The dispatcher chooses based on whether the
|
|
294
|
+
* plans share a `targetTaskId` (parallel-safe) or pipeline data
|
|
295
|
+
* (sequential-only).
|
|
296
|
+
*
|
|
297
|
+
* @param {VpPlan[]} plans
|
|
298
|
+
* @param {(plan: VpPlan, index: number) => Promise<*>} runOne
|
|
299
|
+
* @param {{ groupMemberIds?: string[], concurrency?: number }} [opts]
|
|
300
|
+
* @returns {Promise<{ results: any[], errors: Array<{ index: number, error: Error }> }>}
|
|
301
|
+
*/
|
|
302
|
+
export async function runPlansParallel(plans, runOne, opts = {}) {
|
|
303
|
+
if (!Array.isArray(plans)) throw new Error('runPlansParallel: plans array required');
|
|
304
|
+
if (typeof runOne !== 'function') throw new Error('runPlansParallel: runOne fn required');
|
|
305
|
+
const memberSet = Array.isArray(opts.groupMemberIds)
|
|
306
|
+
? new Set(opts.groupMemberIds) : null;
|
|
307
|
+
const concurrency = Number.isFinite(opts.concurrency) && opts.concurrency > 0
|
|
308
|
+
? Math.floor(opts.concurrency) : Infinity;
|
|
309
|
+
|
|
310
|
+
const results = new Array(plans.length);
|
|
311
|
+
const errors = [];
|
|
312
|
+
let nextIdx = 0;
|
|
313
|
+
|
|
314
|
+
const runSlot = async () => {
|
|
315
|
+
// Workers pull tasks from a shared queue index — preserves backpressure
|
|
316
|
+
// when concurrency < plans.length without per-task scheduling overhead.
|
|
317
|
+
while (true) {
|
|
318
|
+
const i = nextIdx;
|
|
319
|
+
nextIdx += 1;
|
|
320
|
+
if (i >= plans.length) return;
|
|
321
|
+
const plan = plans[i];
|
|
322
|
+
if (memberSet && !memberSet.has(plan.vpId)) {
|
|
323
|
+
results[i] = { index: i, vpId: plan.vpId, skipped: 'not_member' };
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
results[i] = await runOne(plan, i);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
errors.push({ index: i, error: err });
|
|
330
|
+
results[i] = { index: i, vpId: plan.vpId, error: err };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const workerCount = Math.min(plans.length, concurrency);
|
|
336
|
+
const workers = [];
|
|
337
|
+
for (let w = 0; w < workerCount; w += 1) workers.push(runSlot());
|
|
338
|
+
await Promise.all(workers);
|
|
339
|
+
|
|
340
|
+
return { results, errors };
|
|
341
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!-- lang:en -->
|
|
2
|
+
# Harness — Router Handoff
|
|
3
|
+
|
|
4
|
+
If, while drafting your reply, you realise you are the wrong VP for this
|
|
5
|
+
turn, hand off instead of guessing. Call `route_forward(targetVpId, reason)`
|
|
6
|
+
with a short, actionable reason. The next turn becomes the receiving VP's
|
|
7
|
+
turn with your reason as the inbound envelope — they act with no other
|
|
8
|
+
context from you.
|
|
9
|
+
|
|
10
|
+
Use this when:
|
|
11
|
+
|
|
12
|
+
- The user's question is outside your expertise and another VP in the
|
|
13
|
+
group clearly owns it.
|
|
14
|
+
- Your read of the situation is "this is comms not kernel" / "this is
|
|
15
|
+
legal not engineering" — name the boundary.
|
|
16
|
+
|
|
17
|
+
Do NOT use this to dodge hard questions you legitimately own. The router
|
|
18
|
+
already picked you; only forward when the topic genuinely belongs to
|
|
19
|
+
someone else.
|
|
20
|
+
<!-- lang:zh -->
|
|
21
|
+
# Harness — Router 转交
|
|
22
|
+
|
|
23
|
+
如果你在起草回复时发现本轮应该由其他 VP 来回答,请直接转交,而不是
|
|
24
|
+
强答。调用 `route_forward(targetVpId, reason)` 并给出简短可操作的原因。
|
|
25
|
+
下一轮变为目标 VP 的回合,你给的 reason 即为他们看到的入站信封——他
|
|
26
|
+
们不会读到你的其他上下文。
|
|
27
|
+
|
|
28
|
+
适用场景:
|
|
29
|
+
|
|
30
|
+
- 用户的问题超出你的专业范围,群里另一个 VP 显然更合适。
|
|
31
|
+
- 你判断「这是沟通不是内核」/「这是法务不是工程」——说出边界。
|
|
32
|
+
|
|
33
|
+
不要用它来回避你确实该回答的问题。Router 既然选了你,只有当话题
|
|
34
|
+
确实属于他人时才转交。
|