@yeaft/webchat-agent 0.1.627 → 0.1.629
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/unify/memory/migrate-r6-to-v2.js +462 -0
- package/unify/memory/store-v2.js +402 -0
package/package.json
CHANGED
|
@@ -0,0 +1,462 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|