@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 +1 -1
- package/yeaft/compact/orchestrator.js +7 -13
- package/yeaft/dream-v2/apply.js +28 -11
- package/yeaft/dream-v2/prompts/index.js +8 -1
- package/yeaft/dream-v2/runner.js +12 -5
- package/yeaft/dream-v2/triage.js +13 -8
- package/yeaft/engine.js +2 -2
- package/yeaft/groups/pre-flow.js +14 -2
- package/yeaft/memory/adjust.js +2 -4
- package/yeaft/memory/ams.js +2 -4
- package/yeaft/memory/preflow.js +2 -6
- package/yeaft/memory/seed-backfill.js +41 -1
- package/yeaft/memory/segment.js +1 -1
- package/yeaft/memory/store-v2.js +120 -73
- package/yeaft/session.js +11 -1
- package/yeaft/vp/vp-crud.js +7 -24
package/package.json
CHANGED
|
@@ -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,
|
|
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 —
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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;
|
package/yeaft/dream-v2/apply.js
CHANGED
|
@@ -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
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
return { kind: '
|
|
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':
|
|
256
|
-
case '
|
|
257
|
-
case 'group':
|
|
258
|
-
case '
|
|
259
|
-
case '
|
|
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
|
-
|
|
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
|
}
|
package/yeaft/dream-v2/runner.js
CHANGED
|
@@ -95,9 +95,14 @@ export async function runDream(opts) {
|
|
|
95
95
|
const processedGroups = [];
|
|
96
96
|
|
|
97
97
|
// 2. per-group: skip / segment / triage
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
}
|
package/yeaft/dream-v2/triage.js
CHANGED
|
@@ -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')
|
|
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))
|
|
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 (!
|
|
165
|
-
|
|
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);
|
package/yeaft/groups/pre-flow.js
CHANGED
|
@@ -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)
|
|
242
|
-
|
|
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);
|
package/yeaft/memory/adjust.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/yeaft/memory/ams.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/yeaft/memory/preflow.js
CHANGED
|
@@ -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
|
+
}
|
package/yeaft/memory/segment.js
CHANGED
|
@@ -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|
|
|
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 →
|
package/yeaft/memory/store-v2.js
CHANGED
|
@@ -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([
|
|
59
|
-
|
|
60
|
-
|
|
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]
|
|
66
|
-
* @property {string
|
|
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 '
|
|
93
|
-
if (!scope.
|
|
94
|
-
assertSafeSegment(scope.
|
|
95
|
-
return `
|
|
96
|
-
|
|
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/
|
|
411
|
-
*
|
|
412
|
-
* group/<
|
|
413
|
-
*
|
|
414
|
-
*
|
|
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
|
|
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
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
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) {
|
package/yeaft/vp/vp-crud.js
CHANGED
|
@@ -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
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
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
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|