@yeaft/webchat-agent 0.1.591 → 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 +1 -1
- package/unify/llm/chat-completions.js +26 -0
- package/unify/llm/openai-responses.js +53 -3
- package/unify/memory/scope-tree.js +520 -0
- package/unify/prompts.js +166 -0
- package/unify/router/vp-planner.js +282 -0
- package/unify/templates/harness/router-shape.md +49 -0
- package/unify/templates/harness/worker-shape.md +35 -0
package/package.json
CHANGED
|
@@ -45,6 +45,31 @@ function thinkingV1Enabled() {
|
|
|
45
45
|
return process.env.UNIFY_THINKING_V1 === '1';
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* task-DESIGN-v4: Chat Completions adapter is deprecated in favour of
|
|
50
|
+
* `openai-responses.js` (Responses API) for OpenAI-protocol providers and
|
|
51
|
+
* `anthropic.js` for Anthropic. This warning fires once per process the
|
|
52
|
+
* first time the adapter is instantiated, unless UNIFY_SUPPRESS_DEPRECATION=1.
|
|
53
|
+
* Removal is scheduled for Phase 7 of the multi-VP redesign — see
|
|
54
|
+
* `agent/unify/DESIGN.md` § "Migration Plan".
|
|
55
|
+
*/
|
|
56
|
+
let _chatCompletionsDeprecationWarned = false;
|
|
57
|
+
function warnChatCompletionsDeprecated() {
|
|
58
|
+
if (_chatCompletionsDeprecationWarned) return;
|
|
59
|
+
if (process.env.UNIFY_SUPPRESS_DEPRECATION === '1') {
|
|
60
|
+
_chatCompletionsDeprecationWarned = true;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
_chatCompletionsDeprecationWarned = true;
|
|
64
|
+
// eslint-disable-next-line no-console
|
|
65
|
+
console.warn(
|
|
66
|
+
'[unify] ChatCompletionsAdapter is deprecated. Migrate OpenAI-protocol '
|
|
67
|
+
+ 'providers to the Responses API (set provider.protocol="openai-responses"). '
|
|
68
|
+
+ 'This adapter will be removed in a future release. Set '
|
|
69
|
+
+ 'UNIFY_SUPPRESS_DEPRECATION=1 to silence this warning.'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
48
73
|
/**
|
|
49
74
|
* Check if a model ID is an OpenAI model that supports max_completion_tokens.
|
|
50
75
|
* OpenAI introduced max_completion_tokens with o1 and made it standard for
|
|
@@ -79,6 +104,7 @@ export class ChatCompletionsAdapter extends LLMAdapter {
|
|
|
79
104
|
super({ apiKey, baseUrl });
|
|
80
105
|
this.#apiKey = apiKey;
|
|
81
106
|
this.#baseUrl = baseUrl.replace(/\/+$/, ''); // strip trailing slash
|
|
107
|
+
warnChatCompletionsDeprecated();
|
|
82
108
|
}
|
|
83
109
|
|
|
84
110
|
/** Expose baseUrl for testing. */
|
|
@@ -33,9 +33,34 @@ import {
|
|
|
33
33
|
LLMServerError,
|
|
34
34
|
LLMAbortError,
|
|
35
35
|
} from './adapter.js';
|
|
36
|
+
import {
|
|
37
|
+
normalizeEffort,
|
|
38
|
+
getThinkingCapability,
|
|
39
|
+
} from '../models.js';
|
|
36
40
|
|
|
37
41
|
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Feature-flag accessor mirroring anthropic.js. UNIFY_THINKING_V1 is OFF by
|
|
45
|
+
* default; set env to '1' to enable thinking-mode field translation. Read
|
|
46
|
+
* lazily so tests can flip the flag between calls.
|
|
47
|
+
*/
|
|
48
|
+
function thinkingV1Enabled() {
|
|
49
|
+
return process.env.UNIFY_THINKING_V1 === '1';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Translate a normalised effort ('low'|'medium'|'high'|'max') into the value
|
|
54
|
+
* accepted by the OpenAI Responses `reasoning.effort` field. Responses today
|
|
55
|
+
* accepts 'low'|'medium'|'high' — 'max' degrades to 'high' to match the
|
|
56
|
+
* registry's normaliseEffort downgrade rule.
|
|
57
|
+
*/
|
|
58
|
+
function effortForResponses(effort) {
|
|
59
|
+
if (!effort) return null;
|
|
60
|
+
if (effort === 'max') return 'high';
|
|
61
|
+
return effort;
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
40
65
|
#apiKey;
|
|
41
66
|
#baseUrl;
|
|
@@ -200,9 +225,9 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
200
225
|
// ─── Streaming ──────────────────────────────────────────
|
|
201
226
|
|
|
202
227
|
/**
|
|
203
|
-
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, extraBody?: object, signal?: AbortSignal }} params
|
|
228
|
+
* @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal }} params
|
|
204
229
|
*/
|
|
205
|
-
async *stream({ model, system, messages, tools, maxTokens = 16384, extraBody, signal }) {
|
|
230
|
+
async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal }) {
|
|
206
231
|
if (signal?.aborted) throw new LLMAbortError();
|
|
207
232
|
|
|
208
233
|
const body = {
|
|
@@ -214,6 +239,20 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
214
239
|
if (system) body.instructions = system;
|
|
215
240
|
const translatedTools = this.#translateTools(tools);
|
|
216
241
|
if (translatedTools) body.tools = translatedTools;
|
|
242
|
+
|
|
243
|
+
// Inject Responses-API thinking-mode field. Mirrors anthropic.js gating:
|
|
244
|
+
// feature flag must be on, effort must be a known value, and the model's
|
|
245
|
+
// registry entry must declare thinkingProtocol === 'openai-reasoning'.
|
|
246
|
+
// Unknown / unsupported models silently drop the field.
|
|
247
|
+
const normEffort = normalizeEffort(effort);
|
|
248
|
+
if (thinkingV1Enabled() && normEffort) {
|
|
249
|
+
const cap = getThinkingCapability(model);
|
|
250
|
+
if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
|
|
251
|
+
const wireEffort = effortForResponses(normEffort);
|
|
252
|
+
if (wireEffort) body.reasoning = { effort: wireEffort };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
217
256
|
if (extraBody) Object.assign(body, extraBody);
|
|
218
257
|
|
|
219
258
|
let response;
|
|
@@ -375,7 +414,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
375
414
|
|
|
376
415
|
// ─── Non-streaming call() ───────────────────────────────
|
|
377
416
|
|
|
378
|
-
async call({ model, system, messages, maxTokens = 4096, extraBody, signal }) {
|
|
417
|
+
async call({ model, system, messages, maxTokens = 4096, effort, extraBody, signal }) {
|
|
379
418
|
if (signal?.aborted) throw new LLMAbortError();
|
|
380
419
|
|
|
381
420
|
const body = {
|
|
@@ -384,6 +423,17 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
|
|
|
384
423
|
max_output_tokens: maxTokens,
|
|
385
424
|
};
|
|
386
425
|
if (system) body.instructions = system;
|
|
426
|
+
|
|
427
|
+
// Mirror stream()'s thinking injection for non-streaming side queries.
|
|
428
|
+
const normEffort = normalizeEffort(effort);
|
|
429
|
+
if (thinkingV1Enabled() && normEffort) {
|
|
430
|
+
const cap = getThinkingCapability(model);
|
|
431
|
+
if (cap.supportsThinking && cap.thinkingProtocol === 'openai-reasoning') {
|
|
432
|
+
const wireEffort = effortForResponses(normEffort);
|
|
433
|
+
if (wireEffort) body.reasoning = { effort: wireEffort };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
387
437
|
if (extraBody) Object.assign(body, extraBody);
|
|
388
438
|
|
|
389
439
|
let response;
|
|
@@ -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
|
@@ -158,6 +158,11 @@ const RAW_TEMPLATES = {
|
|
|
158
158
|
modeUnified: readTemplate('mode-unified.md'),
|
|
159
159
|
modeDream: readTemplate('mode-dream.md'),
|
|
160
160
|
toolGuidance: readTemplate('tool-guidance.md'),
|
|
161
|
+
// Phase 1 — DESIGN.md "Migration Plan" harness fragments. Optional so
|
|
162
|
+
// older deployments without the templates still boot; buildWorkerPrompt /
|
|
163
|
+
// buildRouterPrompt callers will simply omit the section.
|
|
164
|
+
harnessWorkerShape: readTemplate('harness/worker-shape.md', { required: false }),
|
|
165
|
+
harnessRouterShape: readTemplate('harness/router-shape.md', { required: false }),
|
|
161
166
|
};
|
|
162
167
|
|
|
163
168
|
/**
|
|
@@ -637,3 +642,164 @@ function renderCoreMemory(coreMemory, lang, memoryTraceAvailable) {
|
|
|
637
642
|
}
|
|
638
643
|
return lines.join('\n');
|
|
639
644
|
}
|
|
645
|
+
|
|
646
|
+
// ─── Phase 1: Worker / Router prompt splits ──────────────────────
|
|
647
|
+
//
|
|
648
|
+
// DESIGN.md (multi-VP redesign) describes two distinct prompt shapes:
|
|
649
|
+
//
|
|
650
|
+
// • Worker prompt — what a VP sees when it executes a turn. Layered as
|
|
651
|
+
// A (identity + summaries) / B (router-preselected memory) / C (task
|
|
652
|
+
// scope) / D (turn scope).
|
|
653
|
+
// • Router prompt — what the per-VP Router sees before it decides
|
|
654
|
+
// plans[]. Identity-summary layer + recent group state, no task /
|
|
655
|
+
// turn-scope detail.
|
|
656
|
+
//
|
|
657
|
+
// To stay backwards-compatible with existing callers we KEEP
|
|
658
|
+
// `buildSystemPrompt` and treat the two new entry points as thin wrappers
|
|
659
|
+
// that:
|
|
660
|
+
// 1) compose Layer-A summaries (user / group / vp) into the right
|
|
661
|
+
// headed sections, and
|
|
662
|
+
// 2) prepend the matching harness/*-shape.md fragment when present.
|
|
663
|
+
//
|
|
664
|
+
// Subsequent phases will migrate engine.js / router.js to these entry
|
|
665
|
+
// points and start filling Layers B / C with the new memory tree. For
|
|
666
|
+
// now they exist primarily so tests can pin the contract.
|
|
667
|
+
|
|
668
|
+
const LAYER_A_HEADERS = {
|
|
669
|
+
en: {
|
|
670
|
+
user: '## summary_user',
|
|
671
|
+
group: '## summary_group',
|
|
672
|
+
vp: '## summary_vp',
|
|
673
|
+
},
|
|
674
|
+
zh: {
|
|
675
|
+
user: '## 用户总结',
|
|
676
|
+
group: '## 群组总结',
|
|
677
|
+
vp: '## VP 总结',
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Render Layer A's three rolling summaries (user / group / vp). Each is
|
|
683
|
+
* optional; missing or empty strings are skipped. Headers follow the
|
|
684
|
+
* `## summary_<scope>` convention so Layer-B/C/D headers don't collide.
|
|
685
|
+
*
|
|
686
|
+
* @param {{user?: string, group?: string, vp?: string}} summaries
|
|
687
|
+
* @param {'en'|'zh'} language
|
|
688
|
+
* @returns {string} concatenated block ('' when nothing to render)
|
|
689
|
+
*/
|
|
690
|
+
export function renderLayerASummaries(summaries, language = 'en') {
|
|
691
|
+
if (!summaries || typeof summaries !== 'object') return '';
|
|
692
|
+
const headers = LAYER_A_HEADERS[language] || LAYER_A_HEADERS.en;
|
|
693
|
+
const out = [];
|
|
694
|
+
for (const key of ['user', 'group', 'vp']) {
|
|
695
|
+
const body = typeof summaries[key] === 'string' ? summaries[key].trim() : '';
|
|
696
|
+
if (!body) continue;
|
|
697
|
+
out.push(`${headers[key]}\n${body}`);
|
|
698
|
+
}
|
|
699
|
+
return out.join('\n\n');
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Worker prompt entry point (DESIGN.md Phase 1).
|
|
704
|
+
*
|
|
705
|
+
* Layered output:
|
|
706
|
+
* harness/worker-shape — what each layer means (optional fragment)
|
|
707
|
+
* Layer A — buildSystemPrompt(...) output (identity + persona + Layer-A
|
|
708
|
+
* summaries via `summaries`)
|
|
709
|
+
* Layer B — `preselectedMemory` block (router-supplied)
|
|
710
|
+
* Layer C — `taskScope` block (active task summary + related-task window)
|
|
711
|
+
* Layer D — `turnScope` block (inbound envelope, in-flight turn notes)
|
|
712
|
+
*
|
|
713
|
+
* Layers B/C/D are passed in as already-rendered strings so this builder
|
|
714
|
+
* stays free of memory-store / task-store IO. Phase 2/3 will provide the
|
|
715
|
+
* real renderers; for now any caller can stub them.
|
|
716
|
+
*
|
|
717
|
+
* @param {{
|
|
718
|
+
* language?: 'en'|'zh',
|
|
719
|
+
* summaries?: {user?: string, group?: string, vp?: string},
|
|
720
|
+
* preselectedMemory?: string,
|
|
721
|
+
* taskScope?: string,
|
|
722
|
+
* turnScope?: string,
|
|
723
|
+
* includeShape?: boolean,
|
|
724
|
+
* ...rest: import('./prompts.js').buildSystemPrompt
|
|
725
|
+
* }} params
|
|
726
|
+
* @returns {string}
|
|
727
|
+
*/
|
|
728
|
+
export function buildWorkerPrompt(params = {}) {
|
|
729
|
+
const {
|
|
730
|
+
language = 'en',
|
|
731
|
+
summaries,
|
|
732
|
+
preselectedMemory,
|
|
733
|
+
taskScope,
|
|
734
|
+
turnScope,
|
|
735
|
+
includeShape = true,
|
|
736
|
+
...rest
|
|
737
|
+
} = params;
|
|
738
|
+
|
|
739
|
+
const parts = [];
|
|
740
|
+
|
|
741
|
+
// Optional harness — describes the layered shape.
|
|
742
|
+
if (includeShape) {
|
|
743
|
+
const shape = getTemplate('harnessWorkerShape', language);
|
|
744
|
+
if (shape) parts.push(shape);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Layer A — base + persona + summaries.
|
|
748
|
+
const baseBlock = buildSystemPrompt({ ...rest, language });
|
|
749
|
+
if (baseBlock) parts.push(baseBlock);
|
|
750
|
+
const summaryBlock = renderLayerASummaries(summaries, language);
|
|
751
|
+
if (summaryBlock) parts.push(summaryBlock);
|
|
752
|
+
|
|
753
|
+
// Layer B — router-preselected memory entries (rendered upstream).
|
|
754
|
+
if (typeof preselectedMemory === 'string' && preselectedMemory.trim()) {
|
|
755
|
+
parts.push(preselectedMemory.trim());
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Layer C — task scope.
|
|
759
|
+
if (typeof taskScope === 'string' && taskScope.trim()) {
|
|
760
|
+
parts.push(taskScope.trim());
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Layer D — turn scope (inbound envelope, in-flight turn notes).
|
|
764
|
+
if (typeof turnScope === 'string' && turnScope.trim()) {
|
|
765
|
+
parts.push(turnScope.trim());
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return parts.join('\n\n');
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Router prompt entry point (DESIGN.md Phase 1).
|
|
773
|
+
*
|
|
774
|
+
* The Router sees identity context (no persona — it speaks as a routing
|
|
775
|
+
* brain, not as any specific VP), the three Layer-A summaries, and a
|
|
776
|
+
* `routerContext` block prepared upstream (group roster, recent turns,
|
|
777
|
+
* pending tasks). Output schema is enforced by the harness fragment.
|
|
778
|
+
*
|
|
779
|
+
* @param {{
|
|
780
|
+
* language?: 'en'|'zh',
|
|
781
|
+
* summaries?: {user?: string, group?: string, vp?: string},
|
|
782
|
+
* routerContext?: string,
|
|
783
|
+
* includeShape?: boolean,
|
|
784
|
+
* }} params
|
|
785
|
+
* @returns {string}
|
|
786
|
+
*/
|
|
787
|
+
export function buildRouterPrompt(params = {}) {
|
|
788
|
+
const { language = 'en', summaries, routerContext, includeShape = true } = params;
|
|
789
|
+
const parts = [];
|
|
790
|
+
|
|
791
|
+
if (includeShape) {
|
|
792
|
+
const shape = getTemplate('harnessRouterShape', language);
|
|
793
|
+
if (shape) parts.push(shape);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const summaryBlock = renderLayerASummaries(summaries, language);
|
|
797
|
+
if (summaryBlock) parts.push(summaryBlock);
|
|
798
|
+
|
|
799
|
+
if (typeof routerContext === 'string' && routerContext.trim()) {
|
|
800
|
+
parts.push(routerContext.trim());
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return parts.join('\n\n');
|
|
804
|
+
}
|
|
805
|
+
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!-- lang:en -->
|
|
2
|
+
# Prompt Shape (Router)
|
|
3
|
+
|
|
4
|
+
You are the per-VP Router. You see the group's roster, summaries, recent
|
|
5
|
+
turns, and the latest user message. You return a JSON `plans[]` array — one
|
|
6
|
+
plan per VP that should act this turn, in execution order.
|
|
7
|
+
|
|
8
|
+
Each plan contains:
|
|
9
|
+
|
|
10
|
+
- `vpId` — which VP runs.
|
|
11
|
+
- `forwardQuery` — `{ userOriginal, intent }`. `userOriginal` is the
|
|
12
|
+
verbatim user text; `intent` is a one-line gloss in third person. Do not
|
|
13
|
+
rewrite the user's words; the worker will read both.
|
|
14
|
+
- `preselect` — `{ memoryPaths[], taskIds[] }`. Memory paths are
|
|
15
|
+
scope-prefixed (`user/`, `groups/<id>/`, `vp/<id>/`, `tasks/<id>/`).
|
|
16
|
+
- `thinking` — `null | "high" | "max"`. Set when the turn warrants
|
|
17
|
+
deeper reasoning; leave `null` to use the VP / global default.
|
|
18
|
+
- `thinkingReason` — short justification when `thinking` is non-null.
|
|
19
|
+
|
|
20
|
+
Hard rules:
|
|
21
|
+
- Never include `vp/<other>/` paths in `preselect.memoryPaths`. Cross-VP
|
|
22
|
+
private memory is hard-blocked.
|
|
23
|
+
- Plans run sequentially in the order returned. Treat ordering as load
|
|
24
|
+
bearing; the second plan can read the first plan's output.
|
|
25
|
+
- If no VP needs to act, return `{"plans": []}`.
|
|
26
|
+
<!-- lang:zh -->
|
|
27
|
+
# Prompt 结构(Router)
|
|
28
|
+
|
|
29
|
+
你是当前群组的 Router。你能看到群成员、总结、最近的回合,以及最新的用户
|
|
30
|
+
消息。你返回一个 JSON `plans[]` 数组——每个需要发言的 VP 一个 plan,按
|
|
31
|
+
执行顺序排列。
|
|
32
|
+
|
|
33
|
+
每个 plan 包含:
|
|
34
|
+
|
|
35
|
+
- `vpId`:要执行的 VP。
|
|
36
|
+
- `forwardQuery`:`{ userOriginal, intent }`。`userOriginal` 是用户的
|
|
37
|
+
原话;`intent` 是用第三人称写的一行意图说明。不要改写用户原话,Worker
|
|
38
|
+
会同时看到两者。
|
|
39
|
+
- `preselect`:`{ memoryPaths[], taskIds[] }`。memoryPaths 必须带 scope
|
|
40
|
+
前缀(`user/`、`groups/<id>/`、`vp/<id>/`、`tasks/<id>/`)。
|
|
41
|
+
- `thinking`:`null | "high" | "max"`。需要深度推理时设置,否则保持 null
|
|
42
|
+
使用 VP / 全局默认。
|
|
43
|
+
- `thinkingReason`:当 `thinking` 非空时的简短理由。
|
|
44
|
+
|
|
45
|
+
硬规则:
|
|
46
|
+
- `preselect.memoryPaths` 不允许包含 `vp/<其他 VP>/`。跨 VP 私有记忆硬
|
|
47
|
+
屏蔽。
|
|
48
|
+
- plans 按返回顺序串行执行;后一个 plan 可以读到前一个 plan 的输出。
|
|
49
|
+
- 如果本轮无需任何 VP 发言,返回 `{"plans": []}`。
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!-- lang:en -->
|
|
2
|
+
# Prompt Shape (Worker)
|
|
3
|
+
|
|
4
|
+
You are a Worker VP turn. Your prompt is built from four layers, in order:
|
|
5
|
+
|
|
6
|
+
- **Layer A — Identity & Context**: who you are (VP persona) plus the three
|
|
7
|
+
rolling summaries (user / group / vp). Slow-changing; updated by the
|
|
8
|
+
hourly Dream pass.
|
|
9
|
+
- **Layer B — Pre-selected Memory**: a small set of memory entries the
|
|
10
|
+
Router decided are relevant for this turn. Treat these as authoritative
|
|
11
|
+
context; do not re-fetch unless something is missing.
|
|
12
|
+
- **Layer C — Task Scope**: the active task summary and a short window of
|
|
13
|
+
related task threads. Empty when the turn has no task binding.
|
|
14
|
+
- **Layer D — Turn Scope**: the in-flight messages, tool traces, and any
|
|
15
|
+
inbound envelope (a forwarded handoff from another VP).
|
|
16
|
+
|
|
17
|
+
When information is missing, prefer to ask via tools rather than fabricating
|
|
18
|
+
it. When in doubt about scope, the order of trust is: turn → task →
|
|
19
|
+
preselected memory → identity summary.
|
|
20
|
+
<!-- lang:zh -->
|
|
21
|
+
# Prompt 结构(Worker)
|
|
22
|
+
|
|
23
|
+
你是一个 Worker VP 的回合。Prompt 由四层组成,自上而下:
|
|
24
|
+
|
|
25
|
+
- **A 层 · 身份与背景**:你的 VP 人设,以及三段缓慢更新的总结(用户 /
|
|
26
|
+
群组 / VP)。由每小时一次的 Dream 维护。
|
|
27
|
+
- **B 层 · 路由预选记忆**:Router 判定与本轮相关的少量记忆条目,视为权威
|
|
28
|
+
上下文,缺失时再去取。
|
|
29
|
+
- **C 层 · 任务范围**:当前任务的 summary,以及最近的相关任务窗口。无
|
|
30
|
+
任务绑定时该层为空。
|
|
31
|
+
- **D 层 · 当前回合**:本轮的消息、工具调用 trace,以及(如有)从其他
|
|
32
|
+
VP 转交而来的 inbound envelope。
|
|
33
|
+
|
|
34
|
+
信息缺失时优先用工具询问,不要编造。判定信息可信度的顺序:当前回合 >
|
|
35
|
+
任务范围 > 预选记忆 > 身份总结。
|