@yeaft/webchat-agent 0.1.628 → 0.1.630

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,402 @@
1
+ /**
2
+ * memory/store-v2.js — DESIGN-v2.md Part I: per-scope memory.md + summary.md.
3
+ *
4
+ * One pair of files per scope. No shards, no entries/, no index.md, no
5
+ * index.json. The five scope kinds — user, vp, group, feature, topic — share
6
+ * a single shape:
7
+ *
8
+ * ~/.yeaft/memory/
9
+ * user/ memory.md summary.md
10
+ * vp/<vpId>/ memory.md summary.md
11
+ * group/<groupId>/ memory.md summary.md
12
+ * feature/<featureId>/ memory.md summary.md
13
+ * topic/<l1>[/<l2>]/ memory.md summary.md (≤ 2 levels)
14
+ *
15
+ * Atomicity contract:
16
+ * - Every write goes via `.tmp.<rand>` + rename. Renames are atomic on a
17
+ * single POSIX mount. A reader mid-write sees either the previous file
18
+ * or the next, never half of either.
19
+ * - Reading a missing file returns the empty string. The "scope exists"
20
+ * question is answered by directory presence, not file presence.
21
+ *
22
+ * Concurrency rules:
23
+ * - Two writers to the same memory.md: last-rename wins. Dream is the only
24
+ * code path that overwrites memory.md in v2; daily writes append. Append
25
+ * is a single fs.appendFile call that POSIX guarantees is atomic for
26
+ * buffers ≤ PIPE_BUF (≥ 4KB on every supported platform), which fits a
27
+ * single fragment.
28
+ *
29
+ * ACL:
30
+ * - This module enforces ONE ACL: `vp/<other>` paths are blocked when
31
+ * `currentVpId` is given and differs from `<other>`. Every other scope
32
+ * boundary is ACL-free in v2 (DESIGN-v2 §3.2).
33
+ *
34
+ * What this module deliberately does NOT do:
35
+ * - No frontmatter parsing. memory.md and summary.md are pure markdown;
36
+ * the dream-state metadata block lives at the file's tail and is read
37
+ * by `dream-v2/state.js`, not here.
38
+ * - No LLM calls, no extraction, no summarisation. Pure I/O.
39
+ * - No legacy R6 fallback. The old MemoryStore (memory/store.js) and
40
+ * ScopeTree (memory/scope-tree.js) remain in service until PR-E swaps
41
+ * callers; this module is additive.
42
+ *
43
+ * Reference: agent/unify/memory/DESIGN-v2.md §2, §5, §9.
44
+ */
45
+
46
+ import {
47
+ promises as fsp,
48
+ existsSync,
49
+ mkdirSync,
50
+ } from 'fs';
51
+ import { join, dirname } from 'path';
52
+ import { homedir } from 'os';
53
+
54
+ /** Default memory root. Tests override via `opts.root`. */
55
+ export const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
56
+
57
+ /** Scope kinds recognised by v2. */
58
+ export const SCOPE_KINDS = Object.freeze(['user', 'vp', 'group', 'feature', 'topic']);
59
+
60
+ /** @typedef {'user'|'vp'|'group'|'feature'|'topic'} ScopeKind */
61
+
62
+ /**
63
+ * @typedef {Object} Scope
64
+ * @property {ScopeKind} kind
65
+ * @property {string} [id] — required for vp / group / feature
66
+ * @property {string[]} [path] — required for topic; 1–2 segments
67
+ */
68
+
69
+ /**
70
+ * Compute a scope's directory path relative to the memory root.
71
+ * Returns POSIX-style separators on every platform — the segments compose
72
+ * by `/` for `path.join()` to normalise per-OS at the I/O boundary.
73
+ *
74
+ * @param {Scope} scope
75
+ * @returns {string}
76
+ */
77
+ export function scopeDir(scope) {
78
+ if (!scope || typeof scope !== 'object') {
79
+ throw new Error('scopeDir: scope is required');
80
+ }
81
+ switch (scope.kind) {
82
+ case 'user':
83
+ return 'user';
84
+ case 'vp':
85
+ if (!scope.id) throw new Error('scopeDir: vp scope requires id');
86
+ assertSafeSegment(scope.id, 'vp.id');
87
+ return `vp/${scope.id}`;
88
+ case 'group':
89
+ if (!scope.id) throw new Error('scopeDir: group scope requires id');
90
+ assertSafeSegment(scope.id, 'group.id');
91
+ return `group/${scope.id}`;
92
+ case 'feature':
93
+ if (!scope.id) throw new Error('scopeDir: feature scope requires id');
94
+ assertSafeSegment(scope.id, 'feature.id');
95
+ return `feature/${scope.id}`;
96
+ case 'topic': {
97
+ const segs = Array.isArray(scope.path) ? scope.path : [];
98
+ if (segs.length === 0 || segs.length > 2) {
99
+ throw new Error('scopeDir: topic.path must have 1 or 2 segments');
100
+ }
101
+ for (const s of segs) assertSafeSegment(s, 'topic.path');
102
+ return `topic/${segs.join('/')}`;
103
+ }
104
+ default:
105
+ throw new Error(`scopeDir: unknown kind ${JSON.stringify(scope.kind)}`);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Reject path segments that could escape the scope dir or hit reserved names.
111
+ * Allows letters, digits, underscore, dash, dot — but rejects `.` / `..` and
112
+ * any segment that contains a path separator. Reserved prefix `_` is allowed
113
+ * for system dirs (`_no-group`, `_proposals`) when called from internal sites,
114
+ * but disallowed for user-supplied ids by callers.
115
+ *
116
+ * @param {string} s
117
+ * @param {string} ctx
118
+ */
119
+ function assertSafeSegment(s, ctx) {
120
+ if (typeof s !== 'string' || s.length === 0) {
121
+ throw new Error(`scopeDir: ${ctx} must be a non-empty string`);
122
+ }
123
+ if (s === '.' || s === '..') {
124
+ throw new Error(`scopeDir: ${ctx} cannot be "." or ".."`);
125
+ }
126
+ if (/[\\/]/.test(s)) {
127
+ throw new Error(`scopeDir: ${ctx} cannot contain path separators (got ${JSON.stringify(s)})`);
128
+ }
129
+ // Allow CJK + ASCII identifier-ish characters. Tighten over time if needed.
130
+ if (!/^[A-Za-z0-9_\-.一-鿿]+$/.test(s)) {
131
+ throw new Error(`scopeDir: ${ctx} contains disallowed characters: ${JSON.stringify(s)}`);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Validate topic depth without throwing on the structural-shape errors that
137
+ * `scopeDir` already covers. Returns true iff `kind=topic` and 1 ≤ path ≤ 2.
138
+ *
139
+ * @param {Scope} scope
140
+ * @returns {boolean}
141
+ */
142
+ export function isValidTopic(scope) {
143
+ if (!scope || scope.kind !== 'topic') return false;
144
+ if (!Array.isArray(scope.path)) return false;
145
+ if (scope.path.length < 1 || scope.path.length > 2) return false;
146
+ for (const s of scope.path) {
147
+ if (typeof s !== 'string' || s.length === 0) return false;
148
+ if (s === '.' || s === '..') return false;
149
+ if (/[\\/]/.test(s)) return false;
150
+ if (!/^[A-Za-z0-9_\-.一-鿿]+$/.test(s)) return false;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ // ─── ACL ───────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * The single ACL: `vp/<other>` is foreign when `currentVpId` is given.
159
+ *
160
+ * @param {string} relPath
161
+ * @param {string} currentVpId
162
+ * @returns {boolean}
163
+ */
164
+ export function isVpForeign(relPath, currentVpId) {
165
+ if (!relPath || !currentVpId) return false;
166
+ const m = /^vp\/([^/]+)(?:\/|$)/.exec(relPath);
167
+ if (!m) return false;
168
+ return m[1] !== currentVpId;
169
+ }
170
+
171
+ function enforceVpAcl(rel, currentVpId) {
172
+ if (currentVpId && isVpForeign(rel, currentVpId)) {
173
+ const e = new Error('acl_blocked');
174
+ e.code = 'acl_blocked';
175
+ e.path = rel;
176
+ throw e;
177
+ }
178
+ }
179
+
180
+ // ─── atomic write ──────────────────────────────────────────────
181
+
182
+ /**
183
+ * Atomic write: temp + rename. Creates parent directories on demand.
184
+ * @param {string} absPath
185
+ * @param {string} content
186
+ */
187
+ async function atomicWrite(absPath, content) {
188
+ await fsp.mkdir(dirname(absPath), { recursive: true });
189
+ const tmp = `${absPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
190
+ await fsp.writeFile(tmp, content, 'utf8');
191
+ await fsp.rename(tmp, absPath);
192
+ }
193
+
194
+ // ─── memory.md ─────────────────────────────────────────────────
195
+
196
+ /**
197
+ * Read a scope's memory.md. Missing → empty string.
198
+ *
199
+ * @param {Scope} scope
200
+ * @param {{ root?: string, currentVpId?: string }} [opts]
201
+ * @returns {Promise<string>}
202
+ */
203
+ export async function readMemory(scope, opts = {}) {
204
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
205
+ const rel = `${scopeDir(scope)}/memory.md`;
206
+ enforceVpAcl(rel, currentVpId);
207
+ const abs = join(root, rel);
208
+ try { return await fsp.readFile(abs, 'utf8'); }
209
+ catch (err) {
210
+ if (err && err.code === 'ENOENT') return '';
211
+ throw err;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Atomically rewrite a scope's memory.md.
217
+ *
218
+ * @param {Scope} scope
219
+ * @param {string} content
220
+ * @param {{ root?: string, currentVpId?: string }} [opts]
221
+ */
222
+ export async function writeMemory(scope, content, opts = {}) {
223
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
224
+ const rel = `${scopeDir(scope)}/memory.md`;
225
+ enforceVpAcl(rel, currentVpId);
226
+ const abs = join(root, rel);
227
+ await atomicWrite(abs, typeof content === 'string' ? content : '');
228
+ }
229
+
230
+ /**
231
+ * Append to a scope's memory.md. Used by the rare "direct write" path
232
+ * (DESIGN-v2 §7.1); main flow is dream-driven rewrites.
233
+ *
234
+ * Append is non-atomic with concurrent readers in the strict sense, but a
235
+ * single appendFile of a small buffer is atomic at the kernel level on POSIX
236
+ * — sufficient for fragment-sized appends. Two concurrent appenders may
237
+ * interleave bytes only if both buffers exceed PIPE_BUF; we keep callers in
238
+ * the single-buffer regime.
239
+ *
240
+ * @param {Scope} scope
241
+ * @param {string} chunk
242
+ * @param {{ root?: string, currentVpId?: string }} [opts]
243
+ */
244
+ export async function appendMemory(scope, chunk, opts = {}) {
245
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
246
+ const rel = `${scopeDir(scope)}/memory.md`;
247
+ enforceVpAcl(rel, currentVpId);
248
+ const abs = join(root, rel);
249
+ await fsp.mkdir(dirname(abs), { recursive: true });
250
+ const text = typeof chunk === 'string' ? chunk : '';
251
+ if (text.length === 0) return;
252
+ await fsp.appendFile(abs, text, 'utf8');
253
+ }
254
+
255
+ // ─── summary.md ────────────────────────────────────────────────
256
+
257
+ /**
258
+ * Read a scope's summary.md (trimmed). Missing → empty string.
259
+ *
260
+ * @param {Scope} scope
261
+ * @param {{ root?: string, currentVpId?: string }} [opts]
262
+ * @returns {Promise<string>}
263
+ */
264
+ export async function readSummary(scope, opts = {}) {
265
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
266
+ const rel = `${scopeDir(scope)}/summary.md`;
267
+ enforceVpAcl(rel, currentVpId);
268
+ const abs = join(root, rel);
269
+ try { return (await fsp.readFile(abs, 'utf8')).trim(); }
270
+ catch (err) {
271
+ if (err && err.code === 'ENOENT') return '';
272
+ throw err;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Atomically rewrite a scope's summary.md. Empty body → empty file.
278
+ *
279
+ * @param {Scope} scope
280
+ * @param {string} body
281
+ * @param {{ root?: string, currentVpId?: string }} [opts]
282
+ */
283
+ export async function writeSummary(scope, body, opts = {}) {
284
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
285
+ const rel = `${scopeDir(scope)}/summary.md`;
286
+ enforceVpAcl(rel, currentVpId);
287
+ const abs = join(root, rel);
288
+ await atomicWrite(abs, `${(body || '').trim()}\n`);
289
+ }
290
+
291
+ // ─── scope discovery ───────────────────────────────────────────
292
+
293
+ /**
294
+ * Best-effort: ensure a scope's directory exists. Idempotent.
295
+ *
296
+ * @param {Scope} scope
297
+ * @param {{ root?: string }} [opts]
298
+ */
299
+ export function ensureScopeSync(scope, opts = {}) {
300
+ const { root = DEFAULT_MEMORY_ROOT } = opts;
301
+ const dir = join(root, scopeDir(scope));
302
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
303
+ }
304
+
305
+ /**
306
+ * Async variant of ensureScopeSync.
307
+ *
308
+ * @param {Scope} scope
309
+ * @param {{ root?: string }} [opts]
310
+ */
311
+ export async function ensureScope(scope, opts = {}) {
312
+ const { root = DEFAULT_MEMORY_ROOT } = opts;
313
+ const dir = join(root, scopeDir(scope));
314
+ await fsp.mkdir(dir, { recursive: true });
315
+ }
316
+
317
+ /**
318
+ * Enumerate all scopes present on disk. Returns Scope shapes that round-trip
319
+ * back through `scopeDir`. Used by Triage (DESIGN-v2 §14) to list candidate
320
+ * scopes for a group's diff.
321
+ *
322
+ * Walks shallowly:
323
+ * user/ → { kind: 'user' }
324
+ * vp/<id>/ → { kind: 'vp', id }
325
+ * group/<id>/ → { kind: 'group', id }
326
+ * feature/<id>/ → { kind: 'feature', id }
327
+ * topic/<l1>/[<l2>/] → { kind: 'topic', path: [...] }
328
+ *
329
+ * Skips entries that are not directories, and any name that fails segment
330
+ * validation (e.g. accidental `.tmp.*` files at scope root, dotfiles).
331
+ *
332
+ * @param {{ root?: string }} [opts]
333
+ * @returns {Promise<Scope[]>}
334
+ */
335
+ export async function listScopes(opts = {}) {
336
+ const { root = DEFAULT_MEMORY_ROOT } = opts;
337
+ const out = [];
338
+ if (!existsSync(root)) return out;
339
+
340
+ // user/
341
+ if (existsSync(join(root, 'user'))) out.push({ kind: 'user' });
342
+
343
+ // vp/, group/, feature/ — single-level ids
344
+ for (const kind of ['vp', 'group', 'feature']) {
345
+ const dir = join(root, kind);
346
+ let names;
347
+ try { names = await fsp.readdir(dir, { withFileTypes: true }); }
348
+ catch (err) {
349
+ if (err && err.code === 'ENOENT') continue;
350
+ throw err;
351
+ }
352
+ for (const ent of names) {
353
+ if (!ent.isDirectory()) continue;
354
+ const id = ent.name;
355
+ if (!isSafeId(id)) continue;
356
+ out.push({ kind, id });
357
+ }
358
+ }
359
+
360
+ // topic/<l1>/[<l2>/]
361
+ const topicDir = join(root, 'topic');
362
+ let l1s;
363
+ try { l1s = await fsp.readdir(topicDir, { withFileTypes: true }); }
364
+ catch (err) {
365
+ if (err && err.code === 'ENOENT') l1s = [];
366
+ else throw err;
367
+ }
368
+ for (const l1ent of l1s) {
369
+ if (!l1ent.isDirectory()) continue;
370
+ if (!isSafeId(l1ent.name)) continue;
371
+ const l1 = l1ent.name;
372
+ // Read l2 entries; if l1 itself contains memory.md, treat as 1-level topic
373
+ const l1abs = join(topicDir, l1);
374
+ let l2s;
375
+ try { l2s = await fsp.readdir(l1abs, { withFileTypes: true }); }
376
+ catch { l2s = []; }
377
+ let hasL2 = false;
378
+ for (const l2ent of l2s) {
379
+ if (!l2ent.isDirectory()) continue;
380
+ if (!isSafeId(l2ent.name)) continue;
381
+ out.push({ kind: 'topic', path: [l1, l2ent.name] });
382
+ hasL2 = true;
383
+ }
384
+ // 1-level topic: present iff l1 has memory.md or summary.md directly
385
+ if (!hasL2) {
386
+ const hasMemory = existsSync(join(l1abs, 'memory.md'));
387
+ const hasSummary = existsSync(join(l1abs, 'summary.md'));
388
+ if (hasMemory || hasSummary) {
389
+ out.push({ kind: 'topic', path: [l1] });
390
+ }
391
+ }
392
+ }
393
+
394
+ return out;
395
+ }
396
+
397
+ function isSafeId(s) {
398
+ if (typeof s !== 'string' || s.length === 0) return false;
399
+ if (s === '.' || s === '..') return false;
400
+ if (/[\\/]/.test(s)) return false;
401
+ return /^[A-Za-z0-9_\-.一-鿿]+$/.test(s);
402
+ }