@yeaft/webchat-agent 0.1.523 → 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 +1 -1
- package/unify/vp/vp-bridge.js +164 -17
- package/unify/web-bridge.js +4 -3
package/package.json
CHANGED
package/unify/vp/vp-bridge.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* vp-bridge.js — task-334-ui-a snapshot-
|
|
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`
|
|
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
|
-
|
|
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({
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -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
|
|
109
|
-
* pushing a one-shot `vp_snapshot` event
|
|
110
|
-
*
|
|
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);
|