@yeaft/webchat-agent 0.1.519 → 0.1.521

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.
@@ -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
+ }