@yeaft/webchat-agent 0.1.518 → 0.1.520

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 } from '../unify/web-bridge.js';
39
+ import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyVpSubscribe } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -397,6 +397,12 @@ export async function handleMessage(msg) {
397
397
  handleUnifyAbortAll();
398
398
  break;
399
399
 
400
+ // task-334-ui-a: VP library subscribe — replies with one-shot
401
+ // vp_snapshot event. Live diff (vp_updated/vp_removed) deferred to 334h.
402
+ case 'unify_vp_subscribe':
403
+ handleUnifyVpSubscribe(msg);
404
+ break;
405
+
400
406
  // Expert roles definition (for ExpertPanel detail view)
401
407
  case 'get_expert_roles': {
402
408
  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.518",
3
+ "version": "0.1.520",
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,107 @@
1
+ /**
2
+ * atomic.js — Crash-safe single-file writes.
3
+ *
4
+ * task-334o §Δ15 / §Δ22 §Δ23 shard-store foundation.
5
+ *
6
+ * Contract: writeAtomic(path, data) never leaves a half-written file at `path`.
7
+ * If the process crashes at any point, `path` is either the pre-existing content
8
+ * or the new content — never a torn mix. Leftover `*.tmp.<pid>.<n>` files are
9
+ * the only debris; they are safe to delete on boot (see sweepTmp()).
10
+ *
11
+ * Implementation:
12
+ * 1. Write bytes to `path.tmp.<pid>.<counter>` via writeFileSync.
13
+ * 2. fsync the tmp file (force bytes to disk before rename).
14
+ * 3. rename(tmp, path) — POSIX-atomic on same filesystem.
15
+ * 4. fsync the parent dir (persist the rename itself).
16
+ *
17
+ * Step 4 is what most naive "atomic write" implementations skip. Without it,
18
+ * a crash after the rename call returns can still lose the rename on ext4
19
+ * with data=ordered. We do the dir fsync on Linux/macOS; on Windows we skip
20
+ * (fsync on a directory is an error there) and accept the minor risk window.
21
+ *
22
+ * This module has no knowledge of VP/task/message — it's a pure primitive.
23
+ */
24
+
25
+ import {
26
+ writeFileSync,
27
+ renameSync,
28
+ openSync,
29
+ fsyncSync,
30
+ closeSync,
31
+ existsSync,
32
+ unlinkSync,
33
+ readdirSync,
34
+ } from 'fs';
35
+ import { dirname, basename, join } from 'path';
36
+
37
+ let tmpCounter = 0;
38
+
39
+ /**
40
+ * Atomically write `data` (string | Buffer) to `path`.
41
+ * Throws on failure; never leaves `path` in a half-written state.
42
+ */
43
+ export function writeAtomic(path, data) {
44
+ const dir = dirname(path);
45
+ const tmpPath = `${path}.tmp.${process.pid}.${++tmpCounter}`;
46
+
47
+ writeFileSync(tmpPath, data);
48
+
49
+ // fsync the tmp file so the bytes hit disk before we swap.
50
+ try {
51
+ const fd = openSync(tmpPath, 'r+');
52
+ try {
53
+ fsyncSync(fd);
54
+ } finally {
55
+ closeSync(fd);
56
+ }
57
+ } catch {
58
+ // Best-effort; some filesystems / platforms don't support fsync on a file
59
+ // opened r+. The rename below is still the atomic boundary.
60
+ }
61
+
62
+ renameSync(tmpPath, path);
63
+
64
+ // fsync the parent directory so the rename is durable.
65
+ // Windows: cannot fsync a directory; skip.
66
+ if (process.platform !== 'win32') {
67
+ try {
68
+ const dfd = openSync(dir, 'r');
69
+ try {
70
+ fsyncSync(dfd);
71
+ } finally {
72
+ closeSync(dfd);
73
+ }
74
+ } catch {
75
+ // Directory fsync is best-effort. A failure here does not invalidate
76
+ // the rename itself; it only weakens durability on power loss.
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Remove any leftover `*.tmp.*` files in `dir` from a previous crashed write.
83
+ * Safe to call on boot. Returns the count removed.
84
+ *
85
+ * Only matches the specific `<basename>.tmp.<pid>.<counter>` shape — won't
86
+ * touch user files that happen to end in `.tmp`.
87
+ */
88
+ export function sweepTmp(dir) {
89
+ if (!existsSync(dir)) return 0;
90
+ let removed = 0;
91
+ for (const name of readdirSync(dir)) {
92
+ if (/\.tmp\.\d+\.\d+$/.test(name)) {
93
+ try {
94
+ unlinkSync(join(dir, name));
95
+ removed++;
96
+ } catch {
97
+ // Ignore — another process may have beaten us to it.
98
+ }
99
+ }
100
+ }
101
+ return removed;
102
+ }
103
+
104
+ /** Check whether a given file path looks like our tmp sidecar. Test helper. */
105
+ export function isTmpPath(path) {
106
+ return /\.tmp\.\d+\.\d+$/.test(basename(path));
107
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * compact.js — External compaction entry point for 334g (dream).
3
+ *
4
+ * `runCompact({ dir, threshold, schema })` opens a shard store at `dir`,
5
+ * inspects each shard for softCap breach, and rewrites shard files that
6
+ * need it. Unlike the internal `compactShard` used by `remove()`, this is
7
+ * the call dream uses to periodically reclaim space across the whole store.
8
+ *
9
+ * This module does NOT:
10
+ * - decide what's stale enough to delete (that's dream's job, using
11
+ * `supersededBy` chains)
12
+ * - re-score or re-rank entries
13
+ * - touch memory files belonging to other stores (it only touches `dir`)
14
+ *
15
+ * Parameters:
16
+ * dir : shard-store directory
17
+ * schema : shard schema (shards[], softCap)
18
+ * threshold : optional override — compact any shard where
19
+ * entries >= threshold.entries || bytes >= threshold.bytes
20
+ * (if omitted, uses the schema's softCap)
21
+ * deleteIds : optional array of ids that dream has decided to purge
22
+ * (lets dream do "compact + delete" in one pass)
23
+ *
24
+ * Returns:
25
+ * { compacted: [shardName, ...], deleted: [id, ...], stillOver: [shardName, ...] }
26
+ */
27
+
28
+ import { openShardStore } from './shard-store.js';
29
+
30
+ export async function runCompact({ dir, schema = {}, threshold, deleteIds = [] } = {}) {
31
+ if (!dir) throw new Error('runCompact: dir required');
32
+ const store = openShardStore(dir, schema);
33
+ const stats = store.stats();
34
+ const compacted = [];
35
+ const deleted = [];
36
+ const stillOver = [];
37
+
38
+ // First pass: honour explicit deletions (dream hands us a hitlist).
39
+ const byShard = new Map();
40
+ for (const id of deleteIds) {
41
+ const entry = store.getIndex().entries.find((e) => e.id === id);
42
+ if (!entry) continue;
43
+ const list = byShard.get(entry.shard) || [];
44
+ list.push(id);
45
+ byShard.set(entry.shard, list);
46
+ }
47
+
48
+ for (const [shardName] of byShard) {
49
+ // Use public remove() which already calls the internal compacter.
50
+ for (const id of byShard.get(shardName)) {
51
+ if (store.remove(id)) deleted.push(id);
52
+ }
53
+ compacted.push(shardName);
54
+ }
55
+
56
+ // Second pass: defrag shards whose size still exceeds the threshold.
57
+ const thr = threshold || {};
58
+ for (const shardName of Object.keys(stats.shards)) {
59
+ if (compacted.includes(shardName)) continue;
60
+ const bucket = store.stats().shards[shardName];
61
+ if (!bucket) continue;
62
+ const cap = thr.entries != null || thr.bytes != null
63
+ ? thr
64
+ : (schema.softCap?.[shardName] || schema.defaultSoftCap);
65
+ if (!cap) continue;
66
+ const overEntries = cap.entries != null && bucket.entries > cap.entries;
67
+ const overBytes = cap.bytes != null && bucket.bytes > cap.bytes;
68
+ if (overEntries || overBytes) {
69
+ store.compact(shardName);
70
+ compacted.push(shardName);
71
+ // Re-read stats after compaction; if still over, surface to caller.
72
+ const newBucket = store.stats().shards[shardName];
73
+ if (newBucket.entries > (cap.entries ?? Infinity) || newBucket.bytes > (cap.bytes ?? Infinity)) {
74
+ stillOver.push(shardName);
75
+ }
76
+ }
77
+ }
78
+
79
+ return { compacted, deleted, stillOver };
80
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * storage/ — task-334o Storage Layer v1.
3
+ *
4
+ * Business-semantics-free primitives shared by 334b (group messages),
5
+ * 334f (VP memory), 334l (user memory), 334i (migration), 334n (summaries).
6
+ *
7
+ * Modules:
8
+ * - atomic : writeAtomic (tmp → rename + fsync)
9
+ * - jsonl-log : append-only log with size/line rotation
10
+ * - jsonl-index : segment manifest for jsonl-log
11
+ * - shard-store : schema-aware shard storage (get/put/query/remove/compact)
12
+ * - shard-index : manifest for shard-store
13
+ * - compact : external compaction entry point for dream
14
+ *
15
+ * API stability: 334o freezes its public API. Downstream slices must not
16
+ * request new fields — extensions go through `shard-schema.js` versioning
17
+ * (see slice spec §framework §2).
18
+ */
19
+
20
+ export { writeAtomic, sweepTmp, isTmpPath } from './atomic.js';
21
+ export { openLog } from './jsonl-log.js';
22
+ export {
23
+ emptyIndex,
24
+ loadIndex,
25
+ saveIndex,
26
+ listSegmentFiles,
27
+ statSegmentFromDisk,
28
+ nextSegmentName,
29
+ INDEX_FILE,
30
+ INDEX_VERSION,
31
+ } from './jsonl-index.js';
32
+ export { openShardStore } from './shard-store.js';
33
+ export {
34
+ emptyShardIndex,
35
+ loadShardIndex,
36
+ saveShardIndex,
37
+ rebuildShardIndexFromDisk,
38
+ shardFileName,
39
+ START_MARK,
40
+ END_MARK,
41
+ SHARD_INDEX_FILE,
42
+ SHARD_INDEX_VERSION,
43
+ } from './shard-index.js';
44
+ export { runCompact } from './compact.js';
@@ -0,0 +1,122 @@
1
+ /**
2
+ * jsonl-index.js — Manifest for a jsonl-log directory.
3
+ *
4
+ * The index file tracks which segment files cover which ID/timestamp ranges
5
+ * so `jsonl-log.readRange` can do O(1) segment selection without scanning
6
+ * the whole log. It's rewritten atomically on every rotation.
7
+ *
8
+ * Schema (index.json):
9
+ * {
10
+ * version: 1,
11
+ * nextId: <number | null>, // optional — caller-managed id counter
12
+ * segments: [
13
+ * { file:"000001.jsonl", firstId, lastId, firstTs, lastTs, count, bytes }
14
+ * ]
15
+ * }
16
+ *
17
+ * This module owns ONLY the manifest — it does not read or write the jsonl
18
+ * segments themselves. jsonl-log.js drives rotation and hands us updated
19
+ * segment metadata.
20
+ *
21
+ * No business semantics. "id" and "ts" are opaque; we don't care if they're
22
+ * msg_xxx, mem_xxx, numeric, or empty.
23
+ */
24
+
25
+ import { readFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
26
+ import { join } from 'path';
27
+ import { writeAtomic } from './atomic.js';
28
+
29
+ export const INDEX_FILE = 'index.json';
30
+ export const INDEX_VERSION = 1;
31
+
32
+ /** Build an empty manifest. */
33
+ export function emptyIndex() {
34
+ return { version: INDEX_VERSION, nextId: null, segments: [] };
35
+ }
36
+
37
+ /**
38
+ * Load the index from `dir/index.json`. Returns `null` if the file is missing,
39
+ * unreadable, or corrupt (caller should rebuild). Does NOT auto-rebuild —
40
+ * the caller decides whether a missing / corrupt index is fatal.
41
+ */
42
+ export function loadIndex(dir) {
43
+ const path = join(dir, INDEX_FILE);
44
+ if (!existsSync(path)) return null;
45
+ try {
46
+ const raw = readFileSync(path, 'utf8');
47
+ const parsed = JSON.parse(raw);
48
+ if (!parsed || typeof parsed !== 'object') return null;
49
+ if (!Array.isArray(parsed.segments)) return null;
50
+ return {
51
+ version: parsed.version || INDEX_VERSION,
52
+ nextId: parsed.nextId ?? null,
53
+ segments: parsed.segments,
54
+ };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /** Atomically persist an index. Creates `dir` if missing. */
61
+ export function saveIndex(dir, index) {
62
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
63
+ const payload = JSON.stringify({
64
+ version: INDEX_VERSION,
65
+ nextId: index.nextId ?? null,
66
+ segments: index.segments,
67
+ }, null, 2);
68
+ writeAtomic(join(dir, INDEX_FILE), payload);
69
+ }
70
+
71
+ /**
72
+ * List *.jsonl segment files in `dir`, sorted lexicographically.
73
+ * Returns names only, not full paths.
74
+ */
75
+ export function listSegmentFiles(dir) {
76
+ if (!existsSync(dir)) return [];
77
+ return readdirSync(dir)
78
+ .filter((n) => /^\d+\.jsonl$/.test(n))
79
+ .sort();
80
+ }
81
+
82
+ /**
83
+ * Produce an index entry by reading a segment file off disk. Used when
84
+ * recovering from a missing/corrupt index.json.
85
+ *
86
+ * Caller provides `parseLine(line) -> { id, ts }` so we can populate the
87
+ * firstId/lastId/firstTs/lastTs fields without this module knowing the schema.
88
+ * Malformed lines are skipped (best-effort rebuild).
89
+ */
90
+ export function statSegmentFromDisk(dir, fileName, parseLine) {
91
+ const path = join(dir, fileName);
92
+ const bytes = statSync(path).size;
93
+ const raw = readFileSync(path, 'utf8');
94
+ const lines = raw.split('\n').filter((l) => l.length > 0);
95
+ let firstId = null, lastId = null, firstTs = null, lastTs = null;
96
+ let count = 0;
97
+ for (const line of lines) {
98
+ let parsed;
99
+ try {
100
+ parsed = parseLine(line);
101
+ } catch {
102
+ continue;
103
+ }
104
+ if (!parsed) continue;
105
+ count++;
106
+ if (firstId === null) firstId = parsed.id ?? null;
107
+ lastId = parsed.id ?? lastId;
108
+ if (firstTs === null) firstTs = parsed.ts ?? null;
109
+ lastTs = parsed.ts ?? lastTs;
110
+ }
111
+ return { file: fileName, firstId, lastId, firstTs, lastTs, count, bytes };
112
+ }
113
+
114
+ /** Pick the highest segment number in `segments` (caller decides next filename). */
115
+ export function nextSegmentName(segments) {
116
+ let max = 0;
117
+ for (const seg of segments) {
118
+ const m = /^(\d+)\.jsonl$/.exec(seg.file);
119
+ if (m) max = Math.max(max, parseInt(m[1], 10));
120
+ }
121
+ return String(max + 1).padStart(6, '0') + '.jsonl';
122
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * jsonl-log.js — Append-only JSONL log with size-based rotation.
3
+ *
4
+ * task-334o acceptance criterion 1:
5
+ * - append single-line write < 2ms on local SSD
6
+ * - rotate at maxSegmentBytes=1 MiB OR maxSegmentLines=5000 (first to hit)
7
+ * - index.json is atomically updated on every rotation
8
+ *
9
+ * Layout (managed here):
10
+ * <dir>/000001.jsonl
11
+ * <dir>/000002.jsonl
12
+ * <dir>/index.json
13
+ *
14
+ * API:
15
+ * const log = openLog(dir, { maxSegmentBytes, maxSegmentLines, parseLine });
16
+ * log.append(obj) // writes JSON.stringify(obj) + '\n'
17
+ * log.readRange(firstId, lastId) // iterable of records in [firstId, lastId]
18
+ * log.streamAll() // iterable of all records, oldest -> newest
19
+ * log.rotate() // force rotate (test hook)
20
+ * log.close() // close fd, flush index
21
+ *
22
+ * No business semantics: the log does not know what an id means; it only
23
+ * needs the caller to provide a `parseLine(line) -> {id, ts}` so the index
24
+ * metadata can be rebuilt from disk if index.json is lost or corrupt.
25
+ */
26
+
27
+ import {
28
+ openSync,
29
+ closeSync,
30
+ writeSync,
31
+ readFileSync,
32
+ existsSync,
33
+ mkdirSync,
34
+ statSync,
35
+ } from 'fs';
36
+ import { join } from 'path';
37
+ import {
38
+ loadIndex,
39
+ saveIndex,
40
+ listSegmentFiles,
41
+ statSegmentFromDisk,
42
+ nextSegmentName,
43
+ emptyIndex,
44
+ } from './jsonl-index.js';
45
+
46
+ const DEFAULT_MAX_BYTES = 1 * 1024 * 1024; // 1 MiB
47
+ const DEFAULT_MAX_LINES = 5000;
48
+
49
+ const defaultParseLine = (line) => {
50
+ const obj = JSON.parse(line);
51
+ return { id: obj.id ?? null, ts: obj.ts ?? null };
52
+ };
53
+
54
+ /**
55
+ * Open (or create) an append-only JSONL log rooted at `dir`.
56
+ * On startup, verifies / rebuilds index.json against files on disk.
57
+ */
58
+ export function openLog(dir, opts = {}) {
59
+ const maxSegmentBytes = opts.maxSegmentBytes ?? DEFAULT_MAX_BYTES;
60
+ const maxSegmentLines = opts.maxSegmentLines ?? DEFAULT_MAX_LINES;
61
+ const parseLine = opts.parseLine ?? defaultParseLine;
62
+
63
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
64
+
65
+ let index = loadIndex(dir);
66
+ const filesOnDisk = listSegmentFiles(dir);
67
+
68
+ // Rebuild index if missing, corrupt, or out-of-sync with actual segment files.
69
+ if (!index || !indexMatchesDisk(index, filesOnDisk)) {
70
+ index = rebuildIndexFromDisk(dir, filesOnDisk, parseLine);
71
+ saveIndex(dir, index);
72
+ }
73
+
74
+ // Current segment = last one in the index; create 000001.jsonl if empty.
75
+ let current = index.segments[index.segments.length - 1];
76
+ if (!current) {
77
+ current = {
78
+ file: '000001.jsonl',
79
+ firstId: null, lastId: null,
80
+ firstTs: null, lastTs: null,
81
+ count: 0, bytes: 0,
82
+ };
83
+ index.segments.push(current);
84
+ // Touch the file so the fd is openable.
85
+ const path = join(dir, current.file);
86
+ if (!existsSync(path)) closeSync(openSync(path, 'a'));
87
+ }
88
+
89
+ let fd = openSync(join(dir, current.file), 'a');
90
+
91
+ /** Detect & perform rotation when current segment is full. */
92
+ function maybeRotate() {
93
+ if (current.bytes >= maxSegmentBytes || current.count >= maxSegmentLines) {
94
+ rotate();
95
+ }
96
+ }
97
+
98
+ function rotate() {
99
+ // Close current fd.
100
+ closeSync(fd);
101
+ // Open new segment.
102
+ const newFile = nextSegmentName(index.segments);
103
+ const newSeg = {
104
+ file: newFile,
105
+ firstId: null, lastId: null,
106
+ firstTs: null, lastTs: null,
107
+ count: 0, bytes: 0,
108
+ };
109
+ index.segments.push(newSeg);
110
+ current = newSeg;
111
+ // Persist the rotation atomically before we start writing to the new file
112
+ // so a crash mid-rotation doesn't lose the boundary.
113
+ saveIndex(dir, index);
114
+ fd = openSync(join(dir, current.file), 'a');
115
+ }
116
+
117
+ function append(obj) {
118
+ const line = JSON.stringify(obj) + '\n';
119
+ const buf = Buffer.from(line, 'utf8');
120
+ writeSync(fd, buf, 0, buf.length);
121
+ current.count += 1;
122
+ current.bytes += buf.length;
123
+ const id = obj.id ?? null;
124
+ const ts = obj.ts ?? null;
125
+ if (current.firstId === null) current.firstId = id;
126
+ current.lastId = id;
127
+ if (current.firstTs === null) current.firstTs = ts;
128
+ current.lastTs = ts;
129
+ maybeRotate();
130
+ }
131
+
132
+ function* streamAll() {
133
+ for (const seg of index.segments) {
134
+ const path = join(dir, seg.file);
135
+ if (!existsSync(path)) continue;
136
+ const raw = readFileSync(path, 'utf8');
137
+ for (const line of raw.split('\n')) {
138
+ if (!line) continue;
139
+ try {
140
+ yield JSON.parse(line);
141
+ } catch {
142
+ // Skip malformed line — don't crash the read pipeline.
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ /** Read records whose id falls in `[firstId, lastId]` inclusive (string or number). */
149
+ function* readRange(firstId, lastId) {
150
+ for (const seg of index.segments) {
151
+ if (!segmentOverlaps(seg, firstId, lastId)) continue;
152
+ const path = join(dir, seg.file);
153
+ if (!existsSync(path)) continue;
154
+ const raw = readFileSync(path, 'utf8');
155
+ for (const line of raw.split('\n')) {
156
+ if (!line) continue;
157
+ let obj;
158
+ try { obj = JSON.parse(line); } catch { continue; }
159
+ const id = obj.id ?? null;
160
+ if (id === null) continue;
161
+ if (idBetween(id, firstId, lastId)) yield obj;
162
+ }
163
+ }
164
+ }
165
+
166
+ function setNextId(n) { index.nextId = n; saveIndex(dir, index); }
167
+ function getNextId() { return index.nextId; }
168
+ function getIndex() { return index; }
169
+ function flushIndex() { saveIndex(dir, index); }
170
+
171
+ function close() {
172
+ try { closeSync(fd); } catch { /* already closed */ }
173
+ saveIndex(dir, index);
174
+ }
175
+
176
+ return {
177
+ append, readRange, streamAll, rotate, close,
178
+ setNextId, getNextId, getIndex, flushIndex,
179
+ };
180
+ }
181
+
182
+ /** True when index's segment list matches exactly the files on disk. */
183
+ function indexMatchesDisk(index, filesOnDisk) {
184
+ const indexFiles = index.segments.map((s) => s.file);
185
+ if (indexFiles.length !== filesOnDisk.length) return false;
186
+ for (let i = 0; i < indexFiles.length; i++) {
187
+ if (indexFiles[i] !== filesOnDisk[i]) return false;
188
+ }
189
+ return true;
190
+ }
191
+
192
+ /**
193
+ * Rebuild the index by scanning every segment file on disk. Expensive but
194
+ * only happens on first open or after index.json loss.
195
+ */
196
+ function rebuildIndexFromDisk(dir, files, parseLine) {
197
+ const idx = emptyIndex();
198
+ for (const file of files) {
199
+ idx.segments.push(statSegmentFromDisk(dir, file, parseLine));
200
+ }
201
+ return idx;
202
+ }
203
+
204
+ /**
205
+ * Cheap overlap test. If a segment's firstId/lastId are null (empty segment),
206
+ * we treat it as non-overlapping.
207
+ */
208
+ function segmentOverlaps(seg, first, last) {
209
+ if (seg.firstId == null || seg.lastId == null) return false;
210
+ // Ordering on strings is lexicographic which matches ULID-style ids used
211
+ // by the product (msg_01HW...). For numeric ids, JS > / < works too.
212
+ return !(compareId(seg.lastId, first) < 0 || compareId(seg.firstId, last) > 0);
213
+ }
214
+
215
+ function idBetween(id, first, last) {
216
+ return compareId(id, first) >= 0 && compareId(id, last) <= 0;
217
+ }
218
+
219
+ function compareId(a, b) {
220
+ if (a === b) return 0;
221
+ return a < b ? -1 : 1;
222
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * shard-index.js — Manifest for shard-store.
3
+ *
4
+ * shard-store keeps N shard files (one per schema.shard value) and one
5
+ * `index.json` that maps entry ids → their shard + byte range. This module
6
+ * owns only the manifest; shard-store.js drives writes.
7
+ *
8
+ * Schema (index.json):
9
+ * {
10
+ * version: 1,
11
+ * entries: [{
12
+ * id, shard,
13
+ * byteOffset, byteLen, // byte range inside shard file
14
+ * meta: { kind?, tags?, pinned?, ...caller-chosen }
15
+ * }],
16
+ * shards: {
17
+ * <name>: { entries: <count>, bytes: <size>, softCap: { entries, bytes } }
18
+ * }
19
+ * }
20
+ *
21
+ * entries[] is append-style but we rewrite it atomically on every mutation.
22
+ * At ~thousands of entries this is still cheap (<10 KB JSON) and keeps the
23
+ * read path O(1) — the full index loads into memory on open.
24
+ *
25
+ * If index.json is lost or corrupt, shard-store rebuilds it by scanning the
26
+ * shard markdown files for <!--entry:<id>:START/END--> delimiters.
27
+ */
28
+
29
+ import { existsSync, readFileSync, mkdirSync, readdirSync, statSync } from 'fs';
30
+ import { join } from 'path';
31
+ import { writeAtomic } from './atomic.js';
32
+
33
+ export const SHARD_INDEX_FILE = 'index.json';
34
+ export const SHARD_INDEX_VERSION = 1;
35
+
36
+ export const START_MARK = (id) => `<!--entry:${id}:START-->`;
37
+ export const END_MARK = (id) => `<!--entry:${id}:END-->`;
38
+
39
+ /** Regex that matches any start or end delimiter. */
40
+ const ENTRY_MARK_RE = /<!--entry:([A-Za-z0-9_\-]+):(START|END)-->/g;
41
+
42
+ export function emptyShardIndex() {
43
+ return { version: SHARD_INDEX_VERSION, entries: [], shards: {} };
44
+ }
45
+
46
+ export function loadShardIndex(dir) {
47
+ const path = join(dir, SHARD_INDEX_FILE);
48
+ if (!existsSync(path)) return null;
49
+ try {
50
+ const raw = readFileSync(path, 'utf8');
51
+ const parsed = JSON.parse(raw);
52
+ if (!parsed || typeof parsed !== 'object') return null;
53
+ if (!Array.isArray(parsed.entries)) return null;
54
+ if (!parsed.shards || typeof parsed.shards !== 'object') return null;
55
+ return {
56
+ version: parsed.version || SHARD_INDEX_VERSION,
57
+ entries: parsed.entries,
58
+ shards: parsed.shards,
59
+ };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ export function saveShardIndex(dir, index) {
66
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
67
+ const payload = JSON.stringify({
68
+ version: SHARD_INDEX_VERSION,
69
+ entries: index.entries,
70
+ shards: index.shards,
71
+ }, null, 2);
72
+ writeAtomic(join(dir, SHARD_INDEX_FILE), payload);
73
+ }
74
+
75
+ /** Compose the on-disk filename for a shard (schema may customise). */
76
+ export function shardFileName(shardName) {
77
+ return `memory-${shardName}.md`;
78
+ }
79
+
80
+ /**
81
+ * Scan shard files in `dir` and rebuild the index entirely from disk.
82
+ * Relies only on the <!--entry:<id>:START/END--> delimiters. Returns a
83
+ * fresh index object. Caller is responsible for populating `meta` again
84
+ * by reading each entry's frontmatter if they need it — this module does
85
+ * not parse the entry body (keeping the store schema-agnostic).
86
+ */
87
+ export function rebuildShardIndexFromDisk(dir, schema) {
88
+ const index = emptyShardIndex();
89
+ if (!existsSync(dir)) return index;
90
+
91
+ // Preseed shard buckets from the schema so even empty shards show up.
92
+ for (const shardName of schema.shards || []) {
93
+ index.shards[shardName] = {
94
+ entries: 0,
95
+ bytes: 0,
96
+ softCap: schema.softCap?.[shardName] || schema.defaultSoftCap || null,
97
+ };
98
+ }
99
+
100
+ for (const name of readdirSync(dir)) {
101
+ if (!name.startsWith('memory-') || !name.endsWith('.md')) continue;
102
+ const shardName = name.slice('memory-'.length, -'.md'.length);
103
+ const path = join(dir, name);
104
+ const body = readFileSync(path, 'utf8');
105
+ const bytes = statSync(path).size;
106
+
107
+ // Ensure bucket exists even if schema didn't preseed this shard.
108
+ if (!index.shards[shardName]) {
109
+ index.shards[shardName] = {
110
+ entries: 0,
111
+ bytes,
112
+ softCap: schema.defaultSoftCap || null,
113
+ };
114
+ } else {
115
+ index.shards[shardName].bytes = bytes;
116
+ }
117
+
118
+ // Walk START/END pairs. Tolerate out-of-order markers by matching by id.
119
+ const starts = new Map();
120
+ ENTRY_MARK_RE.lastIndex = 0;
121
+ let m;
122
+ while ((m = ENTRY_MARK_RE.exec(body))) {
123
+ const id = m[1];
124
+ const kind = m[2];
125
+ if (kind === 'START') {
126
+ starts.set(id, m.index);
127
+ } else if (kind === 'END' && starts.has(id)) {
128
+ const startIdx = starts.get(id);
129
+ const endIdx = m.index + m[0].length;
130
+ index.entries.push({
131
+ id,
132
+ shard: shardName,
133
+ byteOffset: startIdx,
134
+ byteLen: endIdx - startIdx,
135
+ meta: {},
136
+ });
137
+ index.shards[shardName].entries += 1;
138
+ starts.delete(id);
139
+ }
140
+ }
141
+ }
142
+ return index;
143
+ }
144
+
145
+ /** Upsert (or insert) a single entry record. Mutates `index` in place. */
146
+ export function putEntryRecord(index, record) {
147
+ const i = index.entries.findIndex((e) => e.id === record.id);
148
+ if (i >= 0) index.entries[i] = record;
149
+ else index.entries.push(record);
150
+ }
151
+
152
+ /** Remove an entry record by id. Returns the removed record or null. */
153
+ export function removeEntryRecord(index, id) {
154
+ const i = index.entries.findIndex((e) => e.id === id);
155
+ if (i < 0) return null;
156
+ const [removed] = index.entries.splice(i, 1);
157
+ return removed;
158
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * shard-store.js — Schema-aware shard storage on top of shard-index.
3
+ *
4
+ * Stores opaque "entries" across a small number of shard files. Each entry
5
+ * is a chunk of text (typically the serialised body the caller supplies),
6
+ * bracketed by `<!--entry:<id>:START-->` / `<!--entry:<id>:END-->` delimiters.
7
+ *
8
+ * Caller provides a `schema` describing:
9
+ * - shards : allowed shard names (open set if undefined)
10
+ * - softCap : per-shard { entries, bytes } soft limit
11
+ * - defaultSoftCap : fallback for shards not explicitly listed
12
+ *
13
+ * API surface (§10 acceptance):
14
+ * put(entry) → { id, shard, needsRecompression }
15
+ * get(id) → { id, shard, body, meta } | null
16
+ * query(filter) → { results: [...], needsRecompression: [shard names] }
17
+ * remove(id) → boolean
18
+ * compact(shardName?) → rewrites shard(s) to strip tombstone gaps
19
+ *
20
+ * What this module does NOT know:
21
+ * - What an entry body means (kind, sourceRef, superseded chains...).
22
+ * It only reads meta fields the caller surfaces through `entry.meta`
23
+ * for query filtering.
24
+ * - What a VP, task, group, or message is.
25
+ * - When to compact. Compaction is a separate primitive called by 334g
26
+ * (dream). This module only surfaces `needsRecompression` advisory.
27
+ *
28
+ * Soft-cap semantics (acceptance #4):
29
+ * When a shard exceeds its softCap, operations succeed normally but the
30
+ * return value carries `needsRecompression: true` (put) or the shard
31
+ * name is listed in `result.needsRecompression` (query). The store never
32
+ * auto-compacts in response.
33
+ */
34
+
35
+ import {
36
+ existsSync,
37
+ readFileSync,
38
+ mkdirSync,
39
+ appendFileSync,
40
+ statSync,
41
+ } from 'fs';
42
+ import { join } from 'path';
43
+ import { writeAtomic } from './atomic.js';
44
+ import {
45
+ loadShardIndex,
46
+ saveShardIndex,
47
+ rebuildShardIndexFromDisk,
48
+ putEntryRecord,
49
+ removeEntryRecord,
50
+ shardFileName,
51
+ START_MARK,
52
+ END_MARK,
53
+ emptyShardIndex,
54
+ } from './shard-index.js';
55
+
56
+ /**
57
+ * Open (or create) a shard store rooted at `dir`.
58
+ * `schema` example:
59
+ * {
60
+ * shards: ['skill', 'lessons', 'preferences', 'relations'],
61
+ * softCap: {
62
+ * skill: { entries: 80, bytes: 64 * 1024 },
63
+ * lessons: { entries: 80, bytes: 64 * 1024 },
64
+ * },
65
+ * defaultSoftCap: { entries: 150, bytes: 128 * 1024 },
66
+ * }
67
+ */
68
+ export function openShardStore(dir, schema = {}) {
69
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
70
+ const shardSchema = normaliseSchema(schema);
71
+
72
+ let index = loadShardIndex(dir);
73
+ if (!index || !indexLooksConsistent(dir, index)) {
74
+ index = rebuildShardIndexFromDisk(dir, shardSchema);
75
+ // Preserve meta from the old index if rebuild lost it and we have a
76
+ // readable on-disk frontmatter strategy — out of scope for 334o; callers
77
+ // re-hydrate meta through `refreshMeta()` below if they care.
78
+ saveShardIndex(dir, index);
79
+ }
80
+
81
+ /** Write a fresh entry to a shard file, append-style. */
82
+ function put(entry) {
83
+ validateEntry(entry, shardSchema);
84
+
85
+ // Remove old copy if same id exists (keeps "put" upsert-like).
86
+ const existing = index.entries.find((e) => e.id === entry.id);
87
+ if (existing) {
88
+ compactShard(existing.shard, [entry.id]);
89
+ }
90
+
91
+ const shard = entry.shard;
92
+ const path = join(dir, shardFileName(shard));
93
+ const payload = formatEntry(entry);
94
+
95
+ // byteOffset is the size of the file BEFORE we append.
96
+ const byteOffset = existsSync(path) ? statSync(path).size : 0;
97
+ appendFileSync(path, payload);
98
+ const byteLen = Buffer.byteLength(payload, 'utf8');
99
+
100
+ putEntryRecord(index, {
101
+ id: entry.id,
102
+ shard,
103
+ byteOffset,
104
+ byteLen,
105
+ meta: sanitiseMeta(entry.meta),
106
+ });
107
+ updateShardStats(index, shard, path);
108
+ saveShardIndex(dir, index);
109
+
110
+ return {
111
+ id: entry.id,
112
+ shard,
113
+ needsRecompression: isOverSoftCap(index, shard, shardSchema),
114
+ };
115
+ }
116
+
117
+ /** Read one entry by id. Returns null if absent. */
118
+ function get(id) {
119
+ const rec = index.entries.find((e) => e.id === id);
120
+ if (!rec) return null;
121
+ const path = join(dir, shardFileName(rec.shard));
122
+ if (!existsSync(path)) return null;
123
+ const raw = readFileSync(path, 'utf8');
124
+ // Slice by byte range is approximate for multi-byte UTF-8 — we use the
125
+ // delimiter as the authoritative boundary to stay safe on emoji etc.
126
+ const body = extractBody(raw, id);
127
+ if (body === null) return null;
128
+ return { id, shard: rec.shard, body, meta: rec.meta || {} };
129
+ }
130
+
131
+ /**
132
+ * Filter entries in-memory. Filter fields:
133
+ * shard : string | string[] exact shard match
134
+ * kind : string | string[] matches meta.kind
135
+ * tags : string[] entry must contain ALL listed tags
136
+ * pinned: boolean exact match on meta.pinned
137
+ * where : (rec) => boolean escape hatch
138
+ */
139
+ function query(filter = {}) {
140
+ const { shard, kind, tags, pinned, where } = filter;
141
+ const results = [];
142
+ for (const rec of index.entries) {
143
+ if (shard && !matchesOneOf(rec.shard, shard)) continue;
144
+ if (kind && !matchesOneOf(rec.meta?.kind, kind)) continue;
145
+ if (pinned !== undefined && Boolean(rec.meta?.pinned) !== Boolean(pinned)) continue;
146
+ if (tags && tags.length > 0) {
147
+ const recTags = rec.meta?.tags || [];
148
+ if (!tags.every((t) => recTags.includes(t))) continue;
149
+ }
150
+ if (where && !where(rec)) continue;
151
+ results.push(rec);
152
+ }
153
+ // Surface which shards need re-compression so dream can schedule work.
154
+ const over = [];
155
+ for (const name of Object.keys(index.shards)) {
156
+ if (isOverSoftCap(index, name, shardSchema)) over.push(name);
157
+ }
158
+ return { results, needsRecompression: over };
159
+ }
160
+
161
+ /** Delete one entry; compacts the shard to reclaim space immediately. */
162
+ function remove(id) {
163
+ const rec = index.entries.find((e) => e.id === id);
164
+ if (!rec) return false;
165
+ compactShard(rec.shard, [id]);
166
+ return true;
167
+ }
168
+
169
+ /**
170
+ * Rewrite a shard file, omitting the entries listed in `deleteIds`.
171
+ * Exposed as both the implementation of `remove` and the public compact
172
+ * primitive used by `compact()` (no deletions, just defrag).
173
+ */
174
+ function compactShard(shardName, deleteIds = []) {
175
+ const path = join(dir, shardFileName(shardName));
176
+ if (!existsSync(path)) return;
177
+ const raw = readFileSync(path, 'utf8');
178
+ const keepIds = index.entries
179
+ .filter((e) => e.shard === shardName && !deleteIds.includes(e.id))
180
+ .map((e) => e.id);
181
+ const parts = [];
182
+ for (const id of keepIds) {
183
+ const body = extractBody(raw, id);
184
+ if (body === null) continue;
185
+ parts.push(formatEntry({ id, shard: shardName, body, meta: null }));
186
+ }
187
+ writeAtomic(path, parts.join(''));
188
+
189
+ // Update in-memory records with their new byte offsets.
190
+ let cursor = 0;
191
+ for (let i = 0; i < keepIds.length; i++) {
192
+ const id = keepIds[i];
193
+ const rec = index.entries.find((e) => e.id === id);
194
+ const part = parts[i];
195
+ const len = Buffer.byteLength(part, 'utf8');
196
+ rec.byteOffset = cursor;
197
+ rec.byteLen = len;
198
+ cursor += len;
199
+ }
200
+
201
+ // Drop removed ids from the index entirely.
202
+ for (const id of deleteIds) removeEntryRecord(index, id);
203
+
204
+ updateShardStats(index, shardName, path);
205
+ saveShardIndex(dir, index);
206
+ }
207
+
208
+ /** Public compact: rewrite one shard (or all) with no deletions. */
209
+ function compact(shardName) {
210
+ if (shardName) return compactShard(shardName, []);
211
+ for (const name of Object.keys(index.shards)) compactShard(name, []);
212
+ }
213
+
214
+ /** Allow caller (memory-family) to re-hydrate meta after bulk rebuild. */
215
+ function setMeta(id, meta) {
216
+ const rec = index.entries.find((e) => e.id === id);
217
+ if (!rec) return false;
218
+ rec.meta = sanitiseMeta(meta);
219
+ saveShardIndex(dir, index);
220
+ return true;
221
+ }
222
+
223
+ function stats() {
224
+ return structuredClone({ shards: index.shards, count: index.entries.length });
225
+ }
226
+
227
+ function getIndex() { return index; }
228
+
229
+ return { put, get, query, remove, compact, setMeta, stats, getIndex };
230
+ }
231
+
232
+ // ─── Helpers ────────────────────────────────────────────────────
233
+
234
+ function normaliseSchema(schema) {
235
+ return {
236
+ shards: Array.isArray(schema.shards) ? schema.shards.slice() : [],
237
+ softCap: schema.softCap || {},
238
+ defaultSoftCap: schema.defaultSoftCap || { entries: 1000, bytes: 10 * 1024 * 1024 },
239
+ };
240
+ }
241
+
242
+ function validateEntry(entry, schema) {
243
+ if (!entry || typeof entry !== 'object') throw new Error('entry must be an object');
244
+ if (!entry.id || typeof entry.id !== 'string') throw new Error('entry.id required (string)');
245
+ if (!/^[A-Za-z0-9_\-]+$/.test(entry.id)) throw new Error('entry.id must be [A-Za-z0-9_-]+');
246
+ if (!entry.shard || typeof entry.shard !== 'string') throw new Error('entry.shard required');
247
+ if (schema.shards.length > 0 && !schema.shards.includes(entry.shard)) {
248
+ // Open shard extension allowed by returning a warning? Spec says shards
249
+ // are fixed — so reject unknown ones. Caller can extend schema.shards[].
250
+ throw new Error(`entry.shard "${entry.shard}" not in schema.shards`);
251
+ }
252
+ if (typeof entry.body !== 'string') throw new Error('entry.body required (string)');
253
+ }
254
+
255
+ function formatEntry({ id, body }) {
256
+ // Leading \n so successive appends stay visually separated even if the
257
+ // previous entry's body didn't end in a newline.
258
+ return `\n${START_MARK(id)}\n${body.replace(/\n+$/, '')}\n${END_MARK(id)}\n`;
259
+ }
260
+
261
+ function extractBody(raw, id) {
262
+ const start = raw.indexOf(START_MARK(id));
263
+ const end = raw.indexOf(END_MARK(id));
264
+ if (start < 0 || end < 0 || end < start) return null;
265
+ const bodyStart = start + START_MARK(id).length;
266
+ return raw.slice(bodyStart, end).replace(/^\n+/, '').replace(/\n+$/, '');
267
+ }
268
+
269
+ function updateShardStats(index, shardName, path) {
270
+ const bucket = index.shards[shardName] || (index.shards[shardName] = {
271
+ entries: 0, bytes: 0, softCap: null,
272
+ });
273
+ bucket.bytes = existsSync(path) ? statSync(path).size : 0;
274
+ bucket.entries = index.entries.filter((e) => e.shard === shardName).length;
275
+ }
276
+
277
+ function isOverSoftCap(index, shardName, schema) {
278
+ const bucket = index.shards[shardName];
279
+ if (!bucket) return false;
280
+ const cap = schema.softCap?.[shardName] || schema.defaultSoftCap;
281
+ if (!cap) return false;
282
+ if (cap.entries != null && bucket.entries > cap.entries) return true;
283
+ if (cap.bytes != null && bucket.bytes > cap.bytes) return true;
284
+ return false;
285
+ }
286
+
287
+ function matchesOneOf(value, needle) {
288
+ if (Array.isArray(needle)) return needle.includes(value);
289
+ return value === needle;
290
+ }
291
+
292
+ function sanitiseMeta(meta) {
293
+ if (!meta || typeof meta !== 'object') return {};
294
+ // Only allow JSON-safe fields (number/string/boolean/array of those).
295
+ // Anything weird silently dropped so a bad call can't corrupt the index.
296
+ const out = {};
297
+ for (const [k, v] of Object.entries(meta)) {
298
+ if (v === null || ['string', 'number', 'boolean'].includes(typeof v)) {
299
+ out[k] = v;
300
+ } else if (Array.isArray(v) && v.every((x) => typeof x === 'string')) {
301
+ out[k] = v.slice();
302
+ }
303
+ }
304
+ return out;
305
+ }
306
+
307
+ function indexLooksConsistent(dir, index) {
308
+ if (!index || !Array.isArray(index.entries)) return false;
309
+ // Cheap sanity: each shard listed in index has a file on disk, OR the shard
310
+ // is empty (no entries yet). Caller recomputes sizes next op.
311
+ for (const name of Object.keys(index.shards)) {
312
+ const path = join(dir, shardFileName(name));
313
+ const bucket = index.shards[name];
314
+ if (bucket.entries > 0 && !existsSync(path)) return false;
315
+ }
316
+ return true;
317
+ }
@@ -0,0 +1,102 @@
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
7
+ *
8
+ * This module is the SOLE serialiser for the wire-format VP shape.
9
+ * Per ruling §1 (D1 = (b) web-bridge boundary rename), the entity layer
10
+ * (`vp-store.js` / `registry.js`) keeps `id` / `name`; here we map to
11
+ * `vpId` / `displayName` per the spec (§2.1) / architecture §R6.11.
12
+ *
13
+ * Per ruling §2 (D2):
14
+ * • 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.
18
+ * • color / avatar → web-derived, NOT emitted here
19
+ */
20
+
21
+ import { defaultRegistry } from './registry.js';
22
+ import { VpLoader } from './vp-loader.js';
23
+
24
+ /** Process-singleton VpLoader; lazily started on first subscribe. */
25
+ let _loaderStarted = false;
26
+ let _loader = null;
27
+
28
+ function ensureLoader() {
29
+ if (_loaderStarted) return _loader;
30
+ _loaderStarted = true;
31
+ try {
32
+ _loader = new VpLoader({ registry: defaultRegistry });
33
+ _loader.start();
34
+ } catch {
35
+ // Hot-reload optional; subscribe still returns whatever scan loaded.
36
+ _loader = null;
37
+ }
38
+ return _loader;
39
+ }
40
+
41
+ /**
42
+ * Serialise a VP (entity layer shape) to the wire-format the web layer
43
+ * expects (spec §2.1). Pure; no IO.
44
+ *
45
+ * @param {{id:string,name:string,role:string,traits?:string[],modelHint?:string,personaHash?:string}} vp
46
+ * @returns {{vpId:string,displayName:string,subtitle:string,role:string,traits:string[],modelHint:?string,personaHash:?string}}
47
+ */
48
+ export function serializeVpForWire(vp) {
49
+ return {
50
+ vpId: vp.id,
51
+ displayName: vp.name,
52
+ role: vp.role || '',
53
+ subtitle: vp.role || '',
54
+ traits: Array.isArray(vp.traits) ? vp.traits.slice() : [],
55
+ modelHint: vp.modelHint ?? null,
56
+ personaHash: vp.personaHash ?? null,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Build a vp_snapshot event payload from the registry.
62
+ * @param {import('./registry.js').Registry} [registry]
63
+ * @returns {{type:'vp_snapshot', vps:Array, emptyLibrary:boolean}}
64
+ */
65
+ export function buildVpSnapshot(registry = defaultRegistry) {
66
+ const vps = registry.listVps().map(serializeVpForWire);
67
+ return {
68
+ type: 'vp_snapshot',
69
+ vps,
70
+ emptyLibrary: vps.length === 0,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Handle an `unify_vp_subscribe` request from the web client.
76
+ * Lazily starts the VpLoader on first call (debounced rescan watchers).
77
+ *
78
+ * @param {(event: object) => void} sendUnifyEvent — emit fn (web-bridge wires this)
79
+ * @param {import('./registry.js').Registry} [registry]
80
+ */
81
+ export function handleVpSubscribe(sendUnifyEvent, registry = defaultRegistry) {
82
+ ensureLoader();
83
+ try {
84
+ sendUnifyEvent(buildVpSnapshot(registry));
85
+ } catch {
86
+ // Never crash the WS pipeline from snapshot serialisation.
87
+ }
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.
91
+ }
92
+
93
+ /**
94
+ * Test seam: reset the lazy loader (for vitest).
95
+ */
96
+ export function _resetVpBridgeForTest() {
97
+ if (_loader) {
98
+ try { _loader.stop(); } catch { /* ignore */ }
99
+ }
100
+ _loader = null;
101
+ _loaderStarted = false;
102
+ }
@@ -26,6 +26,7 @@ import { loadSession } from './session.js';
26
26
  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
+ import { handleVpSubscribe } from './vp/vp-bridge.js';
29
30
 
30
31
  /** @type {import('./session.js').Session | null} */
31
32
  let session = null;
@@ -103,6 +104,15 @@ function sendUnifyEvent(event) {
103
104
  });
104
105
  }
105
106
 
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.
111
+ */
112
+ export function handleUnifyVpSubscribe(_msg) {
113
+ handleVpSubscribe(sendUnifyEvent);
114
+ }
115
+
106
116
  /**
107
117
  * task-318 rev-1 fix: install live-setter bridge between the session's
108
118
  * runtime handles (engineRegistry + threadStore) and `ctx.unifyRuntimeSettings`,