@yeaft/webchat-agent 0.1.662 → 0.1.664
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/cli.js +0 -10
- package/unify/engine.js +9 -3
- package/unify/index.js +0 -1
- package/unify/init.js +0 -1
- package/unify/memory/ams-registry.js +57 -11
- package/unify/session.js +5 -49
- package/unify/stop-hooks.js +8 -44
- package/unify/memory/dream-prompt.js +0 -272
- package/unify/memory/dream.js +0 -783
- package/unify/memory/migrate-r6-to-v2.js +0 -462
- package/unify/memory/scan.js +0 -273
- package/unify/router/vp-planner.js +0 -341
package/unify/memory/scan.js
DELETED
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* scan.js — Memory header scanning and scope/tag matching
|
|
3
|
-
*
|
|
4
|
-
* Fast in-memory scanning of entry frontmatter for:
|
|
5
|
-
* - Scope tree traversal
|
|
6
|
-
* - Tag overlap scoring
|
|
7
|
-
* - Kind-based filtering
|
|
8
|
-
* - Stale entry detection (for Dream)
|
|
9
|
-
*
|
|
10
|
-
* Reference: yeaft-unify-core-systems.md §3.3, yeaft-unify-design.md §5.1
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { KINDS, KIND_PRIORITY, IMPORTANCE_WEIGHT, getAncestorScopes } from './types.js';
|
|
14
|
-
|
|
15
|
-
// ─── Scan Results ──────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @typedef {Object} ScanResult
|
|
19
|
-
* @property {object[]} entries — all parsed entries
|
|
20
|
-
* @property {Map<string, number>} scopeCount — scope → entry count
|
|
21
|
-
* @property {Map<string, number>} kindCount — kind → entry count
|
|
22
|
-
* @property {Map<string, Set<string>>} tagIndex — tag → set of entry names
|
|
23
|
-
* @property {number} totalEntries — total count
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Scan all entries from a MemoryStore and build indexes.
|
|
28
|
-
*
|
|
29
|
-
* @param {import('./store.js').MemoryStore} memoryStore
|
|
30
|
-
* @returns {ScanResult}
|
|
31
|
-
*/
|
|
32
|
-
export function scanEntries(memoryStore) {
|
|
33
|
-
const entries = memoryStore.listEntries();
|
|
34
|
-
|
|
35
|
-
const scopeCount = new Map();
|
|
36
|
-
const kindCount = new Map();
|
|
37
|
-
const tagIndex = new Map();
|
|
38
|
-
|
|
39
|
-
for (const entry of entries) {
|
|
40
|
-
// Scope count
|
|
41
|
-
const scope = entry.scope || 'global';
|
|
42
|
-
scopeCount.set(scope, (scopeCount.get(scope) || 0) + 1);
|
|
43
|
-
|
|
44
|
-
// Kind count
|
|
45
|
-
const kind = entry.kind || 'fact';
|
|
46
|
-
kindCount.set(kind, (kindCount.get(kind) || 0) + 1);
|
|
47
|
-
|
|
48
|
-
// Tag index
|
|
49
|
-
const tags = entry.tags || [];
|
|
50
|
-
for (const tag of tags) {
|
|
51
|
-
const lowerTag = tag.toLowerCase();
|
|
52
|
-
if (!tagIndex.has(lowerTag)) tagIndex.set(lowerTag, new Set());
|
|
53
|
-
tagIndex.get(lowerTag).add(entry.name);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return {
|
|
58
|
-
entries,
|
|
59
|
-
scopeCount,
|
|
60
|
-
kindCount,
|
|
61
|
-
tagIndex,
|
|
62
|
-
totalEntries: entries.length,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ─── Scoring Functions ─────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Score an entry for relevance to a query context.
|
|
70
|
-
*
|
|
71
|
-
* Scoring factors:
|
|
72
|
-
* - Scope match: exact=5, parent/child=3, global=1
|
|
73
|
-
* - Tag overlap: 2 per matching tag
|
|
74
|
-
* - Kind priority: see KIND_PRIORITY
|
|
75
|
-
* - Importance weight: see IMPORTANCE_WEIGHT
|
|
76
|
-
* - Frequency bonus: log2(frequency)
|
|
77
|
-
* - Recency bonus: entries updated in last 7 days get +2
|
|
78
|
-
*
|
|
79
|
-
* @param {object} entry — memory entry
|
|
80
|
-
* @param {{ scope?: string, tags?: string[], preferKinds?: string[] }} context
|
|
81
|
-
* @returns {number} — relevance score
|
|
82
|
-
*/
|
|
83
|
-
export function scoreEntry(entry, context = {}) {
|
|
84
|
-
let score = 0;
|
|
85
|
-
|
|
86
|
-
// Scope match
|
|
87
|
-
if (context.scope && entry.scope) {
|
|
88
|
-
if (entry.scope === context.scope) {
|
|
89
|
-
score += 5; // exact match
|
|
90
|
-
} else {
|
|
91
|
-
const ancestors = getAncestorScopes(context.scope);
|
|
92
|
-
if (ancestors.includes(entry.scope)) {
|
|
93
|
-
score += 3; // ancestor match
|
|
94
|
-
} else if (entry.scope.startsWith(context.scope + '/')) {
|
|
95
|
-
score += 3; // descendant match
|
|
96
|
-
} else if (entry.scope === 'global') {
|
|
97
|
-
score += 1; // global fallback
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Tag overlap
|
|
103
|
-
if (context.tags && context.tags.length > 0 && entry.tags) {
|
|
104
|
-
const entryTags = new Set(entry.tags.map(t => t.toLowerCase()));
|
|
105
|
-
for (const tag of context.tags) {
|
|
106
|
-
if (entryTags.has(tag.toLowerCase())) {
|
|
107
|
-
score += 2;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Kind priority
|
|
113
|
-
const kindPriority = KIND_PRIORITY[entry.kind] || 0;
|
|
114
|
-
score += kindPriority * 0.5;
|
|
115
|
-
|
|
116
|
-
// Preferred kinds bonus
|
|
117
|
-
if (context.preferKinds && context.preferKinds.includes(entry.kind)) {
|
|
118
|
-
score += 2;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Importance weight
|
|
122
|
-
const impWeight = IMPORTANCE_WEIGHT[entry.importance] || IMPORTANCE_WEIGHT.normal;
|
|
123
|
-
score += impWeight * 0.5;
|
|
124
|
-
|
|
125
|
-
// Frequency bonus (logarithmic)
|
|
126
|
-
const freq = entry.frequency || 1;
|
|
127
|
-
score += Math.log2(Math.max(freq, 1));
|
|
128
|
-
|
|
129
|
-
// Recency bonus
|
|
130
|
-
if (entry.updated_at) {
|
|
131
|
-
const daysSince = (Date.now() - new Date(entry.updated_at).getTime()) / (1000 * 60 * 60 * 24);
|
|
132
|
-
if (daysSince <= 7) score += 2;
|
|
133
|
-
else if (daysSince <= 30) score += 1;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return score;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ─── Stale Detection (for Dream) ────────────────────────────
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Find entries that are potentially stale.
|
|
143
|
-
*
|
|
144
|
-
* Stale criteria:
|
|
145
|
-
* - context entries older than 30 days
|
|
146
|
-
* - entries never recalled (frequency = 1) and older than 60 days
|
|
147
|
-
* - relation entries older than 90 days
|
|
148
|
-
*
|
|
149
|
-
* @param {object[]} entries
|
|
150
|
-
* @returns {object[]} — stale entries
|
|
151
|
-
*/
|
|
152
|
-
export function findStaleEntries(entries) {
|
|
153
|
-
const now = Date.now();
|
|
154
|
-
const stale = [];
|
|
155
|
-
|
|
156
|
-
for (const entry of entries) {
|
|
157
|
-
const updatedAt = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
|
|
158
|
-
const daysSince = (now - updatedAt) / (1000 * 60 * 60 * 24);
|
|
159
|
-
|
|
160
|
-
let isStale = false;
|
|
161
|
-
|
|
162
|
-
// Context entries become stale fast
|
|
163
|
-
if (entry.kind === 'context' && daysSince > 30) {
|
|
164
|
-
isStale = true;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Entries never recalled and old
|
|
168
|
-
if ((entry.frequency || 1) <= 1 && daysSince > 60) {
|
|
169
|
-
isStale = true;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Relations are volatile
|
|
173
|
-
if (entry.kind === 'relation' && daysSince > 90) {
|
|
174
|
-
isStale = true;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (isStale) {
|
|
178
|
-
stale.push({ ...entry, _daysSinceUpdate: Math.round(daysSince) });
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return stale;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ─── Duplicate Detection (for Dream Merge) ──────────────────
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Find groups of entries that are potentially duplicates.
|
|
189
|
-
* Entries are grouped if they share ≥2 tags AND the same kind.
|
|
190
|
-
*
|
|
191
|
-
* @param {object[]} entries
|
|
192
|
-
* @returns {object[][]} — groups of potentially duplicate entries
|
|
193
|
-
*/
|
|
194
|
-
export function findDuplicateGroups(entries) {
|
|
195
|
-
const groups = [];
|
|
196
|
-
const visited = new Set();
|
|
197
|
-
|
|
198
|
-
for (let i = 0; i < entries.length; i++) {
|
|
199
|
-
if (visited.has(i)) continue;
|
|
200
|
-
|
|
201
|
-
const group = [entries[i]];
|
|
202
|
-
const eTags = new Set((entries[i].tags || []).map(t => t.toLowerCase()));
|
|
203
|
-
|
|
204
|
-
for (let j = i + 1; j < entries.length; j++) {
|
|
205
|
-
if (visited.has(j)) continue;
|
|
206
|
-
if (entries[i].kind !== entries[j].kind) continue;
|
|
207
|
-
|
|
208
|
-
const jTags = new Set((entries[j].tags || []).map(t => t.toLowerCase()));
|
|
209
|
-
let overlap = 0;
|
|
210
|
-
for (const tag of eTags) {
|
|
211
|
-
if (jTags.has(tag)) overlap++;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (overlap >= 2) {
|
|
215
|
-
group.push(entries[j]);
|
|
216
|
-
visited.add(j);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (group.length > 1) {
|
|
221
|
-
visited.add(i);
|
|
222
|
-
groups.push(group);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return groups;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ─── Stats Summary ──────────────────────────────────────────
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Generate a text summary of memory state (for Dream prompts).
|
|
233
|
-
*
|
|
234
|
-
* @param {ScanResult} scan
|
|
235
|
-
* @returns {string}
|
|
236
|
-
*/
|
|
237
|
-
export function summarizeScan(scan) {
|
|
238
|
-
const lines = [];
|
|
239
|
-
|
|
240
|
-
lines.push(`Total entries: ${scan.totalEntries}`);
|
|
241
|
-
|
|
242
|
-
// Kind breakdown
|
|
243
|
-
const kindLines = [];
|
|
244
|
-
for (const kind of KINDS) {
|
|
245
|
-
const count = scan.kindCount.get(kind) || 0;
|
|
246
|
-
if (count > 0) kindLines.push(`${kind}: ${count}`);
|
|
247
|
-
}
|
|
248
|
-
if (kindLines.length > 0) {
|
|
249
|
-
lines.push(`Kinds: ${kindLines.join(', ')}`);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Scope breakdown (top 10)
|
|
253
|
-
const scopeEntries = [...scan.scopeCount.entries()]
|
|
254
|
-
.sort((a, b) => b[1] - a[1])
|
|
255
|
-
.slice(0, 10);
|
|
256
|
-
if (scopeEntries.length > 0) {
|
|
257
|
-
lines.push('Top scopes:');
|
|
258
|
-
for (const [scope, count] of scopeEntries) {
|
|
259
|
-
lines.push(` ${scope}: ${count}`);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Tag cloud (top 20)
|
|
264
|
-
const tagEntries = [...scan.tagIndex.entries()]
|
|
265
|
-
.map(([tag, names]) => [tag, names.size])
|
|
266
|
-
.sort((a, b) => b[1] - a[1])
|
|
267
|
-
.slice(0, 20);
|
|
268
|
-
if (tagEntries.length > 0) {
|
|
269
|
-
lines.push(`Top tags: ${tagEntries.map(([t, c]) => `${t}(${c})`).join(', ')}`);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
return lines.join('\n');
|
|
273
|
-
}
|
|
@@ -1,341 +0,0 @@
|
|
|
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/store-v2.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
|
-
}
|