@yeaft/webchat-agent 0.1.856 → 0.1.859

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/yeaft/init.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * init.js — Yeaft directory structure initialization
3
3
  *
4
4
  * Ensures ~/.yeaft/ and all required subdirectories exist.
5
- * Creates default config.md, MEMORY.md, chat/index.md, and group/index.md if missing.
5
+ * Creates default config.md, MEMORY.md, and chat/index.md if missing.
6
6
  */
7
7
 
8
8
  import { existsSync, mkdirSync, writeFileSync, accessSync, constants } from 'fs';
@@ -77,10 +77,7 @@ const SUBDIRS = [
77
77
  'chat/messages',
78
78
  'chat/cold',
79
79
  'chat/blobs',
80
- 'group/messages',
81
- 'group/cold',
82
- 'group/blobs',
83
- 'group/compact',
80
+ 'groups',
84
81
  'memory/entries',
85
82
  'tasks',
86
83
  'skills',
@@ -200,12 +197,10 @@ export function initYeaftDir(dir) {
200
197
  created.push(memoryPath);
201
198
  }
202
199
 
203
- for (const mode of ['chat', 'group']) {
204
- const indexPath = join(root, mode, 'index.md');
205
- if (!existsSync(indexPath)) {
206
- safeWriteFile(indexPath, DEFAULT_CONVERSATION_INDEX, warnings);
207
- created.push(indexPath);
208
- }
200
+ const chatIndexPath = join(root, 'chat', 'index.md');
201
+ if (!existsSync(chatIndexPath)) {
202
+ safeWriteFile(chatIndexPath, DEFAULT_CONVERSATION_INDEX, warnings);
203
+ created.push(chatIndexPath);
209
204
  }
210
205
 
211
206
  // mcp.json.example — reference template for MCP server configuration
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { approxTokens } from './budget.js';
31
+ import { isVpForeign } from './store-v2.js';
31
32
 
32
33
  /**
33
34
  * @typedef {object} AdjustTriggerInput
@@ -104,10 +105,7 @@ function firstSentence(body) {
104
105
  }
105
106
 
106
107
  function isOwnOrNonVp(scope, ownVpId) {
107
- if (!scope.startsWith('vp/')) return true;
108
- if (!ownVpId) return true;
109
- const other = scope.slice(3).split('/')[0];
110
- return other === ownVpId;
108
+ return !isVpForeign(scope, ownVpId);
111
109
  }
112
110
 
113
111
  /**
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { approxTokens, packWithinBudget } from './budget.js';
20
+ import { isVpForeign } from './store-v2.js';
20
21
 
21
22
  const RECENT_DEFAULT_CAPACITY = 64;
22
23
 
@@ -189,9 +190,6 @@ export class ActiveMemorySet {
189
190
  // ────────────────────────── privacy ──────────────────────────
190
191
 
191
192
  _isForeignVp(scope) {
192
- if (!scope || !scope.startsWith('vp/')) return false;
193
- if (!this.ownVpId) return false; // no own id → no filtering
194
- const other = scope.slice(3).split('/')[0];
195
- return other !== this.ownVpId;
193
+ return isVpForeign(scope, this.ownVpId);
196
194
  }
197
195
  }
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { extractKeywords } from './keywords.js';
19
19
  import { approxTokens } from './budget.js';
20
+ import { isVpForeign } from './store-v2.js';
20
21
 
21
22
  /**
22
23
  * @typedef {object} PreflowOptions
@@ -118,12 +119,7 @@ export function buildFtsQuery(keywords) {
118
119
  * @returns {string[]}
119
120
  */
120
121
  export function filterScopes(scopes, ownVpId) {
121
- return scopes.filter(s => {
122
- if (!s.startsWith('vp/')) return true;
123
- if (!ownVpId) return true;
124
- const other = s.slice(3).split('/')[0];
125
- return other === ownVpId;
126
- });
122
+ return scopes.filter(s => !isVpForeign(s, ownVpId));
127
123
  }
128
124
 
129
125
  /**
@@ -15,7 +15,7 @@
15
15
  * a permission error must NEVER prevent the session from loading.
16
16
  */
17
17
 
18
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, renameSync } from 'fs';
19
19
  import { join } from 'path';
20
20
  import { homedir } from 'os';
21
21
  import { parseRoleMd } from '../vp/vp-store.js';
@@ -292,3 +292,43 @@ export function runSummaryBackfill({ yeaftDir, libDir, root = DEFAULT_MEMORY_ROO
292
292
  }
293
293
  return { migrate, vp, group };
294
294
  }
295
+
296
+ /**
297
+ * archiveLegacyScopes(root) — one-shot migration for the group-isolated
298
+ * memory refactor. The legacy flat layout had `vp/<id>/`, `feature/<id>/`,
299
+ * and `topic/<l1>[/<l2>]/` directories at the memory root; the new layout
300
+ * tucks each into `group/<g>/{vp,feature,topic}/...`. Per user directive
301
+ * "硬切,老的就不要了" — we do NOT migrate per-record, we just move the
302
+ * top-level dirs to `<root>/.legacy/<kind>/` once. They are never read
303
+ * again; this is forensics-only.
304
+ *
305
+ * Idempotent: a second invocation is a no-op when no legacy dirs remain at
306
+ * the root. If `.legacy/<kind>/` already exists, the new move is suffixed
307
+ * with a timestamp so re-attempts after a partial first run don't clobber.
308
+ *
309
+ * @param {string} root memory root (typically <yeaftDir>/memory)
310
+ * @returns {{moved: string[]}}
311
+ */
312
+ export function archiveLegacyScopes(root) {
313
+ const moved = [];
314
+ if (!root || !existsSync(root)) return { moved };
315
+ const legacyRoot = join(root, '.legacy');
316
+ for (const kind of ['vp', 'feature', 'topic']) {
317
+ const src = join(root, kind);
318
+ if (!existsSync(src)) continue;
319
+ try {
320
+ mkdirSync(legacyRoot, { recursive: true });
321
+ let dst = join(legacyRoot, kind);
322
+ if (existsSync(dst)) {
323
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
324
+ dst = `${dst}.${ts}`;
325
+ }
326
+ // eslint-disable-next-line global-require
327
+ renameSync(src, dst);
328
+ moved.push(kind);
329
+ } catch (err) {
330
+ console.warn(`[seed-backfill] archiveLegacyScopes(${kind}) failed:`, err?.message || err);
331
+ }
332
+ }
333
+ return { moved };
334
+ }
@@ -51,7 +51,7 @@ export const KIND_VALUES = new Set([
51
51
  'fact', 'preference', 'decision', 'lesson', 'relation', 'goal', 'context',
52
52
  ]);
53
53
 
54
- const SCOPE_RE = /^(user|vp\/[\w-]+|group\/[\w-]+|feature\/[\w-]+|topic\/[\w-]+(?:\/[\w-]+)?)$/;
54
+ const SCOPE_RE = /^(user|group\/[\w-]+(?:\/(?:user|vp\/[\w-]+|feature\/[\w-]+|topic\/[\w-]+(?:\/[\w-]+)?))?)$/;
55
55
 
56
56
  /**
57
57
  * Compute a stable id from segment content. Same body + scope + kind →
@@ -54,16 +54,24 @@ import { homedir } from 'os';
54
54
  /** Default memory root. Tests override via `opts.root`. */
55
55
  export const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
56
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 */
57
+ /** Scope kinds recognised by v2 (group-isolated layout). */
58
+ export const SCOPE_KINDS = Object.freeze([
59
+ 'user',
60
+ 'group',
61
+ 'group-user',
62
+ 'group-vp',
63
+ 'group-feature',
64
+ 'group-topic',
65
+ ]);
66
+
67
+ /** @typedef {'user'|'group'|'group-user'|'group-vp'|'group-feature'|'group-topic'} ScopeKind */
61
68
 
62
69
  /**
63
70
  * @typedef {Object} Scope
64
71
  * @property {ScopeKind} kind
65
- * @property {string} [id] — required for vp / group / feature
66
- * @property {string[]} [path] — required for topic; 1–2 segments
72
+ * @property {string} [id] — required for group; for group-vp / group-feature the per-kind id
73
+ * @property {string} [groupId] — required for every group-* kind
74
+ * @property {string[]} [path] — required for group-topic; 1–2 segments
67
75
  */
68
76
 
69
77
  /**
@@ -81,25 +89,38 @@ export function scopeDir(scope) {
81
89
  switch (scope.kind) {
82
90
  case 'user':
83
91
  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
92
  case 'group':
89
93
  if (!scope.id) throw new Error('scopeDir: group scope requires id');
90
94
  assertSafeSegment(scope.id, 'group.id');
91
95
  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': {
96
+ case 'group-user': {
97
+ if (!scope.groupId) throw new Error('scopeDir: group-user scope requires groupId');
98
+ assertSafeSegment(scope.groupId, 'group-user.groupId');
99
+ return `group/${scope.groupId}/user`;
100
+ }
101
+ case 'group-vp': {
102
+ if (!scope.groupId) throw new Error('scopeDir: group-vp scope requires groupId');
103
+ if (!scope.id) throw new Error('scopeDir: group-vp scope requires id');
104
+ assertSafeSegment(scope.groupId, 'group-vp.groupId');
105
+ assertSafeSegment(scope.id, 'group-vp.id');
106
+ return `group/${scope.groupId}/vp/${scope.id}`;
107
+ }
108
+ case 'group-feature': {
109
+ if (!scope.groupId) throw new Error('scopeDir: group-feature scope requires groupId');
110
+ if (!scope.id) throw new Error('scopeDir: group-feature scope requires id');
111
+ assertSafeSegment(scope.groupId, 'group-feature.groupId');
112
+ assertSafeSegment(scope.id, 'group-feature.id');
113
+ return `group/${scope.groupId}/feature/${scope.id}`;
114
+ }
115
+ case 'group-topic': {
116
+ if (!scope.groupId) throw new Error('scopeDir: group-topic scope requires groupId');
117
+ assertSafeSegment(scope.groupId, 'group-topic.groupId');
97
118
  const segs = Array.isArray(scope.path) ? scope.path : [];
98
119
  if (segs.length === 0 || segs.length > 2) {
99
- throw new Error('scopeDir: topic.path must have 1 or 2 segments');
120
+ throw new Error('scopeDir: group-topic.path must have 1 or 2 segments');
100
121
  }
101
- for (const s of segs) assertSafeSegment(s, 'topic.path');
102
- return `topic/${segs.join('/')}`;
122
+ for (const s of segs) assertSafeSegment(s, 'group-topic.path');
123
+ return `group/${scope.groupId}/topic/${segs.join('/')}`;
103
124
  }
104
125
  default:
105
126
  throw new Error(`scopeDir: unknown kind ${JSON.stringify(scope.kind)}`);
@@ -140,7 +161,8 @@ function assertSafeSegment(s, ctx) {
140
161
  * @returns {boolean}
141
162
  */
142
163
  export function isValidTopic(scope) {
143
- if (!scope || scope.kind !== 'topic') return false;
164
+ if (!scope || scope.kind !== 'group-topic') return false;
165
+ if (!scope.groupId || typeof scope.groupId !== 'string') return false;
144
166
  if (!Array.isArray(scope.path)) return false;
145
167
  if (scope.path.length < 1 || scope.path.length > 2) return false;
146
168
  for (const s of scope.path) {
@@ -155,7 +177,9 @@ export function isValidTopic(scope) {
155
177
  // ─── ACL ───────────────────────────────────────────────────────
156
178
 
157
179
  /**
158
- * The single ACL: `vp/<other>` is foreign when `currentVpId` is given.
180
+ * The single ACL: `group/<g>/vp/<other>` is foreign when `currentVpId` is given.
181
+ * Across groups, every `group/<g>/vp/...` path is foreign by construction
182
+ * (the calling VP only runs inside its own group dir).
159
183
  *
160
184
  * @param {string} relPath
161
185
  * @param {string} currentVpId
@@ -163,7 +187,7 @@ export function isValidTopic(scope) {
163
187
  */
164
188
  export function isVpForeign(relPath, currentVpId) {
165
189
  if (!relPath || !currentVpId) return false;
166
- const m = /^vp\/([^/]+)(?:\/|$)/.exec(relPath);
190
+ const m = /^group\/[^/]+\/vp\/([^/]+)(?:\/|$)/.exec(relPath);
167
191
  if (!m) return false;
168
192
  return m[1] !== currentVpId;
169
193
  }
@@ -403,18 +427,17 @@ export async function ensureScope(scope, opts = {}) {
403
427
 
404
428
  /**
405
429
  * Enumerate all scopes present on disk. Returns Scope shapes that round-trip
406
- * back through `scopeDir`. Used by Triage to list candidate
407
- * scopes for a group's diff.
430
+ * back through `scopeDir`. Used by Triage to list candidate scopes.
408
431
  *
409
432
  * Walks shallowly:
410
- * user/ → { kind: 'user' }
411
- * vp/<id>/ → { kind: 'vp', id }
412
- * group/<id>/ → { kind: 'group', id }
413
- * feature/<id>/ → { kind: 'feature', id }
414
- * topic/<l1>/[<l2>/] → { kind: 'topic', path: [...] }
433
+ * user/ → { kind: 'user' }
434
+ * group/<g>/ → { kind: 'group', id: g }
435
+ * group/<g>/user/ → { kind: 'group-user', groupId: g }
436
+ * group/<g>/vp/<v>/ → { kind: 'group-vp', groupId: g, id: v }
437
+ * group/<g>/feature/<f>/ → { kind: 'group-feature', groupId: g, id: f }
438
+ * group/<g>/topic/<l1>[/<l2>]/ → { kind: 'group-topic', groupId: g, path: [...] }
415
439
  *
416
- * Skips entries that are not directories, and any name that fails segment
417
- * validation (e.g. accidental `.tmp.*` files at scope root, dotfiles).
440
+ * Skips `.legacy/` and any dotfile / unsafe segment.
418
441
  *
419
442
  * @param {{ root?: string }} [opts]
420
443
  * @returns {Promise<Scope[]>}
@@ -427,53 +450,77 @@ export async function listScopes(opts = {}) {
427
450
  // user/
428
451
  if (existsSync(join(root, 'user'))) out.push({ kind: 'user' });
429
452
 
430
- // vp/, group/, feature/ — single-level ids
431
- for (const kind of ['vp', 'group', 'feature']) {
432
- const dir = join(root, kind);
433
- let names;
434
- try { names = await fsp.readdir(dir, { withFileTypes: true }); }
435
- catch (err) {
436
- if (err && err.code === 'ENOENT') continue;
437
- throw err;
453
+ // group/<g>/...
454
+ const groupRoot = join(root, 'group');
455
+ let groups;
456
+ try { groups = await fsp.readdir(groupRoot, { withFileTypes: true }); }
457
+ catch (err) {
458
+ if (err && err.code === 'ENOENT') return out;
459
+ throw err;
460
+ }
461
+
462
+ for (const gent of groups) {
463
+ if (!gent.isDirectory()) continue;
464
+ if (gent.name.startsWith('.')) continue;
465
+ if (!isSafeId(gent.name)) continue;
466
+ const g = gent.name;
467
+ out.push({ kind: 'group', id: g });
468
+ const gAbs = join(groupRoot, g);
469
+
470
+ // group/<g>/user/
471
+ if (existsSync(join(gAbs, 'user'))) {
472
+ out.push({ kind: 'group-user', groupId: g });
438
473
  }
439
- for (const ent of names) {
440
- if (!ent.isDirectory()) continue;
441
- const id = ent.name;
442
- if (!isSafeId(id)) continue;
443
- out.push({ kind, id });
474
+
475
+ // group/<g>/vp/<v>/ and group/<g>/feature/<f>/
476
+ for (const kind of ['vp', 'feature']) {
477
+ const dir = join(gAbs, kind);
478
+ let names;
479
+ try { names = await fsp.readdir(dir, { withFileTypes: true }); }
480
+ catch (err) {
481
+ if (err && err.code === 'ENOENT') continue;
482
+ throw err;
483
+ }
484
+ for (const ent of names) {
485
+ if (!ent.isDirectory()) continue;
486
+ if (!isSafeId(ent.name)) continue;
487
+ out.push({
488
+ kind: kind === 'vp' ? 'group-vp' : 'group-feature',
489
+ groupId: g,
490
+ id: ent.name,
491
+ });
492
+ }
444
493
  }
445
- }
446
494
 
447
- // topic/<l1>/[<l2>/]
448
- const topicDir = join(root, 'topic');
449
- let l1s;
450
- try { l1s = await fsp.readdir(topicDir, { withFileTypes: true }); }
451
- catch (err) {
452
- if (err && err.code === 'ENOENT') l1s = [];
453
- else throw err;
454
- }
455
- for (const l1ent of l1s) {
456
- if (!l1ent.isDirectory()) continue;
457
- if (!isSafeId(l1ent.name)) continue;
458
- const l1 = l1ent.name;
459
- // Read l2 entries; if l1 itself contains memory.md, treat as 1-level topic
460
- const l1abs = join(topicDir, l1);
461
- let l2s;
462
- try { l2s = await fsp.readdir(l1abs, { withFileTypes: true }); }
463
- catch { l2s = []; }
464
- let hasL2 = false;
465
- for (const l2ent of l2s) {
466
- if (!l2ent.isDirectory()) continue;
467
- if (!isSafeId(l2ent.name)) continue;
468
- out.push({ kind: 'topic', path: [l1, l2ent.name] });
469
- hasL2 = true;
495
+ // group/<g>/topic/<l1>/[<l2>/]
496
+ const topicDir = join(gAbs, 'topic');
497
+ let l1s;
498
+ try { l1s = await fsp.readdir(topicDir, { withFileTypes: true }); }
499
+ catch (err) {
500
+ if (err && err.code === 'ENOENT') l1s = [];
501
+ else throw err;
470
502
  }
471
- // 1-level topic: present iff l1 has memory.md or summary.md directly
472
- if (!hasL2) {
473
- const hasMemory = existsSync(join(l1abs, 'memory.md'));
474
- const hasSummary = existsSync(join(l1abs, 'summary.md'));
475
- if (hasMemory || hasSummary) {
476
- out.push({ kind: 'topic', path: [l1] });
503
+ for (const l1ent of l1s) {
504
+ if (!l1ent.isDirectory()) continue;
505
+ if (!isSafeId(l1ent.name)) continue;
506
+ const l1 = l1ent.name;
507
+ const l1abs = join(topicDir, l1);
508
+ let l2s;
509
+ try { l2s = await fsp.readdir(l1abs, { withFileTypes: true }); }
510
+ catch { l2s = []; }
511
+ let hasL2 = false;
512
+ for (const l2ent of l2s) {
513
+ if (!l2ent.isDirectory()) continue;
514
+ if (!isSafeId(l2ent.name)) continue;
515
+ out.push({ kind: 'group-topic', groupId: g, path: [l1, l2ent.name] });
516
+ hasL2 = true;
517
+ }
518
+ if (!hasL2) {
519
+ const hasMemory = existsSync(join(l1abs, 'memory.md'));
520
+ const hasSummary = existsSync(join(l1abs, 'summary.md'));
521
+ if (hasMemory || hasSummary) {
522
+ out.push({ kind: 'group-topic', groupId: g, path: [l1] });
523
+ }
477
524
  }
478
525
  }
479
526
  }
package/yeaft/session.js CHANGED
@@ -45,7 +45,7 @@ import { ToolUsageStats } from './stats/tool-usage.js';
45
45
  import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
46
46
  import { seedDefaultVps } from './vp/seed-defaults.js';
47
47
  import { topUpDefaultVps } from './vp/seed-topup.js';
48
- import { runSummaryBackfill } from './memory/seed-backfill.js';
48
+ import { runSummaryBackfill, archiveLegacyScopes } from './memory/seed-backfill.js';
49
49
  import { createV2DreamScheduler, bootInitEmptyGroups, bootCatchUpStaleDream } from './dream-v2/session-wiring.js';
50
50
  import { openSegmentIndex } from './memory/index-db.js';
51
51
  import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
@@ -235,6 +235,16 @@ export async function loadSession(options = {}) {
235
235
  const indexPath = join(yeaftDir, 'memory', 'index.db');
236
236
  memoryIndex = openSegmentIndex(indexPath);
237
237
  const memoryRoot = join(yeaftDir, 'memory');
238
+ // One-shot migration to the group-isolated memory layout: move any
239
+ // remaining top-level vp/ feature/ topic/ dirs into .legacy/ before
240
+ // we open the FTS index and re-sync from disk.
241
+ try {
242
+ archiveLegacyScopes(memoryRoot);
243
+ } catch (archiveErr) {
244
+ if (config.debug) {
245
+ console.warn(`[Yeaft] legacy scope archive warning: ${archiveErr?.message || archiveErr}`);
246
+ }
247
+ }
238
248
  try {
239
249
  syncSegmentIndex(memoryRoot, memoryIndex);
240
250
  } catch (syncErr) {
@@ -23,7 +23,6 @@ import { join } from 'path';
23
23
  import { homedir } from 'os';
24
24
  import { validateVpId } from '../groups/ids.js';
25
25
  import { DEFAULT_VP_LIB_DIR, parseRoleMd } from './vp-store.js';
26
- import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store-v2.js';
27
26
  import { VP_STUB_MARKER } from '../memory/seed-backfill.js';
28
27
  import { STOCK_VP_IDS } from './stock-ids.js';
29
28
 
@@ -178,22 +177,9 @@ export function createVp(payload, options = {}) {
178
177
  mkdirSync(join(dir, 'memory'), { recursive: true });
179
178
  writeFileSync(vpRolePathFor(libDir, vpId), buildRoleMd({ ...payload, vpId }), 'utf-8');
180
179
 
181
- // Seed the VP's Layer-A resident summary so the first session has SOMETHING
182
- // for engine.#loadLayerASummaries to read. Without this, fresh VPs have
183
- // an empty memory section in the system prompt until Dream-v2 runs (which
184
- // requires a non-empty diff stream — i.e. several turns of activity).
185
- // We only seed when the file is missing/empty: this is safe to re-run and
186
- // never clobbers Dream-v2 writes. Failures are best-effort: a memory-root
187
- // permission failure must NOT break VP creation.
188
- try {
189
- seedSummaryIfMissingSync(
190
- { kind: 'vp', id: vpId },
191
- buildVpSeedSummary({ ...payload, vpId }),
192
- { root: memoryRoot },
193
- );
194
- } catch (err) {
195
- console.warn(`[vp-crud] failed to seed summary.md for ${vpId}:`, err?.message || err);
196
- }
180
+ // Note: VP memory is now per-group (group/<g>/vp/<vpId>/...) no global
181
+ // VP scope to seed at create time. Per-group summaries spring into being
182
+ // when dream first writes for that VP inside a group.
197
183
 
198
184
  return { vpId, dir };
199
185
  }
@@ -258,13 +244,10 @@ export function deleteVp(vpId, options = {}) {
258
244
  throw new VpCrudError('not_found', vpId);
259
245
  }
260
246
  rmSync(dir, { recursive: true, force: true });
261
- // Cascade: drop the VP's memory scope so a recreate with the same id
262
- // starts clean. Best-effort never let memory cleanup fail the CRUD op.
263
- try {
264
- removeScopeDirSync({ kind: 'vp', id: vpId }, { root: memoryRoot });
265
- } catch (err) {
266
- console.warn(`[vp-crud] failed to remove memory dir for ${vpId}:`, err?.message || err);
267
- }
247
+ // VP memory is per-group now (group/<g>/vp/<id>/...). We deliberately do
248
+ // NOT cascade-delete across every group dir on disk that would couple
249
+ // VP CRUD to the group registry. Stale per-group VP scopes get pruned by
250
+ // dream's natural rewrite cycle, or by group deletion.
268
251
  return { vpId };
269
252
  }
270
253