@yeaft/webchat-agent 0.1.856 → 0.1.857

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.856",
3
+ "version": "0.1.857",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -28,7 +28,6 @@
28
28
  */
29
29
 
30
30
  import { groupTurns, pickCoolingGroups, indicesFromGroups } from './turn-group.js';
31
- import { writeSummary } from '../memory/store-v2.js';
32
31
 
33
32
  /**
34
33
  * @typedef {{
@@ -57,7 +56,7 @@ import { writeSummary } from '../memory/store-v2.js';
57
56
  * nextMessages: object[],
58
57
  * }>}
59
58
  */
60
- export async function runCompact({ messages, keepHot = 10, taskId = null, root, hooks }) {
59
+ export async function runCompact({ messages, keepHot = 10, hooks }) {
61
60
  if (!Array.isArray(messages)) {
62
61
  throw new Error('runCompact: messages array required');
63
62
  }
@@ -104,17 +103,12 @@ export async function runCompact({ messages, keepHot = 10, taskId = null, root,
104
103
  archiveResults.push({ ...g, turnId: r?.turnId });
105
104
  }
106
105
 
107
- // Track 2 — refresh task summary if applicable.
108
- let taskSummaryRefreshed = false;
109
- if (taskId && root && typeof hooks.refreshTaskSummary === 'function') {
110
- const prior = typeof hooks.readPriorTaskSummary === 'function'
111
- ? await hooks.readPriorTaskSummary() : '';
112
- const next = await hooks.refreshTaskSummary(coolingMessages, prior);
113
- if (typeof next === 'string' && next.trim()) {
114
- await writeSummary({ kind: 'feature', id: taskId }, next, { root });
115
- taskSummaryRefreshed = true;
116
- }
117
- }
106
+ // Track 2 — task summary refresh: removed. The legacy `feature/<id>` root
107
+ // scope was dropped along with the Feature system (2026-05-13); under the
108
+ // group-isolated layout feature summaries would live at
109
+ // `group/<g>/feature/<id>/` and are written by dream, not by post-turn
110
+ // compact. Engine no longer passes `taskId`/`root` to this orchestrator.
111
+ const taskSummaryRefreshed = false;
118
112
 
119
113
  // Track 3 — memory extraction.
120
114
  let extractedCount = 0;
@@ -120,12 +120,28 @@ export function targetToScope(target) {
120
120
  if (!target || typeof target !== 'string') throw new Error('apply.targetToScope: target required');
121
121
  if (target === 'user') return { kind: 'user' };
122
122
  const segs = target.split('/').filter(Boolean);
123
- const head = segs[0];
124
- if (head === 'vp' && segs.length === 2) return { kind: 'vp', id: segs[1] };
125
- if (head === 'group' && segs.length === 2) return { kind: 'group', id: segs[1] };
126
- if (head === 'feature' && segs.length === 2) return { kind: 'feature', id: segs[1] };
127
- if (head === 'topic' && (segs.length === 2 || segs.length === 3)) {
128
- return { kind: 'topic', path: segs.slice(1) };
123
+ // Legacy scopes — explicitly rejected. Old data lives under .legacy/.
124
+ if (segs[0] === 'vp' || segs[0] === 'feature' || segs[0] === 'topic') {
125
+ throw new Error(`apply.targetToScope: legacy root scope ${JSON.stringify(target)} rejected use group/<g>/${segs[0]}/...`);
126
+ }
127
+ if (segs[0] === 'group') {
128
+ if (segs.length === 2) return { kind: 'group', id: segs[1] };
129
+ // group/<g>/user
130
+ if (segs.length === 3 && segs[2] === 'user') {
131
+ return { kind: 'group-user', groupId: segs[1] };
132
+ }
133
+ // group/<g>/vp/<v>
134
+ if (segs.length === 4 && segs[2] === 'vp') {
135
+ return { kind: 'group-vp', groupId: segs[1], id: segs[3] };
136
+ }
137
+ // group/<g>/feature/<f>
138
+ if (segs.length === 4 && segs[2] === 'feature') {
139
+ return { kind: 'group-feature', groupId: segs[1], id: segs[3] };
140
+ }
141
+ // group/<g>/topic/<l1>[/<l2>]
142
+ if (segs[2] === 'topic' && (segs.length === 4 || segs.length === 5)) {
143
+ return { kind: 'group-topic', groupId: segs[1], path: segs.slice(3) };
144
+ }
129
145
  }
130
146
  throw new Error(`apply.targetToScope: malformed target ${JSON.stringify(target)}`);
131
147
  }
@@ -252,11 +268,12 @@ export async function applyMergedTarget(merged, opts) {
252
268
 
253
269
  function scopeRelDir(scope) {
254
270
  switch (scope.kind) {
255
- case 'user': return 'user';
256
- case 'vp': return `vp/${scope.id}`;
257
- case 'group': return `group/${scope.id}`;
258
- case 'feature': return `feature/${scope.id}`;
259
- case 'topic': return `topic/${scope.path.join('/')}`;
271
+ case 'user': return 'user';
272
+ case 'group': return `group/${scope.id}`;
273
+ case 'group-user': return `group/${scope.groupId}/user`;
274
+ case 'group-vp': return `group/${scope.groupId}/vp/${scope.id}`;
275
+ case 'group-feature': return `group/${scope.groupId}/feature/${scope.id}`;
276
+ case 'group-topic': return `group/${scope.groupId}/topic/${scope.path.join('/')}`;
260
277
  default: throw new Error(`apply.scopeRelDir: unknown kind ${scope.kind}`);
261
278
  }
262
279
  }
@@ -41,8 +41,15 @@ const FILES = {
41
41
  export function extractTemplateForScope(scope) {
42
42
  if (!scope || typeof scope !== 'string') return 'extractTopic';
43
43
  if (scope === 'user') return 'extractUser';
44
- if (scope.startsWith('vp/')) return 'extractVp';
44
+ // Nested group-isolated scopes must be matched BEFORE the bare `group/<g>`
45
+ // branch so VPs/topics/features under a group don't get the group template.
46
+ if (/^group\/[^/]+\/vp\//.test(scope)) return 'extractVp';
47
+ if (/^group\/[^/]+\/topic\//.test(scope)) return 'extractTopic';
48
+ if (/^group\/[^/]+\/user(?:\/|$)/.test(scope)) return 'extractUser';
45
49
  if (scope.startsWith('group/')) return 'extractGroup';
50
+ // Legacy top-level vp/topic scopes (archived to .legacy/ on boot — kept
51
+ // here defensively in case something still constructs the old strings).
52
+ if (scope.startsWith('vp/')) return 'extractVp';
46
53
  if (scope.startsWith('topic/')) return 'extractTopic';
47
54
  return 'extractTopic';
48
55
  }
@@ -95,9 +95,14 @@ export async function runDream(opts) {
95
95
  const processedGroups = [];
96
96
 
97
97
  // 2. per-group: skip / segment / triage
98
- const topicSummaries = opts.listTopicSummaries
99
- ? await safeCall(opts.listTopicSummaries, [])
100
- : await defaultListTopicSummaries(opts.root, opts.language).catch(() => []);
98
+ // Topic summaries are now per-group (group/<g>/topic/...), so resolve
99
+ // them inside the per-group loop instead of once up front.
100
+ const resolveTopicSummaries = async (groupId) => {
101
+ if (opts.listTopicSummaries) {
102
+ return await safeCall(() => opts.listTopicSummaries(groupId), []);
103
+ }
104
+ return await defaultListTopicSummaries(opts.root, groupId, opts.language).catch(() => []);
105
+ };
101
106
 
102
107
  for (const groupId of groupIds) {
103
108
  // Current-group manual dream passes are the one case where scopeFilter
@@ -152,6 +157,7 @@ export async function runDream(opts) {
152
157
 
153
158
  let actions;
154
159
  try {
160
+ const topicSummaries = await resolveTopicSummaries(groupId);
155
161
  actions = await triageGroupSegments({
156
162
  groupId,
157
163
  segments,
@@ -304,11 +310,12 @@ async function safeCall(fn, fallback) {
304
310
  }
305
311
  }
306
312
 
307
- async function defaultListTopicSummaries(root, language) {
313
+ async function defaultListTopicSummaries(root, groupId, language) {
308
314
  const all = await listScopes({ root });
309
315
  const out = [];
310
316
  for (const sc of all) {
311
- if (sc.kind !== 'topic') continue;
317
+ if (sc.kind !== 'group-topic') continue;
318
+ if (sc.groupId !== groupId) continue;
312
319
  const summary = await readSummary(sc, { root, language });
313
320
  out.push({ path: sc.path.join('/'), summary });
314
321
  }
@@ -62,20 +62,24 @@ export function applyHardRules({ groupId, messages }) {
62
62
  const out = new Map();
63
63
  const add = (scope) => { if (!out.has(scope)) out.set(scope, { kind: 'update', scope }); };
64
64
 
65
- // user is always in.
65
+ // global user is always in.
66
66
  add('user');
67
67
 
68
- // active group, except the virtual _no-group bucket.
69
- if (groupId && groupId !== '_no-group') add(`group/${groupId}`);
68
+ // active group + its per-group user layer, except the virtual _no-group bucket.
69
+ if (groupId && groupId !== '_no-group') {
70
+ add(`group/${groupId}`);
71
+ add(`group/${groupId}/user`);
72
+ }
70
73
 
71
74
  for (const m of (messages || [])) {
72
75
  if (!m || typeof m !== 'object') continue;
73
- // Active VP: any assistant message's vpId.
76
+ // Active VP: any assistant message's vpId — now group-internal.
74
77
  if (m.role === 'assistant') {
75
78
  const vp = m.vpId || (m.author && /^vp:(.+)$/.exec(m.author)?.[1]);
76
- if (vp && /^[A-Za-z0-9_\-.一-鿿]+$/.test(vp)) add(`vp/${vp}`);
79
+ if (vp && /^[A-Za-z0-9_\-.一-鿿]+$/.test(vp) && groupId && groupId !== '_no-group') {
80
+ add(`group/${groupId}/vp/${vp}`);
81
+ }
77
82
  }
78
- // (Active feature scope was dropped 2026-05-13 with the Feature system.)
79
83
  }
80
84
 
81
85
  return Array.from(out.values());
@@ -161,8 +165,9 @@ export async function classifySoft({ groupId, messages, topicSummaries, llm, lan
161
165
  const path = String(pass2.path || '').trim();
162
166
  if (!path) continue;
163
167
  const segs = path.split('/').filter(Boolean);
164
- if (!isValidTopic({ kind: 'topic', path: segs })) continue;
165
- const scope = `topic/${segs.join('/')}`;
168
+ if (!groupId || groupId === '_no-group') continue;
169
+ if (!isValidTopic({ kind: 'group-topic', groupId, path: segs })) continue;
170
+ const scope = `group/${groupId}/topic/${segs.join('/')}`;
166
171
  if (pass2.decision === 'match') {
167
172
  out.push({ kind: 'update', scope });
168
173
  } else if (pass2.decision === 'new') {
package/yeaft/engine.js CHANGED
@@ -569,8 +569,8 @@ export class Engine {
569
569
  groupId
570
570
  ? readScopeSummary({ kind: 'group', id: groupId }, { root: memoryRoot, language }).catch(() => '')
571
571
  : Promise.resolve(''),
572
- vpId
573
- ? readScopeSummary({ kind: 'vp', id: vpId }, { root: memoryRoot, language }).catch(() => '')
572
+ vpId && groupId
573
+ ? readScopeSummary({ kind: 'group-vp', groupId, id: vpId }, { root: memoryRoot, language }).catch(() => '')
574
574
  : Promise.resolve(''),
575
575
  ];
576
576
  const [user, group, vp] = await Promise.all(tasks);
@@ -167,6 +167,15 @@ export function selectRespondingVps(input) {
167
167
  */
168
168
  function scopeHeading(scope) {
169
169
  if (scope === 'user') return '## Memory: User';
170
+ // Nested group scopes first.
171
+ let m = /^group\/([^/]+)\/vp\/(.+)$/.exec(scope);
172
+ if (m) return `## Memory: VP ${m[2]}`;
173
+ m = /^group\/([^/]+)\/user$/.exec(scope);
174
+ if (m) return `## Memory: Group ${m[1]} (user)`;
175
+ m = /^group\/([^/]+)\/feature\/(.+)$/.exec(scope);
176
+ if (m) return `## Memory: Feature ${m[2]}`;
177
+ m = /^group\/([^/]+)\/topic\/(.+)$/.exec(scope);
178
+ if (m) return `## Memory: Topic ${m[2]}`;
170
179
  if (scope.startsWith('group/')) return `## Memory: Group ${scope.slice(6)}`;
171
180
  if (scope.startsWith('vp/')) return `## Memory: VP ${scope.slice(3)}`;
172
181
  if (scope.startsWith('feature/')) return `## Memory: Feature ${scope.slice(8)}`;
@@ -238,8 +247,11 @@ export function formatPickedForInjection(picked) {
238
247
  */
239
248
  export function buildRelevantScopes({ groupId, vpId, extra } = {}) {
240
249
  const scopes = ['user'];
241
- if (groupId) scopes.push(`group/${groupId}`);
242
- if (vpId) scopes.push(`vp/${vpId}`);
250
+ if (groupId) {
251
+ scopes.push(`group/${groupId}`);
252
+ scopes.push(`group/${groupId}/user`);
253
+ if (vpId) scopes.push(`group/${groupId}/vp/${vpId}`);
254
+ }
243
255
  if (Array.isArray(extra)) {
244
256
  for (const s of extra) {
245
257
  if (s && !scopes.includes(s)) scopes.push(s);
@@ -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