@yeaft/webchat-agent 0.1.524 → 0.1.525

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.524",
3
+ "version": "0.1.525",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1,9 +1,5 @@
1
1
  /**
2
- * vp-bridge.js — task-334-ui-a snapshot-only WS adapter.
3
- *
4
- * Per ruling .crew/context/task-334-ui-a-ruling.md §3 (D3 = mixed):
5
- * • snapshot path lives here (this slice, in-scope)
6
- * • live diff (vp_updated / vp_removed) deferred to 334h — see TODO below
2
+ * vp-bridge.js — task-334-ui-a snapshot + task-334h live-diff WS adapter.
7
3
  *
8
4
  * This module is the SOLE serialiser for the wire-format VP shape.
9
5
  * Per ruling §1 (D1 = (b) web-bridge boundary rename), the entity layer
@@ -12,10 +8,17 @@
12
8
  *
13
9
  * Per ruling §2 (D2):
14
10
  * • subtitle → agent emits `vp.role` directly
15
- * • personaHash → agent emits `vp.personaHash` (added by dev-1's
16
- * 334a-followup patch). Until that lands, this serialiser falls back
17
- * to undefined and the web layer simply omits the field.
11
+ * • personaHash → agent emits `vp.personaHash`
18
12
  * • color / avatar → web-derived, NOT emitted here
13
+ *
14
+ * task-334h — live diff:
15
+ * • subscribers (Set of sendUnifyEvent fns) receive per-vp `vp_updated`
16
+ * and `vp_removed` events as VpLoader's debounced rescan commits
17
+ * added / updated / removed vpIds.
18
+ * • Event shape: `{ type, vpId, vp?, reason? }` where `reason` ∈
19
+ * { 'persona.edit', 'traits.edit', 'manual.reload', 'file.removed' }.
20
+ * • Back-compat: `vp_snapshot` shape unchanged; `reason` is purely
21
+ * additive so older web clients ignore it.
19
22
  */
20
23
 
21
24
  import { defaultRegistry } from './registry.js';
@@ -25,12 +28,132 @@ import { VpLoader } from './vp-loader.js';
25
28
  let _loaderStarted = false;
26
29
  let _loader = null;
27
30
 
28
- function ensureLoader() {
31
+ /**
32
+ * Broadcast fan-out. VpLoader.onChange fires once per debounce batch for the
33
+ * whole process, so every active subscriber must receive each live-diff event.
34
+ * @type {Set<(event:object)=>void>}
35
+ */
36
+ const _subscribers = new Set();
37
+
38
+ /**
39
+ * Last-seen persona snapshot per vpId, used to classify `reason` when an
40
+ * `updated` entry arrives from VpLoader. We cache only the minimal fields we
41
+ * compare against, never the VP reference (identity is stable via
42
+ * registry.updateVpInPlace, so by the time onChange fires the registry
43
+ * already holds the new values — we must remember the *previous* values).
44
+ *
45
+ * Shape: vpId → { persona: string, traits: string[] }
46
+ * @type {Map<string, {persona:string, traits:string[]}>}
47
+ */
48
+ const _prevState = new Map();
49
+
50
+ function tsnap(vp) {
51
+ return {
52
+ persona: vp && typeof vp.persona === 'string' ? vp.persona : '',
53
+ traits: Array.isArray(vp && vp.traits) ? vp.traits.slice() : [],
54
+ };
55
+ }
56
+
57
+ function traitsEqual(a, b) {
58
+ if (!Array.isArray(a) || !Array.isArray(b)) return a === b;
59
+ if (a.length !== b.length) return false;
60
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * Pure reason classifier. Exposed for unit tests.
66
+ *
67
+ * @param {{persona:string, traits:string[]}|undefined} prev
68
+ * @param {{persona?:string, traits?:string[]}|undefined} next
69
+ * @returns {'persona.edit'|'traits.edit'|'manual.reload'}
70
+ */
71
+ export function classifyUpdateReason(prev, next) {
72
+ if (!prev || !next) return 'manual.reload';
73
+ const prevPersona = prev.persona || '';
74
+ const nextPersona = typeof next.persona === 'string' ? next.persona : '';
75
+ if (prevPersona !== nextPersona) return 'persona.edit';
76
+ if (!traitsEqual(prev.traits || [], next.traits || [])) return 'traits.edit';
77
+ return 'manual.reload';
78
+ }
79
+
80
+ /** Seed / refresh `_prevState` for a given registry. */
81
+ function captureState(registry) {
82
+ _prevState.clear();
83
+ for (const vp of registry.listVps()) {
84
+ _prevState.set(vp.id, tsnap(vp));
85
+ }
86
+ }
87
+
88
+ /**
89
+ * onChange callback bound to the process-singleton VpLoader. Translates the
90
+ * `{added, updated, removed}` summary into per-vpId wire events and
91
+ * broadcasts them to every active subscriber.
92
+ *
93
+ * @param {{added:string[],updated:string[],removed:string[]}} summary
94
+ * @param {import('./registry.js').Registry} registry
95
+ */
96
+ function broadcastChange(summary, registry) {
97
+ if (!summary || _subscribers.size === 0) {
98
+ // Still must refresh state so a later subscriber doesn't mis-classify.
99
+ captureState(registry);
100
+ return;
101
+ }
102
+
103
+ // Removed → vp_removed
104
+ for (const vpId of summary.removed || []) {
105
+ const evt = { type: 'vp_removed', vpId, reason: 'file.removed' };
106
+ _fanout(evt);
107
+ _prevState.delete(vpId);
108
+ }
109
+
110
+ // Added → vp_updated (no prev state yet, so reason = 'manual.reload').
111
+ for (const vpId of summary.added || []) {
112
+ const vp = registry.getVp(vpId);
113
+ if (!vp) continue;
114
+ const evt = {
115
+ type: 'vp_updated',
116
+ vpId,
117
+ vp: serializeVpForWire(vp),
118
+ reason: 'manual.reload',
119
+ };
120
+ _fanout(evt);
121
+ _prevState.set(vpId, tsnap(vp));
122
+ }
123
+
124
+ // Updated → classify against cached prev state.
125
+ for (const vpId of summary.updated || []) {
126
+ const vp = registry.getVp(vpId);
127
+ if (!vp) continue;
128
+ const prev = _prevState.get(vpId);
129
+ const reason = classifyUpdateReason(prev, vp);
130
+ const evt = {
131
+ type: 'vp_updated',
132
+ vpId,
133
+ vp: serializeVpForWire(vp),
134
+ reason,
135
+ };
136
+ _fanout(evt);
137
+ _prevState.set(vpId, tsnap(vp));
138
+ }
139
+ }
140
+
141
+ function _fanout(evt) {
142
+ for (const fn of _subscribers) {
143
+ try { fn(evt); } catch { /* never crash the loader from a subscriber */ }
144
+ }
145
+ }
146
+
147
+ function ensureLoader(registry = defaultRegistry) {
29
148
  if (_loaderStarted) return _loader;
30
149
  _loaderStarted = true;
31
150
  try {
32
- _loader = new VpLoader({ registry: defaultRegistry });
151
+ _loader = new VpLoader({
152
+ registry,
153
+ onChange: (summary) => broadcastChange(summary, registry),
154
+ });
33
155
  _loader.start();
156
+ captureState(registry);
34
157
  } catch {
35
158
  // Hot-reload optional; subscribe still returns whatever scan loaded.
36
159
  _loader = null;
@@ -73,25 +196,31 @@ export function buildVpSnapshot(registry = defaultRegistry) {
73
196
 
74
197
  /**
75
198
  * Handle an `unify_vp_subscribe` request from the web client.
76
- * Lazily starts the VpLoader on first call (debounced rescan watchers).
77
199
  *
78
- * @param {(event: object) => void} sendUnifyEvent emit fn (web-bridge wires this)
200
+ * Registers `sendUnifyEvent` as a live-diff subscriber, emits an initial
201
+ * `vp_snapshot`, and (on first call process-wide) starts the VpLoader
202
+ * whose debounced rescan fans out `vp_updated` / `vp_removed` events.
203
+ *
204
+ * Returns an unsubscribe fn the caller MAY invoke on WS close to prevent
205
+ * sending to a dead socket. Web-bridge is expected to manage this.
206
+ *
207
+ * @param {(event: object) => void} sendUnifyEvent
79
208
  * @param {import('./registry.js').Registry} [registry]
209
+ * @returns {() => void} unsubscribe fn
80
210
  */
81
211
  export function handleVpSubscribe(sendUnifyEvent, registry = defaultRegistry) {
82
- ensureLoader();
212
+ ensureLoader(registry);
213
+ _subscribers.add(sendUnifyEvent);
83
214
  try {
84
215
  sendUnifyEvent(buildVpSnapshot(registry));
85
216
  } catch {
86
217
  // Never crash the WS pipeline from snapshot serialisation.
87
218
  }
88
- // TODO(334h): vp_updated / vp_removed live broadcast.
89
- // Wire VpLoader.onChange → emit per-vp `vp_updated` and `vp_removed`
90
- // events using serializeVpForWire(). Out of scope for 334-ui-a.
219
+ return () => { _subscribers.delete(sendUnifyEvent); };
91
220
  }
92
221
 
93
222
  /**
94
- * Test seam: reset the lazy loader (for vitest).
223
+ * Test seam: reset the lazy loader + subscriber set + state cache.
95
224
  */
96
225
  export function _resetVpBridgeForTest() {
97
226
  if (_loader) {
@@ -99,4 +228,22 @@ export function _resetVpBridgeForTest() {
99
228
  }
100
229
  _loader = null;
101
230
  _loaderStarted = false;
231
+ _subscribers.clear();
232
+ _prevState.clear();
233
+ }
234
+
235
+ /**
236
+ * Test seam: manually drive broadcast (bypasses VpLoader so tests don't need
237
+ * a real filesystem). Used by the live-diff unit test.
238
+ */
239
+ export function _broadcastChangeForTest(summary, registry = defaultRegistry) {
240
+ broadcastChange(summary, registry);
241
+ }
242
+
243
+ /**
244
+ * Test seam: pre-seed `_prevState` so classifyUpdateReason has something to
245
+ * compare against when a test drives `_broadcastChangeForTest` directly.
246
+ */
247
+ export function _seedPrevStateForTest(registry = defaultRegistry) {
248
+ captureState(registry);
102
249
  }
@@ -105,9 +105,10 @@ function sendUnifyEvent(event) {
105
105
  }
106
106
 
107
107
  /**
108
- * task-334-ui-a: respond to `unify_vp_subscribe` from the web client by
109
- * pushing a one-shot `vp_snapshot` event. Live diff (vp_updated /
110
- * vp_removed) is intentionally deferred to 334h per ruling §3.
108
+ * task-334-ui-a + task-334h: respond to `unify_vp_subscribe` from the web
109
+ * client by pushing a one-shot `vp_snapshot` event AND registering this
110
+ * socket as a live-diff subscriber. VpLoader's debounced rescan fans out
111
+ * `vp_updated` / `vp_removed` events to every active subscriber.
111
112
  */
112
113
  export function handleUnifyVpSubscribe(_msg) {
113
114
  handleVpSubscribe(sendUnifyEvent);