@yeaft/webchat-agent 0.1.662 → 0.1.663

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.
@@ -1,462 +0,0 @@
1
- /**
2
- * memory/migrate-r6-to-v2.js — one-shot migration of R6 shard layout to v2.
3
- *
4
- * R6 layout (input):
5
- * ~/.yeaft/memory/
6
- * user/
7
- * MEMORY.md ← legacy R5 user profile (optional)
8
- * summary.md ← R6 paragraph synopsis (optional)
9
- * index.md ← R6 markdown table (optional)
10
- * memory-<shard>.md ← R6 semantic shards (zero or more)
11
- * entries/<date>-<slug>.md ← R6 entries (zero or more)
12
- * scopes.md ← legacy R5 scope index (optional)
13
- * groups/<id>/ ← (R6 plural) same shape
14
- * vp/<id>/ ← same shape
15
- * features/<id>/ ← (R6 plural) same shape
16
- *
17
- * v2 layout (output):
18
- * ~/.yeaft/memory/
19
- * user/
20
- * memory.md
21
- * summary.md
22
- * vp/<id>/ ← (singular)
23
- * memory.md
24
- * summary.md
25
- * group/<id>/ ← (singular)
26
- * memory.md
27
- * summary.md
28
- * feature/<id>/ ← (singular)
29
- * memory.md
30
- * summary.md
31
- *
32
- * ~/.yeaft/memory.v1.bak/<ts>/... ← snapshot of input prior to deletion
33
- *
34
- * Strategy:
35
- * - Concatenate, don't synthesise. We preserve every byte of the R6 data
36
- * by appending it to the new memory.md under labelled sections. The
37
- * first post-migration dream (PR-C) will rewrite into a clean form.
38
- * This means: zero LLM calls, zero data loss, fully deterministic.
39
- * - summary.md: prefer R6 summary.md verbatim if present; otherwise leave
40
- * empty for dream to fill.
41
- * - Topic scope: not generated by migration (no R6 source). Topic is a
42
- * pure v2 concept, populated only by future dreams.
43
- *
44
- * Modes:
45
- * - dryRun: true (default) → report planned writes, touch nothing
46
- * - apply: true → actually write v2 files + take backup
47
- *
48
- * Output:
49
- * { plan: [...], backedUpTo: '...', migratedScopes: N, errors: [...] }
50
- *
51
- * This module performs no LLM work and does not depend on store-v2; it
52
- * speaks raw filesystem so it can run before any v2 process initialises.
53
- */
54
-
55
- import {
56
- promises as fsp,
57
- existsSync,
58
- } from 'fs';
59
- import { join, dirname } from 'path';
60
-
61
- const HEADER = `<!-- migrated from R6 by migrate-r6-to-v2.js -->`;
62
-
63
- /**
64
- * Run migration.
65
- *
66
- * @param {{
67
- * root?: string, // default ~/.yeaft/memory
68
- * backupRoot?: string, // default ~/.yeaft/memory.v1.bak/<ts>
69
- * apply?: boolean, // false = dry-run
70
- * onProgress?: (event: object) => void,
71
- * }} [opts]
72
- * @returns {Promise<{
73
- * plan: object[],
74
- * migratedScopes: number,
75
- * skippedScopes: string[],
76
- * errors: object[],
77
- * backedUpTo: string|null,
78
- * }>}
79
- */
80
- export async function migrateR6toV2(opts = {}) {
81
- const root = opts.root;
82
- if (!root) throw new Error('migrateR6toV2: opts.root is required');
83
- const apply = !!opts.apply;
84
- const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : () => {};
85
- const backupRoot = opts.backupRoot
86
- || join(dirname(root), 'memory.v1.bak', new Date().toISOString().replace(/[:.]/g, '-'));
87
-
88
- const plan = [];
89
- const errors = [];
90
- const skippedScopes = [];
91
-
92
- if (!existsSync(root)) {
93
- return { plan, migratedScopes: 0, skippedScopes, errors, backedUpTo: null };
94
- }
95
-
96
- const scopeRoots = await discoverR6Scopes(root);
97
- onProgress({ phase: 'discover', count: scopeRoots.length });
98
-
99
- for (const sr of scopeRoots) {
100
- try {
101
- const action = await planScope(root, sr);
102
- plan.push(action);
103
- if (action.kind === 'skip') skippedScopes.push(sr.relPath);
104
- } catch (err) {
105
- errors.push({ scope: sr.relPath, error: err.message });
106
- }
107
- }
108
-
109
- if (!apply) {
110
- return { plan, migratedScopes: 0, skippedScopes, errors, backedUpTo: null };
111
- }
112
-
113
- // 1) backup the entire memory root before we mutate
114
- await fsp.mkdir(backupRoot, { recursive: true });
115
- await copyDir(root, backupRoot);
116
- onProgress({ phase: 'backup', backupRoot });
117
-
118
- // 2) write v2 files
119
- let migrated = 0;
120
- for (const action of plan) {
121
- if (action.kind !== 'migrate') continue;
122
- try {
123
- await applyScope(root, action);
124
- onProgress({ phase: 'migrated', scope: action.dstRelPath });
125
- migrated += 1;
126
- } catch (err) {
127
- errors.push({ scope: action.dstRelPath, error: err.message });
128
- }
129
- }
130
-
131
- // 3) delete R6 leftovers (only the files we knew about — we don't rm -rf)
132
- for (const action of plan) {
133
- if (action.kind !== 'migrate') continue;
134
- try {
135
- await purgeR6Leftovers(root, action);
136
- } catch (err) {
137
- errors.push({ scope: action.dstRelPath, error: `purge: ${err.message}` });
138
- }
139
- }
140
-
141
- return { plan, migratedScopes: migrated, skippedScopes, errors, backedUpTo: backupRoot };
142
- }
143
-
144
- // ─── discovery ────────────────────────────────────────────────
145
-
146
- /**
147
- * Walk the R6 memory root and return one entry per detected scope.
148
- * Returns shapes:
149
- * { relPath: 'user', kind: 'user' }
150
- * { relPath: 'vp/<id>', kind: 'vp', id }
151
- * { relPath: 'groups/<id>', kind: 'group', id }
152
- * { relPath: 'features/<id>', kind: 'feature', id }
153
- *
154
- * @returns {Promise<{relPath: string, kind: string, id?: string}[]>}
155
- */
156
- async function discoverR6Scopes(root) {
157
- const out = [];
158
- if (existsSync(join(root, 'user'))) out.push({ relPath: 'user', kind: 'user' });
159
-
160
- for (const [r6Folder, kind] of [
161
- ['vp', 'vp'],
162
- ['groups', 'group'],
163
- ['features', 'feature'],
164
- // Tolerate already-singular layouts (a partially migrated tree)
165
- ['group', 'group'],
166
- ['feature', 'feature'],
167
- ]) {
168
- const dir = join(root, r6Folder);
169
- if (!existsSync(dir)) continue;
170
- let names;
171
- try { names = await fsp.readdir(dir, { withFileTypes: true }); }
172
- catch { continue; }
173
- for (const ent of names) {
174
- if (!ent.isDirectory()) continue;
175
- out.push({ relPath: `${r6Folder}/${ent.name}`, kind, id: ent.name, r6Folder });
176
- }
177
- }
178
- return out;
179
- }
180
-
181
- // ─── plan ─────────────────────────────────────────────────────
182
-
183
- /**
184
- * Build a migration action for one scope: where to read from, where to
185
- * write to, and what content the new memory.md / summary.md will hold.
186
- *
187
- * @param {string} root
188
- * @param {{relPath: string, kind: string, id?: string, r6Folder?: string}} sr
189
- * @returns {Promise<object>}
190
- */
191
- async function planScope(root, sr) {
192
- const srcAbs = join(root, sr.relPath);
193
-
194
- // v2 destination dir (singular paths)
195
- let dstRel;
196
- switch (sr.kind) {
197
- case 'user': dstRel = 'user'; break;
198
- case 'vp': dstRel = `vp/${sr.id}`; break;
199
- case 'group': dstRel = `group/${sr.id}`; break;
200
- case 'feature': dstRel = `feature/${sr.id}`; break;
201
- default:
202
- return { kind: 'skip', srcRelPath: sr.relPath, reason: 'unknown kind' };
203
- }
204
-
205
- const r6Files = await readR6Files(srcAbs);
206
- if (!r6Files.hasAny) {
207
- return { kind: 'skip', srcRelPath: sr.relPath, reason: 'no R6 content' };
208
- }
209
-
210
- // If destination already has v2 layout, skip — caller may have run
211
- // migration before. We do NOT clobber.
212
- const dstAbs = join(root, dstRel);
213
- const dstHasV2 = existsSync(join(dstAbs, 'memory.md')) || existsSync(join(dstAbs, 'summary.md'));
214
- if (dstHasV2) {
215
- if (sr.relPath === dstRel) {
216
- // src == dst (already-singular layout). The memory.md/summary.md at
217
- // `dst` are the source's own R6 files — not a foreign v2 layout. Only
218
- // skip if there is genuinely no R6-specific content to fold; otherwise
219
- // proceed and concatenate the R6 surfaces into a fresh memory.md.
220
- if (!r6Files.hasShards && !r6Files.hasEntries
221
- && !r6Files.hasIndex && !r6Files.hasLegacyMemoryMd && !r6Files.hasScopesMd) {
222
- return { kind: 'skip', srcRelPath: sr.relPath, reason: 'already v2' };
223
- }
224
- // fall through → migrate
225
- } else {
226
- return {
227
- kind: 'skip',
228
- srcRelPath: sr.relPath,
229
- reason: 'destination already has v2 files; refusing to overwrite',
230
- };
231
- }
232
- }
233
-
234
- return {
235
- kind: 'migrate',
236
- srcRelPath: sr.relPath,
237
- dstRelPath: dstRel,
238
- files: r6Files,
239
- };
240
- }
241
-
242
- /**
243
- * Read every R6 surface file we might want to fold in.
244
- * @param {string} dir absolute scope dir
245
- */
246
- async function readR6Files(dir) {
247
- const out = {
248
- summaryMd: null,
249
- indexMd: null,
250
- legacyMemoryMd: null, // R5 user profile
251
- scopesMd: null, // R5 scope index
252
- shards: [], // [{name, content}]
253
- entries: [], // [{name, content}]
254
- hasShards: false,
255
- hasEntries: false,
256
- hasIndex: false,
257
- hasLegacyMemoryMd: false,
258
- hasScopesMd: false,
259
- hasAny: false,
260
- };
261
-
262
- const safeRead = async (p) => {
263
- try { return await fsp.readFile(p, 'utf8'); }
264
- catch (err) { if (err && err.code === 'ENOENT') return null; throw err; }
265
- };
266
-
267
- out.summaryMd = await safeRead(join(dir, 'summary.md'));
268
- out.indexMd = await safeRead(join(dir, 'index.md'));
269
- out.legacyMemoryMd = await safeRead(join(dir, 'MEMORY.md'));
270
- out.scopesMd = await safeRead(join(dir, 'scopes.md'));
271
- out.hasIndex = !!out.indexMd;
272
- out.hasLegacyMemoryMd = !!out.legacyMemoryMd;
273
- out.hasScopesMd = !!out.scopesMd;
274
-
275
- // memory-<shard>.md files (semantic shards). Skip the literal v2 file
276
- // `memory.md` so a partially-migrated tree isn't double-counted.
277
- let names;
278
- try { names = await fsp.readdir(dir, { withFileTypes: true }); }
279
- catch (err) { if (err && err.code === 'ENOENT') names = []; else throw err; }
280
- for (const ent of names) {
281
- if (!ent.isFile()) continue;
282
- if (ent.name === 'memory.md') continue;
283
- const m = /^memory-(.+)\.md(\.compacting)?$/.exec(ent.name);
284
- if (!m) continue;
285
- const content = await safeRead(join(dir, ent.name));
286
- if (content !== null) out.shards.push({ name: ent.name, content });
287
- }
288
- out.hasShards = out.shards.length > 0;
289
-
290
- // entries/*.md
291
- const entriesDir = join(dir, 'entries');
292
- if (existsSync(entriesDir)) {
293
- let entryNames;
294
- try { entryNames = (await fsp.readdir(entriesDir)).filter(n => n.endsWith('.md')).sort(); }
295
- catch { entryNames = []; }
296
- for (const n of entryNames) {
297
- const content = await safeRead(join(entriesDir, n));
298
- if (content !== null) out.entries.push({ name: n, content });
299
- }
300
- }
301
- out.hasEntries = out.entries.length > 0;
302
-
303
- out.hasAny = !!(out.summaryMd || out.indexMd || out.legacyMemoryMd || out.scopesMd
304
- || out.hasShards || out.hasEntries);
305
- return out;
306
- }
307
-
308
- // ─── apply ────────────────────────────────────────────────────
309
-
310
- /**
311
- * Execute one planned migrate action.
312
- *
313
- * @param {string} root
314
- * @param {object} action
315
- */
316
- async function applyScope(root, action) {
317
- const dstAbs = join(root, action.dstRelPath);
318
- await fsp.mkdir(dstAbs, { recursive: true });
319
-
320
- const memoryMd = composeMemoryMd(action.files);
321
- await atomicWrite(join(dstAbs, 'memory.md'), memoryMd);
322
-
323
- const summary = (action.files.summaryMd || '').trim();
324
- if (summary) {
325
- await atomicWrite(join(dstAbs, 'summary.md'), `${summary}\n`);
326
- } else {
327
- // Initialise an empty summary.md so listScopes detects 1-level scopes.
328
- await atomicWrite(join(dstAbs, 'summary.md'), '\n');
329
- }
330
- }
331
-
332
- /**
333
- * Compose the v2 memory.md by labelled-section concatenation. We keep
334
- * everything; dream will rewrite later.
335
- */
336
- function composeMemoryMd(files) {
337
- const parts = [HEADER, ''];
338
-
339
- if (files.legacyMemoryMd) {
340
- parts.push('## Legacy MEMORY.md (R5 user profile)');
341
- parts.push('');
342
- parts.push(files.legacyMemoryMd.trim());
343
- parts.push('');
344
- }
345
-
346
- if (files.shards.length > 0) {
347
- parts.push('## R6 shards');
348
- parts.push('');
349
- for (const sh of files.shards) {
350
- parts.push(`### ${sh.name}`);
351
- parts.push('');
352
- parts.push(sh.content.trim());
353
- parts.push('');
354
- }
355
- }
356
-
357
- if (files.entries.length > 0) {
358
- parts.push('## R6 entries');
359
- parts.push('');
360
- for (const e of files.entries) {
361
- parts.push(`### ${e.name}`);
362
- parts.push('');
363
- parts.push(e.content.trim());
364
- parts.push('');
365
- }
366
- }
367
-
368
- if (files.indexMd) {
369
- parts.push('## R6 index.md (deprecated; preserved for first dream)');
370
- parts.push('');
371
- parts.push(files.indexMd.trim());
372
- parts.push('');
373
- }
374
-
375
- if (files.scopesMd) {
376
- parts.push('## Legacy scopes.md');
377
- parts.push('');
378
- parts.push(files.scopesMd.trim());
379
- parts.push('');
380
- }
381
-
382
- parts.push('<!-- dream-state -->');
383
- parts.push(`migratedAt: ${new Date().toISOString()}`);
384
- parts.push('<!-- /dream-state -->');
385
- parts.push('');
386
-
387
- return parts.join('\n');
388
- }
389
-
390
- /**
391
- * Remove the R6 surface files we just folded in. Leaves any unrelated files
392
- * alone (for safety). Does NOT remove parent directories — listScopes will
393
- * tolerate empty `groups/`, `features/`, `vp/` shells.
394
- *
395
- * @param {string} root
396
- * @param {object} action
397
- */
398
- async function purgeR6Leftovers(root, action) {
399
- const srcAbs = join(root, action.srcRelPath);
400
- // The destination may equal the source (already-singular vp/group/feature),
401
- // in which case we must NOT delete memory.md / summary.md — those are the
402
- // newly written v2 files. Track the v2 names as protected.
403
- const dstAbs = join(root, action.dstRelPath);
404
- const dstSame = srcAbs === dstAbs;
405
-
406
- // shards
407
- const names = await fsp.readdir(srcAbs);
408
- for (const n of names) {
409
- if (dstSame && (n === 'memory.md' || n === 'summary.md')) continue;
410
- const full = join(srcAbs, n);
411
- if (/^memory-.+\.md(\.compacting)?$/.test(n)) {
412
- await fsp.unlink(full).catch(() => {});
413
- } else if (n === 'index.md' || n === 'MEMORY.md' || n === 'scopes.md') {
414
- await fsp.unlink(full).catch(() => {});
415
- } else if (n === 'entries') {
416
- await rmDir(full);
417
- } else if (n === 'index.json') {
418
- await fsp.unlink(full).catch(() => {});
419
- } else if (!dstSame && (n === 'memory.md' || n === 'summary.md')) {
420
- // Source dir differs from dst: these were the R6 originals, already
421
- // folded into the new dst. Remove so the source dir can be rmdir'd.
422
- await fsp.unlink(full).catch(() => {});
423
- }
424
- }
425
-
426
- // If the source path differs from destination (groups/<id> → group/<id>),
427
- // remove the now-empty source dir to avoid two parallel trees. Only when
428
- // it's actually empty.
429
- if (!dstSame) {
430
- try {
431
- const remaining = await fsp.readdir(srcAbs);
432
- if (remaining.length === 0) await fsp.rmdir(srcAbs);
433
- } catch { /* tolerate */ }
434
- }
435
- }
436
-
437
- // ─── tiny fs helpers ──────────────────────────────────────────
438
-
439
- async function atomicWrite(absPath, content) {
440
- await fsp.mkdir(dirname(absPath), { recursive: true });
441
- const tmp = `${absPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
442
- await fsp.writeFile(tmp, content, 'utf8');
443
- await fsp.rename(tmp, absPath);
444
- }
445
-
446
- async function copyDir(src, dst) {
447
- await fsp.mkdir(dst, { recursive: true });
448
- const entries = await fsp.readdir(src, { withFileTypes: true });
449
- for (const e of entries) {
450
- const s = join(src, e.name);
451
- const d = join(dst, e.name);
452
- if (e.isDirectory()) await copyDir(s, d);
453
- else if (e.isFile()) await fsp.copyFile(s, d);
454
- // ignore symlinks/sockets
455
- }
456
- }
457
-
458
- async function rmDir(p) {
459
- try {
460
- await fsp.rm(p, { recursive: true, force: true });
461
- } catch { /* tolerate */ }
462
- }