@yeaft/webchat-agent 0.1.527 → 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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.527",
3
+ "version": "0.1.528",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,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
+ }
@@ -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`,