@yeaft/webchat-agent 0.1.526 → 0.1.528
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/connection/message-router.js +18 -1
- package/package.json +1 -1
- package/unify/routing/index.js +2 -0
- package/unify/routing/loop-guard.js +93 -4
- package/unify/vp/vp-crud.js +201 -0
- package/unify/web-bridge.js +100 -0
|
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
|
|
|
36
36
|
import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
|
|
37
37
|
import { loadMcpServers, updateMcpConfig } from '../mcp.js';
|
|
38
38
|
import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.js';
|
|
39
|
-
import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe } from '../unify/web-bridge.js';
|
|
39
|
+
import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead } from '../unify/web-bridge.js';
|
|
40
40
|
|
|
41
41
|
export async function handleMessage(msg) {
|
|
42
42
|
switch (msg.type) {
|
|
@@ -403,6 +403,23 @@ export async function handleMessage(msg) {
|
|
|
403
403
|
handleUnifyVpSubscribe(msg);
|
|
404
404
|
break;
|
|
405
405
|
|
|
406
|
+
// task-334-ui-g: VP CRUD (create / update / delete / read-single).
|
|
407
|
+
// All four reply via `vp_crud_result`; VpLoader's rescan emits the
|
|
408
|
+
// authoritative `vp_updated` / `vp_removed` events so the store stays
|
|
409
|
+
// in sync without a bespoke ack path.
|
|
410
|
+
case 'unify_vp_create':
|
|
411
|
+
handleUnifyVpCreate(msg);
|
|
412
|
+
break;
|
|
413
|
+
case 'unify_vp_update':
|
|
414
|
+
handleUnifyVpUpdate(msg);
|
|
415
|
+
break;
|
|
416
|
+
case 'unify_vp_delete':
|
|
417
|
+
handleUnifyVpDelete(msg);
|
|
418
|
+
break;
|
|
419
|
+
case 'unify_vp_read':
|
|
420
|
+
handleUnifyVpRead(msg);
|
|
421
|
+
break;
|
|
422
|
+
|
|
406
423
|
// Expert roles definition (for ExpertPanel detail view)
|
|
407
424
|
case 'get_expert_roles': {
|
|
408
425
|
const { getExpertRolesDefinition } = await import('../expert-roles.js');
|
package/package.json
CHANGED
package/unify/routing/index.js
CHANGED
|
@@ -18,6 +18,22 @@
|
|
|
18
18
|
* The guard is a pure in-memory helper — no persistence — because the
|
|
19
19
|
* threat model is one runaway turn storm within a single process tick.
|
|
20
20
|
*
|
|
21
|
+
* Long-running process hygiene (N1, task-334d-followup):
|
|
22
|
+
* The `hits` Map is keyed by "groupId::vpId" and would otherwise grow
|
|
23
|
+
* unboundedly over a long session. Two complementary bounds:
|
|
24
|
+
* - TTL sweep: on each NEW key insert, drop entries whose most
|
|
25
|
+
* recent hit is older than `ttlMultiplier × windowMs` (default 2×).
|
|
26
|
+
* Those entries can never throttle anyone regardless — their rate
|
|
27
|
+
* window is already fully expired. Amortised O(n) but only on new
|
|
28
|
+
* keys, so normal hot-path cost stays O(1).
|
|
29
|
+
* - LRU cap: after the TTL sweep, if size > maxKeys (default 1000)
|
|
30
|
+
* the Map's insertion-order head (oldest-used key) is evicted until
|
|
31
|
+
* back under the cap. `check()` and `record()` both `touch()` a key
|
|
32
|
+
* (delete+re-set) so recency is refreshed on every access.
|
|
33
|
+
* Behavior invariants preserved: the `'all'` broadcast sentinel, the
|
|
34
|
+
* `now()` injection seam, and the chain-depth check are untouched — only
|
|
35
|
+
* the eviction path is new.
|
|
36
|
+
*
|
|
21
37
|
* Integration contract (routing/router.js):
|
|
22
38
|
* - router stamps envelope.meta.causedBy = [...prevChain, currentMsgId]
|
|
23
39
|
* - router calls `guard.check({ groupId, targetVpId, chain })` BEFORE
|
|
@@ -30,6 +46,8 @@
|
|
|
30
46
|
export const MAX_CHAIN_DEPTH = 10;
|
|
31
47
|
export const DEFAULT_WINDOW_MS = 5_000;
|
|
32
48
|
export const DEFAULT_MAX_HITS_PER_WINDOW = 8;
|
|
49
|
+
export const DEFAULT_MAX_KEYS = 1_000;
|
|
50
|
+
export const DEFAULT_TTL_MULTIPLIER = 2;
|
|
33
51
|
|
|
34
52
|
/**
|
|
35
53
|
* Build a new loop guard. Safe to share across a single web-bridge process.
|
|
@@ -38,17 +56,24 @@ export const DEFAULT_MAX_HITS_PER_WINDOW = 8;
|
|
|
38
56
|
* maxChainDepth?: number,
|
|
39
57
|
* windowMs?: number,
|
|
40
58
|
* maxHitsPerWindow?: number,
|
|
41
|
-
*
|
|
59
|
+
* maxKeys?: number, // N1: LRU cap (default 1000)
|
|
60
|
+
* ttlMultiplier?: number, // N1: evict keys idle > ttlMultiplier*windowMs
|
|
61
|
+
* now?: () => number, // injectable for tests
|
|
42
62
|
* }} [options]
|
|
43
63
|
*/
|
|
44
64
|
export function createLoopGuard(options = {}) {
|
|
45
65
|
const maxChainDepth = options.maxChainDepth ?? MAX_CHAIN_DEPTH;
|
|
46
66
|
const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
|
|
47
67
|
const maxHits = options.maxHitsPerWindow ?? DEFAULT_MAX_HITS_PER_WINDOW;
|
|
68
|
+
const maxKeys = options.maxKeys ?? DEFAULT_MAX_KEYS;
|
|
69
|
+
const ttlMultiplier = options.ttlMultiplier ?? DEFAULT_TTL_MULTIPLIER;
|
|
48
70
|
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
49
71
|
|
|
50
|
-
/** Map<"groupId::vpId", number[]> — sorted ascending timestamps.
|
|
72
|
+
/** Map<"groupId::vpId", number[]> — sorted ascending timestamps.
|
|
73
|
+
* Map insertion order doubles as LRU recency: touching (delete+set) on
|
|
74
|
+
* every access keeps the oldest-used entry at the front for eviction. */
|
|
51
75
|
const hits = new Map();
|
|
76
|
+
let evictions = 0;
|
|
52
77
|
|
|
53
78
|
function key(groupId, vpId) { return `${groupId}::${vpId}`; }
|
|
54
79
|
|
|
@@ -58,6 +83,47 @@ export function createLoopGuard(options = {}) {
|
|
|
58
83
|
if (i > 0) arr.splice(0, i);
|
|
59
84
|
}
|
|
60
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Touch a key → move to the Map's insertion tail (most-recently-used).
|
|
88
|
+
* Used on BOTH check() and record() paths so a blocked-but-checked
|
|
89
|
+
* target is kept warm as long as something keeps referencing it.
|
|
90
|
+
*/
|
|
91
|
+
function touch(k, arr) {
|
|
92
|
+
hits.delete(k);
|
|
93
|
+
hits.set(k, arr);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Opportunistic TTL sweep: drop keys whose last hit is older than
|
|
98
|
+
* ttlMultiplier × windowMs (i.e. their rate window is fully expired and
|
|
99
|
+
* stale). Called on insert to amortise cleanup across normal traffic,
|
|
100
|
+
* so we never scan on the hot read path.
|
|
101
|
+
*/
|
|
102
|
+
function sweepExpired() {
|
|
103
|
+
const ttlCutoff = now() - ttlMultiplier * windowMs;
|
|
104
|
+
for (const [k, arr] of hits) {
|
|
105
|
+
// arr is sorted ascending; the tail is the most-recent hit.
|
|
106
|
+
const last = arr.length > 0 ? arr[arr.length - 1] : -Infinity;
|
|
107
|
+
if (last < ttlCutoff) {
|
|
108
|
+
hits.delete(k);
|
|
109
|
+
evictions += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Enforce the hard LRU cap. Called on insert AFTER sweepExpired so
|
|
116
|
+
* only genuinely hot but stale-enough entries get evicted.
|
|
117
|
+
*/
|
|
118
|
+
function enforceCap() {
|
|
119
|
+
while (hits.size > maxKeys) {
|
|
120
|
+
const oldest = hits.keys().next().value;
|
|
121
|
+
if (oldest === undefined) break;
|
|
122
|
+
hits.delete(oldest);
|
|
123
|
+
evictions += 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
61
127
|
return {
|
|
62
128
|
/**
|
|
63
129
|
* Check whether a forward to (groupId, vpId) with the supplied causedBy
|
|
@@ -83,6 +149,9 @@ export function createLoopGuard(options = {}) {
|
|
|
83
149
|
if (arr) {
|
|
84
150
|
const cutoff = now() - windowMs;
|
|
85
151
|
trim(arr, cutoff);
|
|
152
|
+
// Refresh LRU recency — a repeatedly-probed hot target should not
|
|
153
|
+
// be evicted just because it never crosses into record().
|
|
154
|
+
touch(k, arr);
|
|
86
155
|
if (arr.length >= maxHits) {
|
|
87
156
|
return {
|
|
88
157
|
ok: false,
|
|
@@ -99,6 +168,7 @@ export function createLoopGuard(options = {}) {
|
|
|
99
168
|
if (!groupId || !targetVpId) return;
|
|
100
169
|
const k = key(groupId, targetVpId);
|
|
101
170
|
let arr = hits.get(k);
|
|
171
|
+
const creating = !arr;
|
|
102
172
|
if (!arr) {
|
|
103
173
|
arr = [];
|
|
104
174
|
hits.set(k, arr);
|
|
@@ -106,17 +176,36 @@ export function createLoopGuard(options = {}) {
|
|
|
106
176
|
const cutoff = now() - windowMs;
|
|
107
177
|
trim(arr, cutoff);
|
|
108
178
|
arr.push(now());
|
|
179
|
+
// Refresh LRU recency for both existing and new keys.
|
|
180
|
+
touch(k, arr);
|
|
181
|
+
// On *new* key creation, opportunistically clean up: first drop
|
|
182
|
+
// fully-expired entries (cheap, bounds unbounded growth), then
|
|
183
|
+
// enforce the hard cap. Skip on the update path to keep hot-loop
|
|
184
|
+
// cost O(1).
|
|
185
|
+
if (creating) {
|
|
186
|
+
sweepExpired();
|
|
187
|
+
enforceCap();
|
|
188
|
+
}
|
|
109
189
|
},
|
|
110
190
|
|
|
111
191
|
/** Snapshot for tests / debug. */
|
|
112
192
|
snapshot() {
|
|
113
193
|
const out = {};
|
|
114
194
|
for (const [k, arr] of hits) out[k] = arr.slice();
|
|
115
|
-
return {
|
|
195
|
+
return {
|
|
196
|
+
hits: out,
|
|
197
|
+
maxChainDepth,
|
|
198
|
+
windowMs,
|
|
199
|
+
maxHits,
|
|
200
|
+
maxKeys,
|
|
201
|
+
ttlMultiplier,
|
|
202
|
+
size: hits.size,
|
|
203
|
+
evictions,
|
|
204
|
+
};
|
|
116
205
|
},
|
|
117
206
|
|
|
118
207
|
/** Wipe all counters (tests). */
|
|
119
|
-
reset() { hits.clear(); },
|
|
208
|
+
reset() { hits.clear(); evictions = 0; },
|
|
120
209
|
};
|
|
121
210
|
}
|
|
122
211
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vp-crud.js — filesystem CRUD for VP library (task-334-ui-g).
|
|
3
|
+
*
|
|
4
|
+
* Writes / updates / deletes `<lib>/<vpId>/role.md`. VpLoader's hot-reload
|
|
5
|
+
* picks up the change on its next debounced rescan and fans out
|
|
6
|
+
* vp_updated / vp_removed WS events to subscribers (334h).
|
|
7
|
+
*
|
|
8
|
+
* Hard constraints:
|
|
9
|
+
* (a) zero touch on ids.js contract — we only *read* validateVpId;
|
|
10
|
+
* (b) zero modification to registry.js / roster.js internals — the
|
|
11
|
+
* entity layer sees changes only via VpLoader rescan;
|
|
12
|
+
* (c) no Storage-Layer (334o) imports — stays on the entity side.
|
|
13
|
+
*
|
|
14
|
+
* Error codes returned to the caller (wire-visible):
|
|
15
|
+
* 'duplicate' — vpId already exists (on create)
|
|
16
|
+
* 'not_found' — vpId does not exist on disk (on update/delete)
|
|
17
|
+
* <reason from validateVpId> — invalid shape
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
21
|
+
import { join } from 'path';
|
|
22
|
+
import { validateVpId } from '../groups/ids.js';
|
|
23
|
+
import { DEFAULT_VP_LIB_DIR, parseRoleMd } from './vp-store.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Error thrown by CRUD entry points. Has stable `.code` so callers can map
|
|
27
|
+
* to i18n / WS payload without string-parsing the message.
|
|
28
|
+
*/
|
|
29
|
+
export class VpCrudError extends Error {
|
|
30
|
+
constructor(code, vpId, message) {
|
|
31
|
+
super(message || `${code}: ${vpId}`);
|
|
32
|
+
this.name = 'VpCrudError';
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.vpId = vpId;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ensureLibDir(libDir) {
|
|
39
|
+
if (!existsSync(libDir)) mkdirSync(libDir, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function vpDirFor(libDir, vpId) {
|
|
43
|
+
return join(libDir, vpId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function vpRolePathFor(libDir, vpId) {
|
|
47
|
+
return join(vpDirFor(libDir, vpId), 'role.md');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Serialise a VP payload into role.md text with YAML frontmatter matching
|
|
52
|
+
* the parser in vp-store.js.
|
|
53
|
+
*
|
|
54
|
+
* @param {{vpId:string, displayName?:string, role?:string, traits?:string[], modelHint?:string, persona?:string}} p
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function buildRoleMd(p) {
|
|
58
|
+
const id = String(p.vpId);
|
|
59
|
+
const name = p.displayName != null ? String(p.displayName) : id;
|
|
60
|
+
const role = p.role != null ? String(p.role) : '';
|
|
61
|
+
const traits = Array.isArray(p.traits) ? p.traits.map(t => String(t)).filter(Boolean) : [];
|
|
62
|
+
const modelHint = p.modelHint === 'primary' || p.modelHint === 'fast' ? p.modelHint : null;
|
|
63
|
+
const body = typeof p.persona === 'string' ? p.persona : '';
|
|
64
|
+
|
|
65
|
+
const lines = ['---', `id: ${id}`, `name: ${yamlScalar(name)}`, `role: ${yamlScalar(role)}`];
|
|
66
|
+
if (modelHint) lines.push(`modelHint: ${modelHint}`);
|
|
67
|
+
if (traits.length > 0) {
|
|
68
|
+
lines.push('traits:');
|
|
69
|
+
for (const t of traits) lines.push(` - ${yamlScalar(t)}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push('---', '', body.trim(), '');
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function yamlScalar(v) {
|
|
76
|
+
const s = String(v);
|
|
77
|
+
// Quote anything that the minimal parser might mis-read: colons, leading
|
|
78
|
+
// dashes, or surrounding whitespace. Plain text passes through unquoted.
|
|
79
|
+
if (/^[\s]|[\s]$|^[-:]|[:#]/.test(s)) {
|
|
80
|
+
return `"${s.replace(/"/g, '\\"')}"`;
|
|
81
|
+
}
|
|
82
|
+
return s;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create a new VP. Writes `<lib>/<vpId>/role.md` and ensures `memory/` dir.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} payload
|
|
89
|
+
* @param {string} payload.vpId
|
|
90
|
+
* @param {string} [payload.displayName]
|
|
91
|
+
* @param {string} [payload.role]
|
|
92
|
+
* @param {string[]} [payload.traits]
|
|
93
|
+
* @param {'primary'|'fast'} [payload.modelHint]
|
|
94
|
+
* @param {string} [payload.persona]
|
|
95
|
+
* @param {object} [options]
|
|
96
|
+
* @param {string} [options.libDir]
|
|
97
|
+
* @returns {{vpId:string, dir:string}}
|
|
98
|
+
*/
|
|
99
|
+
export function createVp(payload, options = {}) {
|
|
100
|
+
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
101
|
+
const vpId = payload && payload.vpId;
|
|
102
|
+
|
|
103
|
+
const v = validateVpId(vpId);
|
|
104
|
+
if (!v.ok) throw new VpCrudError(v.reason, vpId);
|
|
105
|
+
|
|
106
|
+
ensureLibDir(libDir);
|
|
107
|
+
const dir = vpDirFor(libDir, vpId);
|
|
108
|
+
if (existsSync(dir)) {
|
|
109
|
+
// Directory already present. Treat as duplicate regardless of whether
|
|
110
|
+
// role.md is inside — prevents CRUD stomping on a half-created entry
|
|
111
|
+
// or a user-authored dir with no frontmatter yet.
|
|
112
|
+
throw new VpCrudError('duplicate', vpId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
mkdirSync(dir, { recursive: true });
|
|
116
|
+
mkdirSync(join(dir, 'memory'), { recursive: true });
|
|
117
|
+
writeFileSync(vpRolePathFor(libDir, vpId), buildRoleMd({ ...payload, vpId }), 'utf-8');
|
|
118
|
+
return { vpId, dir };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Update an existing VP. vpId is immutable — the dir is keyed by it; if the
|
|
123
|
+
* user wants a rename they must delete + create.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} payload same shape as createVp, vpId must match existing dir
|
|
126
|
+
* @param {object} [options]
|
|
127
|
+
* @returns {{vpId:string, dir:string}}
|
|
128
|
+
*/
|
|
129
|
+
export function updateVp(payload, options = {}) {
|
|
130
|
+
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
131
|
+
const vpId = payload && payload.vpId;
|
|
132
|
+
|
|
133
|
+
const v = validateVpId(vpId);
|
|
134
|
+
if (!v.ok) throw new VpCrudError(v.reason, vpId);
|
|
135
|
+
|
|
136
|
+
const dir = vpDirFor(libDir, vpId);
|
|
137
|
+
if (!existsSync(dir) || !existsSync(vpRolePathFor(libDir, vpId))) {
|
|
138
|
+
throw new VpCrudError('not_found', vpId);
|
|
139
|
+
}
|
|
140
|
+
writeFileSync(vpRolePathFor(libDir, vpId), buildRoleMd({ ...payload, vpId }), 'utf-8');
|
|
141
|
+
return { vpId, dir };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Delete a VP — removes the entire VP dir (role.md + memory/).
|
|
146
|
+
*
|
|
147
|
+
* Hard constraint: `memory/` contents are scoped to this VP; removing them
|
|
148
|
+
* with the role is the intended CRUD semantic (UX rule is the confirm
|
|
149
|
+
* dialog upstream, not here).
|
|
150
|
+
*
|
|
151
|
+
* @param {string} vpId
|
|
152
|
+
* @param {object} [options]
|
|
153
|
+
* @returns {{vpId:string}}
|
|
154
|
+
*/
|
|
155
|
+
export function deleteVp(vpId, options = {}) {
|
|
156
|
+
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
157
|
+
// We do NOT run validateVpId here — deleting an already-legacy bad id is
|
|
158
|
+
// legitimate cleanup. But we DO refuse obviously unsafe inputs.
|
|
159
|
+
if (!vpId || typeof vpId !== 'string' || vpId.includes('/') || vpId.includes('\\') || vpId === '..' || vpId === '.') {
|
|
160
|
+
throw new VpCrudError('illegal_character', vpId);
|
|
161
|
+
}
|
|
162
|
+
const dir = vpDirFor(libDir, vpId);
|
|
163
|
+
if (!existsSync(dir)) {
|
|
164
|
+
throw new VpCrudError('not_found', vpId);
|
|
165
|
+
}
|
|
166
|
+
rmSync(dir, { recursive: true, force: true });
|
|
167
|
+
return { vpId };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read the full editable shape of an existing VP (for populating the edit
|
|
172
|
+
* form). Parses role.md directly via the same parser vp-store uses, without
|
|
173
|
+
* dragging in the mtime / memoryDir side effects of loadVpFromDir.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} vpId
|
|
176
|
+
* @param {object} [options]
|
|
177
|
+
* @returns {?{vpId:string, displayName:string, role:string, traits:string[], modelHint:?string, persona:string}}
|
|
178
|
+
*/
|
|
179
|
+
export function readVp(vpId, options = {}) {
|
|
180
|
+
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
181
|
+
const rolePath = vpRolePathFor(libDir, vpId);
|
|
182
|
+
if (!existsSync(rolePath)) return null;
|
|
183
|
+
let source;
|
|
184
|
+
try {
|
|
185
|
+
source = readFileSync(rolePath, 'utf-8');
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const { meta, body } = parseRoleMd(source);
|
|
190
|
+
const id = String(meta.id || vpId).trim() || vpId;
|
|
191
|
+
const modelHintRaw = typeof meta.modelHint === 'string' ? meta.modelHint : null;
|
|
192
|
+
const modelHint = modelHintRaw === 'primary' || modelHintRaw === 'fast' ? modelHintRaw : null;
|
|
193
|
+
return {
|
|
194
|
+
vpId: id,
|
|
195
|
+
displayName: String(meta.name || id),
|
|
196
|
+
role: String(meta.role || ''),
|
|
197
|
+
traits: Array.isArray(meta.traits) ? meta.traits.map(String) : [],
|
|
198
|
+
modelHint,
|
|
199
|
+
persona: body,
|
|
200
|
+
};
|
|
201
|
+
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -27,6 +27,7 @@ import { sendToServer } from '../connection/buffer.js';
|
|
|
27
27
|
import ctx from '../context.js';
|
|
28
28
|
import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
|
|
29
29
|
import { handleVpSubscribe } from './vp/vp-bridge.js';
|
|
30
|
+
import { createVp, updateVp, deleteVp, readVp, VpCrudError } from './vp/vp-crud.js';
|
|
30
31
|
|
|
31
32
|
/** @type {import('./session.js').Session | null} */
|
|
32
33
|
let session = null;
|
|
@@ -114,6 +115,105 @@ export function handleUnifyVpSubscribe(_msg) {
|
|
|
114
115
|
handleVpSubscribe(sendUnifyEvent);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* task-334-ui-g: VP CRUD from the web client.
|
|
120
|
+
*
|
|
121
|
+
* Thin dispatcher over agent/unify/vp/vp-crud.js. We never throw on the WS
|
|
122
|
+
* path — each op reports via `unify_output` with a structured payload so
|
|
123
|
+
* the UI can surface errors as i18n strings keyed by `error.code`. VpLoader
|
|
124
|
+
* picks up the on-disk change on its next debounced rescan (default 500ms)
|
|
125
|
+
* and fans out `vp_updated` / `vp_removed` events to every subscriber, so
|
|
126
|
+
* we do not need to emit an extra snapshot here.
|
|
127
|
+
*
|
|
128
|
+
* Message shapes (wire):
|
|
129
|
+
* unify_vp_create { payload: {vpId, displayName, role, traits, modelHint, persona}, requestId? }
|
|
130
|
+
* unify_vp_update { payload: {...}, requestId? }
|
|
131
|
+
* unify_vp_delete { vpId, requestId? }
|
|
132
|
+
* unify_vp_read { vpId, requestId? }
|
|
133
|
+
*
|
|
134
|
+
* Replies (all sent through sendUnifyEvent):
|
|
135
|
+
* { type: 'vp_crud_result', op, requestId, ok, vpId?, vp?, error?: {code, vpId?} }
|
|
136
|
+
*/
|
|
137
|
+
function sendVpCrudResult(payload) {
|
|
138
|
+
sendUnifyEvent({ type: 'vp_crud_result', ...payload });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function handleUnifyVpCreate(msg) {
|
|
142
|
+
const requestId = msg && msg.requestId;
|
|
143
|
+
const payload = msg && msg.payload;
|
|
144
|
+
try {
|
|
145
|
+
const { vpId } = createVp(payload || {});
|
|
146
|
+
sendVpCrudResult({ op: 'create', requestId, ok: true, vpId });
|
|
147
|
+
} catch (err) {
|
|
148
|
+
sendVpCrudResult({
|
|
149
|
+
op: 'create',
|
|
150
|
+
requestId,
|
|
151
|
+
ok: false,
|
|
152
|
+
error: {
|
|
153
|
+
code: err instanceof VpCrudError ? err.code : 'unknown',
|
|
154
|
+
vpId: err && err.vpId,
|
|
155
|
+
message: err && err.message,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function handleUnifyVpUpdate(msg) {
|
|
162
|
+
const requestId = msg && msg.requestId;
|
|
163
|
+
const payload = msg && msg.payload;
|
|
164
|
+
try {
|
|
165
|
+
const { vpId } = updateVp(payload || {});
|
|
166
|
+
sendVpCrudResult({ op: 'update', requestId, ok: true, vpId });
|
|
167
|
+
} catch (err) {
|
|
168
|
+
sendVpCrudResult({
|
|
169
|
+
op: 'update',
|
|
170
|
+
requestId,
|
|
171
|
+
ok: false,
|
|
172
|
+
error: {
|
|
173
|
+
code: err instanceof VpCrudError ? err.code : 'unknown',
|
|
174
|
+
vpId: err && err.vpId,
|
|
175
|
+
message: err && err.message,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function handleUnifyVpDelete(msg) {
|
|
182
|
+
const requestId = msg && msg.requestId;
|
|
183
|
+
const vpId = msg && msg.vpId;
|
|
184
|
+
try {
|
|
185
|
+
deleteVp(vpId);
|
|
186
|
+
sendVpCrudResult({ op: 'delete', requestId, ok: true, vpId });
|
|
187
|
+
} catch (err) {
|
|
188
|
+
sendVpCrudResult({
|
|
189
|
+
op: 'delete',
|
|
190
|
+
requestId,
|
|
191
|
+
ok: false,
|
|
192
|
+
error: {
|
|
193
|
+
code: err instanceof VpCrudError ? err.code : 'unknown',
|
|
194
|
+
vpId: err && err.vpId,
|
|
195
|
+
message: err && err.message,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function handleUnifyVpRead(msg) {
|
|
202
|
+
const requestId = msg && msg.requestId;
|
|
203
|
+
const vpId = msg && msg.vpId;
|
|
204
|
+
const vp = readVp(vpId);
|
|
205
|
+
if (!vp) {
|
|
206
|
+
sendVpCrudResult({
|
|
207
|
+
op: 'read',
|
|
208
|
+
requestId,
|
|
209
|
+
ok: false,
|
|
210
|
+
error: { code: 'not_found', vpId },
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
sendVpCrudResult({ op: 'read', requestId, ok: true, vpId, vp });
|
|
215
|
+
}
|
|
216
|
+
|
|
117
217
|
/**
|
|
118
218
|
* task-318 rev-1 fix: install live-setter bridge between the session's
|
|
119
219
|
* runtime handles (engineRegistry + threadStore) and `ctx.unifyRuntimeSettings`,
|