@yeaft/webchat-agent 0.1.940 → 0.1.942

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.940",
3
+ "version": "0.1.942",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -529,7 +529,7 @@ export class DebugTrace {
529
529
  const target = typeof data.target === 'string' ? data.target : '';
530
530
  if (sessionId) {
531
531
  const isBroadcast = !evtGroupId && !target;
532
- const isThisGroup = evtGroupId === sessionId || target === `group/${sessionId}` || target === `session/${sessionId}`;
532
+ const isThisGroup = evtGroupId === sessionId || target === `sessions/${sessionId}`;
533
533
  if (!isBroadcast && !isThisGroup) continue;
534
534
  }
535
535
  dreamEvents.push({
@@ -99,7 +99,7 @@ function renderSourceBlocks(sources, language) {
99
99
  const out = [];
100
100
  for (const src of (sources || [])) {
101
101
  out.push('');
102
- out.push(`[group/${src.sessionId}]`);
102
+ out.push(`[sessions/${src.sessionId}]`);
103
103
  for (const m of (src.diff || [])) {
104
104
  const head = `[${m.role || 'message'}${m.kind === 'overlap' ? (String(language || '').toLowerCase().startsWith('zh') ? '(已处理)' : ' (already processed)') : ''}]`;
105
105
  out.push(head);
@@ -110,7 +110,7 @@ function renderSourceBlocks(sources, language) {
110
110
  }
111
111
 
112
112
  /**
113
- * Translate `target` like 'group/g-eng' or 'topic/sci/phys' to a Scope
113
+ * Translate `target` like 'sessions/g-eng' to a Scope
114
114
  * understood by store. Throws if the path is malformed.
115
115
  *
116
116
  * @param {string} target
@@ -121,26 +121,22 @@ export function targetToScope(target) {
121
121
  if (target === 'user') return { kind: 'user' };
122
122
  const segs = target.split('/').filter(Boolean);
123
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]}/...`);
124
+ if (segs[0] === 'group' || segs[0] === 'vp' || segs[0] === 'feature' || segs[0] === 'topic') {
125
+ throw new Error(`apply.targetToScope: legacy root scope ${JSON.stringify(target)} rejected — use sessions/<sessionId>`);
126
126
  }
127
- if (segs[0] === 'group') {
128
- if (segs.length === 2) return { kind: 'group', id: segs[1] };
129
- // group/<g>/user
127
+ if (segs[0] === 'sessions') {
128
+ if (segs.length === 2) return { kind: 'session', id: segs[1] };
130
129
  if (segs.length === 3 && segs[2] === 'user') {
131
- return { kind: 'group-user', sessionId: segs[1] };
130
+ return { kind: 'session-user', sessionId: segs[1] };
132
131
  }
133
- // group/<g>/vp/<v>
134
132
  if (segs.length === 4 && segs[2] === 'vp') {
135
- return { kind: 'group-vp', sessionId: segs[1], id: segs[3] };
133
+ return { kind: 'session-vp', sessionId: segs[1], id: segs[3] };
136
134
  }
137
- // group/<g>/feature/<f>
138
135
  if (segs.length === 4 && segs[2] === 'feature') {
139
- return { kind: 'group-feature', sessionId: segs[1], id: segs[3] };
136
+ return { kind: 'session-feature', sessionId: segs[1], id: segs[3] };
140
137
  }
141
- // group/<g>/topic/<l1>[/<l2>]
142
138
  if (segs[2] === 'topic' && (segs.length === 4 || segs.length === 5)) {
143
- return { kind: 'group-topic', sessionId: segs[1], path: segs.slice(3) };
139
+ return { kind: 'session-topic', sessionId: segs[1], path: segs.slice(3) };
144
140
  }
145
141
  }
146
142
  if (segs[0] === 'chat') {
@@ -275,11 +271,11 @@ export async function applyMergedTarget(merged, opts) {
275
271
  function scopeRelDir(scope) {
276
272
  switch (scope.kind) {
277
273
  case 'user': return 'user';
278
- case 'group': return `group/${scope.id}`;
279
- case 'group-user': return `group/${scope.sessionId}/user`;
280
- case 'group-vp': return `group/${scope.sessionId}/vp/${scope.id}`;
281
- case 'group-feature': return `group/${scope.sessionId}/feature/${scope.id}`;
282
- case 'group-topic': return `group/${scope.sessionId}/topic/${scope.path.join('/')}`;
274
+ case 'session': return `sessions/${scope.id}`;
275
+ case 'session-user': return `sessions/${scope.sessionId}/user`;
276
+ case 'session-vp': return `sessions/${scope.sessionId}/vp/${scope.id}`;
277
+ case 'session-feature': return `sessions/${scope.sessionId}/feature/${scope.id}`;
278
+ case 'session-topic': return `sessions/${scope.sessionId}/topic/${scope.path.join('/')}`;
283
279
  case 'chat': return `chat/${scope.id}`;
284
280
  case 'chat-vp': return `chat/${scope.chatId}/vp/${scope.id}`;
285
281
  default: throw new Error(`apply.scopeRelDir: unknown kind ${scope.kind}`);
@@ -10,7 +10,7 @@
10
10
  import { join } from 'node:path';
11
11
 
12
12
  import { readMemory, readSummary } from '../memory/store.js';
13
- import { readGroupState } from './state.js';
13
+ import { readSessionState } from './state.js';
14
14
 
15
15
  export const DREAM_SNAPSHOT_TEXT_LIMIT = 6000;
16
16
 
@@ -30,15 +30,12 @@ export function truncateDreamText(value, limit = DREAM_SNAPSHOT_TEXT_LIMIT) {
30
30
  export async function buildDreamOutputSnapshot(sessionLike, sessionId) {
31
31
  if (!sessionId || !sessionLike?.yeaftDir) return null;
32
32
  const scope = `sessions/${sessionId}`;
33
- // Disk compatibility: Dream currently writes session summaries through
34
- // the historical kind:'group' store path. The snapshot's public scope label
35
- // is still sessions/<id>.
36
- const memoryScope = { kind: 'group', id: sessionId };
33
+ const memoryScope = { kind: 'session', id: sessionId };
37
34
  const root = join(sessionLike.yeaftDir, 'memory');
38
35
  const [memoryRaw, summaryRaw, state] = await Promise.all([
39
36
  readMemory(memoryScope, { root }).catch(() => ''),
40
37
  readSummary(memoryScope, { root }).catch(() => ''),
41
- readGroupState(root, sessionId).catch(() => ({
38
+ readSessionState(root, sessionId).catch(() => ({
42
39
  lastDreamMessageId: null,
43
40
  lastDreamAt: null,
44
41
  messageCount: 0,
@@ -2,12 +2,12 @@
2
2
 
3
3
  You are extracting **memory segments** from a conversation between the
4
4
  user and a Yeaft AI companion. This pass focuses on a specific
5
- **`session/<id>` scope**: long-lived facts about one collaboration session
5
+ **`sessions/<id>` scope**: long-lived facts about one collaboration session
6
6
  (a project team, a study cohort, a working set of people/agents).
7
7
 
8
8
  The target session id is provided as `{{sessionId}}`.
9
9
 
10
- ## What to extract for `session/<id>` scope
10
+ ## What to extract for `sessions/<id>` scope
11
11
 
12
12
  - **purpose** — what this session exists to do, its charter / mission
13
13
  - **members** — people, VPs, and roles in the session, and what each is
@@ -26,7 +26,7 @@ The target topic id is provided as `{{topicId}}`.
26
26
 
27
27
  - Facts about the user as a person — `user` scope.
28
28
  - Facts specific to one feature implementation — `feature/<id>`.
29
- - Group conventions — `group/<id>`.
29
+ - Group conventions — `sessions/<id>`.
30
30
  - Generic encyclopedia facts the assistant already knows — only the
31
31
  user's *durable views and confirmed knowledge* about the topic.
32
32
 
@@ -41,12 +41,10 @@ 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
- // 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';
49
- if (scope.startsWith('group/')) return 'extractSession';
44
+ if (/^sessions\/[^/]+\/vp\//.test(scope)) return 'extractVp';
45
+ if (/^sessions\/[^/]+\/topic\//.test(scope)) return 'extractTopic';
46
+ if (/^sessions\/[^/]+\/user(?:\/|$)/.test(scope)) return 'extractUser';
47
+ if (scope.startsWith('sessions/')) return 'extractSession';
50
48
  // Chat-isolated scopes: same template family as groups.
51
49
  if (/^chat\/[^/]+\/vp\//.test(scope)) return 'extractVp';
52
50
  if (scope.startsWith('chat/')) return 'extractSession';
@@ -2,7 +2,7 @@ You are deciding whether a recent group conversation carries:
2
2
  - signals that should update the USER profile, and/or
3
3
  - signals that should update one or more TOPIC scopes.
4
4
 
5
- Do NOT mention vp/, group/, or feature/ scopes — those are handled by hard rules.
5
+ Do NOT mention vp/, group/, feature/, or topic/ scopes — those are handled by hard rules.
6
6
 
7
7
  Session: {{sessionId}}
8
8
 
@@ -5,11 +5,11 @@
5
5
  *
6
6
  * trigger
7
7
  * ↓
8
- * enumerateGroups() via opts.listSessions()
8
+ * enumerateSessions() via opts.listSessions()
9
9
  * ↓
10
- * for each group with newCount ≥ MIN_NEW_PER_GROUP (auto)
10
+ * for each session with newCount ≥ MIN_NEW_PER_GROUP (auto)
11
11
  * or > 0 (manual)
12
- * or prior messages in a scoped manual group rerun:
12
+ * or prior messages in a scoped manual session rerun:
13
13
  * loadDiff() via opts.loadGroupDiff(sessionId, sinceId)
14
14
  * applyOverlap() via opts.loadOverlapPreamble(...)
15
15
  * segment() segmentDiff(...)
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * bookkeep:
23
23
  * for each processed group:
24
- * group .dream-state ←
24
+ * session .dream-state ←
25
25
  * { lastDreamMessageId: tail of real diff,
26
26
  * lastDreamAt: nowIso,
27
27
  * messageCount: <after> }
@@ -45,7 +45,7 @@ import { listScopes, readSummary } from '../memory/store.js';
45
45
  import {
46
46
  DEFAULT_LIMITS,
47
47
  } from './limits.js';
48
- import { readGroupState, writeGroupState, writeDreamError } from './state.js';
48
+ import { readSessionState, writeSessionState, writeDreamError } from './state.js';
49
49
  import { segmentDiff, truncateMessage, estimateMessagesTokens } from './segment.js';
50
50
  import { triageGroupSegments } from './triage.js';
51
51
  import { mergeByTarget } from './merge.js';
@@ -56,9 +56,9 @@ import { tsForBackup, pruneOldSnapshots } from './snapshot.js';
56
56
  * @typedef {Object} RunDreamOpts
57
57
  * @property {string} root — memory root, e.g. ~/.yeaft/memory
58
58
  * @property {boolean} [manual=false] — manual trigger overrides newCount<20 skip
59
- * @property {string[]} [scopeFilter] — optional: only dream these targets; scoped manual group triggers rerun the current group when there are prior messages but no new cursor delta ('*' allowed)
59
+ * @property {string[]} [scopeFilter] — optional: only dream these targets; scoped manual session triggers rerun the current session when there are prior messages but no new cursor delta ('*' allowed)
60
60
  * @property {(req: {pass:string, prompt:string, system:string}) => Promise<string>} llm
61
- * @property {() => Promise<Array<string>>} listSessions — return all group ids (incl. '_no-group')
61
+ * @property {() => Promise<Array<string>>} listSessions — return all session ids (incl. '_no-group')
62
62
  * @property {(sessionId: string) => Promise<number>} countMessages — total message count for a group
63
63
  * @property {(sessionId: string, sinceMessageId: string|null) => Promise<Array<object>>} loadGroupDiff
64
64
  * @property {(sessionId: string, beforeMessageId: string|null, count: number) => Promise<Array<object>>} loadOverlapPreamble
@@ -89,14 +89,14 @@ export async function runDream(opts) {
89
89
  // 1. enumerate groups
90
90
  const sessionIds = await safeCall(opts.listSessions, []);
91
91
  const filter = Array.isArray(opts.scopeFilter) ? new Set(opts.scopeFilter) : null;
92
- const groupFilter = deriveGroupFilter(filter);
93
- const groupsReport = [];
94
- const groupTriages = [];
95
- const processedGroups = [];
92
+ const sessionFilter = deriveSessionFilter(filter);
93
+ const sessionsReport = [];
94
+ const sessionTriages = [];
95
+ const processedSessions = [];
96
96
 
97
- // 2. per-group: skip / segment / triage
98
- // Topic summaries are now per-group (group/<g>/topic/...), so resolve
99
- // them inside the per-group loop instead of once up front.
97
+ // 2. per-session: skip / segment / triage
98
+ // Topic summaries are resolved inside the per-session loop
99
+ // them inside the per-session loop instead of once up front.
100
100
  const resolveTopicSummaries = async (sessionId) => {
101
101
  if (opts.listTopicSummaries) {
102
102
  return await safeCall(() => opts.listTopicSummaries(sessionId), []);
@@ -105,31 +105,31 @@ export async function runDream(opts) {
105
105
  };
106
106
 
107
107
  for (const sessionId of sessionIds) {
108
- // Current-group manual dream passes are the one case where scopeFilter
108
+ // Current-session manual dream passes are the one case where scopeFilter
109
109
  // must constrain enumeration too: clicking the conversation header means
110
- // "dream this group now", not "triage every group and then only apply
111
- // group/<id>". Pure target filters such as ['user'] still triage every
112
- // group so their hard-rule actions can contribute to the requested scope.
113
- if (groupFilter && !groupFilter.has(sessionId)) {
114
- groupsReport.push({ sessionId, new: 0, status: 'skipped', reason: 'scope-filtered' });
110
+ // "dream this session now", not "triage every session and then only apply
111
+ // sessions/<id>". Pure target filters such as ['user'] still triage every
112
+ // session so their hard-rule actions can contribute to the requested scope.
113
+ if (sessionFilter && !sessionFilter.has(sessionId)) {
114
+ sessionsReport.push({ sessionId, new: 0, status: 'skipped', reason: 'scope-filtered' });
115
115
  continue;
116
116
  }
117
- const state = await readGroupState(opts.root, sessionId);
117
+ const state = await readSessionState(opts.root, sessionId);
118
118
  const beforeCount = await safeCall(() => opts.countMessages(sessionId), 0);
119
119
  const newCount = Math.max(0, beforeCount - (state.messageCount || 0));
120
120
 
121
121
  const rerunScopedManual = !!opts.manual
122
- && groupFilter
123
- && groupFilter.has(sessionId)
122
+ && sessionFilter
123
+ && sessionFilter.has(sessionId)
124
124
  && newCount === 0
125
125
  && beforeCount > 0;
126
126
 
127
127
  if (newCount === 0 && !rerunScopedManual) {
128
- groupsReport.push({ sessionId, new: 0, status: 'skipped', reason: 'no-new-messages' });
128
+ sessionsReport.push({ sessionId, new: 0, status: 'skipped', reason: 'no-new-messages' });
129
129
  continue;
130
130
  }
131
131
  if (!opts.manual && newCount < limits.MIN_NEW_PER_GROUP) {
132
- groupsReport.push({ sessionId, new: newCount, status: 'skipped', reason: 'below-threshold' });
132
+ sessionsReport.push({ sessionId, new: newCount, status: 'skipped', reason: 'below-threshold' });
133
133
  continue;
134
134
  }
135
135
 
@@ -137,7 +137,7 @@ export async function runDream(opts) {
137
137
  const diffCursor = rerunScopedManual ? null : state.lastDreamMessageId;
138
138
  const diffNew = await safeCall(() => opts.loadGroupDiff(sessionId, diffCursor), []);
139
139
  if (!diffNew || diffNew.length === 0) {
140
- groupsReport.push({ sessionId, new: newCount, status: 'skipped', reason: 'empty-diff' });
140
+ sessionsReport.push({ sessionId, new: newCount, status: 'skipped', reason: 'empty-diff' });
141
141
  continue;
142
142
  }
143
143
  const overlapMessages = state.lastDreamMessageId && !rerunScopedManual
@@ -167,12 +167,12 @@ export async function runDream(opts) {
167
167
  language: opts.language,
168
168
  });
169
169
  } catch (err) {
170
- groupsReport.push({ sessionId, new: newCount, status: 'error', error: err.message });
170
+ sessionsReport.push({ sessionId, new: newCount, status: 'error', error: err.message });
171
171
  onProgress({ phase: 'triage', sessionId, status: 'error', error: err.message });
172
172
  // Journal the failure on disk so operators can see WHY dream is
173
173
  // not advancing without having to enable `config.debug`. Best-
174
174
  // effort — `writeDreamError` swallows its own I/O errors.
175
- await writeDreamError(opts.root, `group/${sessionId}`, {
175
+ await writeDreamError(opts.root, `sessions/${sessionId}`, {
176
176
  phase: 'triage',
177
177
  message: err.message,
178
178
  stack: err.stack,
@@ -181,17 +181,17 @@ export async function runDream(opts) {
181
181
  }
182
182
 
183
183
  onProgress({ phase: 'triage', sessionId, status: 'done', actions: actions.length });
184
- groupTriages.push({ sessionId, diff: fullDiff, actions });
184
+ sessionTriages.push({ sessionId, diff: fullDiff, actions });
185
185
 
186
186
  const tailId = lastMessageId(diffNew);
187
- processedGroups.push({ sessionId, tailId, beforeCount, newCount, segments: segments.length, actions: actions.length });
188
- groupsReport.push({ sessionId, new: newCount, segments: segments.length, actions: actions.length, status: 'triaged', rerun: rerunScopedManual || undefined });
187
+ processedSessions.push({ sessionId, tailId, beforeCount, newCount, segments: segments.length, actions: actions.length });
188
+ sessionsReport.push({ sessionId, new: newCount, segments: segments.length, actions: actions.length, status: 'triaged', rerun: rerunScopedManual || undefined });
189
189
  }
190
190
 
191
191
  // 3. merge
192
- const mergedTargets = mergeByTarget(groupTriages);
192
+ const mergedTargets = mergeByTarget(sessionTriages);
193
193
  const targetsToApply = filter && filter.size > 0 && !filter.has('*')
194
- ? mergedTargets.filter(t => filter.has(t.target) || filter.has(`group/${sourceGroupId(t)}`))
194
+ ? mergedTargets.filter(t => filter.has(t.target) || filter.has(`sessions/${sourceSessionId(t)}`))
195
195
  : mergedTargets;
196
196
 
197
197
  onProgress({ phase: 'merge', targets: targetsToApply.length });
@@ -237,13 +237,13 @@ export async function runDream(opts) {
237
237
  // cursor. (If everything errored, we keep the cursor so next run
238
238
  // retries.)
239
239
  const successfulTargets = new Set(targetsReport.filter(r => r.status === 'done').map(r => r.target));
240
- for (const pg of processedGroups) {
241
- const contributed = (groupTriages.find(g => g.sessionId === pg.sessionId) || { actions: [] })
240
+ for (const pg of processedSessions) {
241
+ const contributed = (sessionTriages.find(g => g.sessionId === pg.sessionId) || { actions: [] })
242
242
  .actions.map(a => a.scope);
243
243
  const anySuccess = contributed.some(t => successfulTargets.has(t));
244
244
  if (!anySuccess) continue;
245
245
  if (pg.tailId) {
246
- await writeGroupState(opts.root, pg.sessionId, {
246
+ await writeSessionState(opts.root, pg.sessionId, {
247
247
  lastDreamMessageId: pg.tailId,
248
248
  lastDreamAt: nowIso,
249
249
  messageCount: pg.beforeCount,
@@ -257,7 +257,7 @@ export async function runDream(opts) {
257
257
  const duration = Date.now() - startedAt;
258
258
  onProgress({
259
259
  phase: 'done',
260
- groups: processedGroups.length,
260
+ sessions: processedSessions.length,
261
261
  targets: targetsReport.length,
262
262
  duration,
263
263
  backupsKept: pruned.kept.length,
@@ -267,7 +267,7 @@ export async function runDream(opts) {
267
267
  return {
268
268
  startedAt: nowIso,
269
269
  durationMs: duration,
270
- groups: groupsReport,
270
+ sessions: sessionsReport,
271
271
  targets: targetsReport,
272
272
  backups: pruned,
273
273
  ts,
@@ -284,18 +284,18 @@ function lastMessageId(messages) {
284
284
  return null;
285
285
  }
286
286
 
287
- function deriveGroupFilter(filter) {
287
+ function deriveSessionFilter(filter) {
288
288
  if (!filter || filter.size === 0 || filter.has('*')) return null;
289
- const groups = [];
289
+ const sessions = [];
290
290
  for (const scope of filter) {
291
291
  if (typeof scope !== 'string') continue;
292
- const m = /^group\/([^/]+)$/.exec(scope);
293
- if (m && m[1]) groups.push(m[1]);
292
+ const m = /^sessions\/([^/]+)$/.exec(scope);
293
+ if (m && m[1]) sessions.push(m[1]);
294
294
  }
295
- return groups.length > 0 ? new Set(groups) : null;
295
+ return sessions.length > 0 ? new Set(sessions) : null;
296
296
  }
297
297
 
298
- function sourceGroupId(mergedTarget) {
298
+ function sourceSessionId(mergedTarget) {
299
299
  const src = Array.isArray(mergedTarget?.sources) ? mergedTarget.sources[0] : null;
300
300
  return src && typeof src.sessionId === 'string' ? src.sessionId : '';
301
301
  }
@@ -42,7 +42,7 @@ import { join } from 'path';
42
42
  import { runDream } from './runner.js';
43
43
  import { createDreamScheduler } from './schedule.js';
44
44
  import { listSessions, openSession } from '../sessions/session-store.js';
45
- import { readGroupState } from './state.js';
45
+ import { readSessionState } from './state.js';
46
46
  import { DREAM_NUDGE_AFTER_MESSAGES, DREAM_INTERVAL_HOURS } from './limits.js';
47
47
 
48
48
  /**
@@ -262,7 +262,7 @@ function finalizeDreamMetrics(metrics, durationMs) {
262
262
 
263
263
  function summarizeDreamResult(result = {}) {
264
264
  return {
265
- groups: Array.isArray(result.groups) ? result.groups.length : 0,
265
+ sessions: Array.isArray(result.sessions) ? result.sessions.length : 0,
266
266
  targets: Array.isArray(result.targets) ? result.targets.length : 0,
267
267
  error: result.error || null,
268
268
  skipped: !!result.skipped,
@@ -461,7 +461,7 @@ export async function bootInitEmptyGroups(args) {
461
461
  const empty = [];
462
462
  for (const gid of ids) {
463
463
  let segCount;
464
- try { segCount = args.memoryIndex.listByScope(`group/${gid}`).length; }
464
+ try { segCount = args.memoryIndex.listByScope(`sessions/${gid}`).length; }
465
465
  catch { continue; }
466
466
  if (segCount > 0) continue;
467
467
  let hasMessages = false;
@@ -473,7 +473,7 @@ export async function bootInitEmptyGroups(args) {
473
473
  hasMessages = !first.done;
474
474
  } catch { continue; }
475
475
  if (!hasMessages) continue;
476
- empty.push(`group/${gid}`);
476
+ empty.push(`sessions/${gid}`);
477
477
  }
478
478
  if (empty.length === 0) return out;
479
479
  if (args.config?.debug) {
@@ -534,7 +534,7 @@ export async function bootCatchUpStaleDream(args) {
534
534
  let anyTraffic = false;
535
535
  for (const gid of sessionIds) {
536
536
  let st;
537
- try { st = await readGroupState(memoryRoot, gid); }
537
+ try { st = await readSessionState(memoryRoot, gid); }
538
538
  catch { continue; }
539
539
  if (st.lastDreamAt) {
540
540
  const t = Date.parse(st.lastDreamAt);
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * Three pieces of state, tracked separately:
5
5
  *
6
- * 1. Per-group control state (used to decide whether a group enters
6
+ * 1. Per-session control state (used to decide whether a session enters
7
7
  * triage and how far to advance the cursor):
8
8
  *
9
- * ~/.yeaft/memory/session/<id>/.dream-state
9
+ * ~/.yeaft/memory/sessions/<id>/.dream-state
10
10
  *
11
11
  * A 3-line text file:
12
12
  *
@@ -17,8 +17,8 @@
17
17
  * Fields are independent of each other; missing fields default to
18
18
  * empty / null / 0. The file is rewritten atomically every dream.
19
19
  *
20
- * The virtual `_no-group/` group lives at the same path layout
21
- * (`session/_no-group/.dream-state`) and uses the same accessor.
20
+ * The virtual `_no-session/` session lives at the same path layout
21
+ * (`sessions/_no-session/.dream-state`) and uses the same accessor.
22
22
  *
23
23
  * 2. Per-scope observability marker, embedded inside the scope's
24
24
  * `memory.md` between two HTML comments at the file's tail:
@@ -53,42 +53,37 @@ const ERROR_FILE = '.dream-last-error.json';
53
53
  const DREAM_BLOCK_OPEN = '<!-- dream-state -->';
54
54
  const DREAM_BLOCK_CLOSE = '<!-- /dream-state -->';
55
55
 
56
- // ─── per-group ────────────────────────────────────────────────
56
+ // ─── per-session ────────────────────────────────────────────────
57
57
 
58
58
  /**
59
- * Read a group's .dream-state. Missing file → defaults.
59
+ * Read a session's .dream-state. Missing file → defaults.
60
60
  *
61
61
  * @param {string} root — memory root, e.g. ~/.yeaft/memory
62
62
  * @param {string} sessionId
63
63
  * @returns {Promise<{ lastDreamMessageId: string|null, lastDreamAt: string|null, messageCount: number }>}
64
64
  */
65
- export async function readGroupState(root, sessionId) {
66
- const abs = join(root, 'session', sessionId, STATE_FILE);
67
- const legacyAbs = join(root, 'group', sessionId, STATE_FILE);
65
+ export async function readSessionState(root, sessionId) {
66
+ const abs = join(root, 'sessions', sessionId, STATE_FILE);
68
67
  const empty = { lastDreamMessageId: null, lastDreamAt: null, messageCount: 0 };
69
68
  let raw;
70
69
  try { raw = await fsp.readFile(abs, 'utf8'); }
71
70
  catch (err) {
72
- if (!err || err.code !== 'ENOENT') throw err;
73
- try { raw = await fsp.readFile(legacyAbs, 'utf8'); }
74
- catch (legacyErr) {
75
- if (legacyErr && legacyErr.code === 'ENOENT') return empty;
76
- throw legacyErr;
77
- }
71
+ if (err && err.code === 'ENOENT') return empty;
72
+ throw err;
78
73
  }
79
- return parseGroupState(raw);
74
+ return parseSessionState(raw);
80
75
  }
81
76
 
82
77
  /**
83
- * Atomically rewrite a group's .dream-state. Creates the group dir if
78
+ * Atomically rewrite a session's .dream-state. Creates the session dir if
84
79
  * absent. Unknown fields are ignored.
85
80
  *
86
81
  * @param {string} root
87
82
  * @param {string} sessionId
88
83
  * @param {{ lastDreamMessageId?: string|null, lastDreamAt?: string|null, messageCount?: number }} state
89
84
  */
90
- export async function writeGroupState(root, sessionId, state) {
91
- const dir = join(root, 'session', sessionId);
85
+ export async function writeSessionState(root, sessionId, state) {
86
+ const dir = join(root, 'sessions', sessionId);
92
87
  await fsp.mkdir(dir, { recursive: true });
93
88
  const abs = join(dir, STATE_FILE);
94
89
  const body =
@@ -103,7 +98,7 @@ export async function writeGroupState(root, sessionId, state) {
103
98
  * empty values.
104
99
  * @param {string} raw
105
100
  */
106
- function parseGroupState(raw) {
101
+ function parseSessionState(raw) {
107
102
  const out = { lastDreamMessageId: null, lastDreamAt: null, messageCount: 0 };
108
103
  const lines = String(raw || '').split(/\r?\n/);
109
104
  for (const ln of lines) {
@@ -134,12 +129,12 @@ function parseGroupState(raw) {
134
129
  // `writeDreamError` writes `<memoryRoot>/<scope>/.dream-last-error.json`
135
130
  // unconditionally on every catch (best-effort — write failures must not
136
131
  // shadow the original error). Operators can then `ls ~/.yeaft/memory/
137
- // group/<id>/` and see what blew up, without having to re-enable debug.
132
+ // sessions/<id>/` and see what blew up, without having to re-enable debug.
138
133
 
139
134
  /**
140
135
  * Resolve a memoryRoot + scope-string to the scope directory.
141
136
  * The scope string is the same shape dream already uses internally:
142
- * `'user'`, `'vp/<vpId>'`, `'group/<sessionId>'`, `'feature/<id>'`, etc.
137
+ * `'user'`, `'sessions/<sessionId>'`, `'sessions/<sessionId>/vp/<vpId>'`, etc.
143
138
  *
144
139
  * Pure path-join; does NOT create the directory. The writer creates it.
145
140
  *
@@ -149,7 +144,7 @@ function parseGroupState(raw) {
149
144
  */
150
145
  export function scopeDirFor(root, scope) {
151
146
  // Defensive: trim leading/trailing slashes so callers can pass either
152
- // `'group/grp_fun'` or `/group/grp_fun/` — both land on the same dir.
147
+ // `'sessions/grp_fun'` or `/sessions/grp_fun/` — both land on the same dir.
153
148
  const clean = String(scope || '').replace(/^\/+|\/+$/g, '');
154
149
  return join(root, clean);
155
150
  }
@@ -160,7 +155,7 @@ export function scopeDirFor(root, scope) {
160
155
  * error-handling path and we must not mask the original failure.
161
156
  *
162
157
  * @param {string} root — memory root, e.g. ~/.yeaft/memory
163
- * @param {string} scope — `'group/<id>'` for triage failures,
158
+ * @param {string} scope — `'sessions/<id>'` for triage failures,
164
159
  * `merged.target` for apply failures.
165
160
  * @param {{ phase: string, message: string, stack?: string|null, at?: string }} info
166
161
  * @returns {Promise<void>}
@@ -283,4 +278,4 @@ async function atomicWrite(absPath, content) {
283
278
  }
284
279
 
285
280
  // re-exported for tests
286
- export const _internals = { parseGroupState, extractDreamBlock };
281
+ export const _internals = { parseSessionState, extractDreamBlock };
@@ -1,3 +1,4 @@
1
+ import { isValidTopic } from '../memory/store.js';
1
2
  /**
2
3
  * dream/triage.js.
3
4
  *
@@ -5,7 +6,7 @@
5
6
  * The decision is two-staged on purpose:
6
7
  *
7
8
  * 1. **Hard rules** (this module, no LLM): everything we can determine
8
- * from message metadata. Always include the active group, every VP
9
+ * from message metadata. Always include the active session, every VP
9
10
  * that spoke as an assistant in the diff, and `user` (so painted-over
10
11
  * user-profile signals can't be missed). (Feature scope was dropped
11
12
  * 2026-05-13 along with the rest of the Feature system.)
@@ -35,7 +36,6 @@
35
36
  * below.)
36
37
  */
37
38
 
38
- import { isValidTopic } from '../memory/store.js';
39
39
  import { truncateMessage } from './segment.js';
40
40
  import { render } from './prompts/index.js';
41
41
 
@@ -50,8 +50,8 @@ function triageSystem(language) {
50
50
  * structure of the diff.
51
51
  *
52
52
  * Inputs:
53
- * - sessionId: the active group ('_no-session' is allowed and skips the
54
- * `group/<id>` entry — by convention the virtual group has no scope
53
+ * - sessionId: the active session ('_no-session' is allowed and skips the
54
+ * `sessions/<id>` entry — by convention the virtual session has no scope
55
55
  * of its own).
56
56
  * - messages: the diff (already overlap-prefixed if applicable).
57
57
  *
@@ -65,7 +65,7 @@ export function applyHardRules({ sessionId, chatId, messages }) {
65
65
  // global user is always in.
66
66
  add('user');
67
67
 
68
- // chat path takes precedence: chat sessions have no group context.
68
+ // chat path takes precedence: chat sessions have no collaborative session context.
69
69
  if (chatId) {
70
70
  add(`chat/${chatId}`);
71
71
  for (const m of (messages || [])) {
@@ -80,19 +80,19 @@ export function applyHardRules({ sessionId, chatId, messages }) {
80
80
  return Array.from(out.values());
81
81
  }
82
82
 
83
- // active group + its per-group user layer, except the virtual _no-group bucket.
83
+ // active collaborative session, except the virtual _no-session bucket.
84
84
  if (sessionId && sessionId !== '_no-session') {
85
- add(`group/${sessionId}`);
86
- add(`group/${sessionId}/user`);
85
+ add(`sessions/${sessionId}`);
86
+ add(`sessions/${sessionId}/user`);
87
87
  }
88
88
 
89
89
  for (const m of (messages || [])) {
90
90
  if (!m || typeof m !== 'object') continue;
91
- // Active VP: any assistant message's vpId now group-internal.
91
+ // Active VP: any assistant message's vpId, isolated inside this session.
92
92
  if (m.role === 'assistant') {
93
93
  const vp = m.vpId || (m.author && /^vp:(.+)$/.exec(m.author)?.[1]);
94
94
  if (vp && /^[A-Za-z0-9_\-.一-鿿]+$/.test(vp) && sessionId && sessionId !== '_no-session') {
95
- add(`group/${sessionId}/vp/${vp}`);
95
+ add(`sessions/${sessionId}/vp/${vp}`);
96
96
  }
97
97
  }
98
98
  }
@@ -181,8 +181,8 @@ export async function classifySoft({ sessionId, messages, topicSummaries, llm, l
181
181
  if (!path) continue;
182
182
  const segs = path.split('/').filter(Boolean);
183
183
  if (!sessionId || sessionId === '_no-session') continue;
184
- if (!isValidTopic({ kind: 'group-topic', sessionId, path: segs })) continue;
185
- const scope = `group/${sessionId}/topic/${segs.join('/')}`;
184
+ if (!isValidTopic({ kind: 'session-topic', sessionId, path: segs })) continue;
185
+ const scope = `sessions/${sessionId}/topic/${segs.join('/')}`;
186
186
  if (pass2.decision === 'match') {
187
187
  out.push({ kind: 'update', scope });
188
188
  } else if (pass2.decision === 'new') {
package/yeaft/engine.js CHANGED
@@ -251,7 +251,7 @@ export function shouldAllowGroupReflection({
251
251
  * @param {{
252
252
  * sessionId?: string|null,
253
253
  * ownVpId?: string|null,
254
- * summaries: { user?: string, group?: string, vp?: string }
254
+ * summaries: { user?: string, session?: string, vp?: string }
255
255
  * }} args
256
256
  * @returns {Array<{scope: string, summary: string}>}
257
257
  */
@@ -259,15 +259,12 @@ export function buildResidentEntries(args) {
259
259
  const summaries = (args && args.summaries) || {};
260
260
  const out = [];
261
261
  if (summaries.user) out.push({ scope: 'user', summary: summaries.user });
262
- if (args.sessionId && summaries.group) {
263
- // Presentation label: product-facing prompt scopes are sessions, even
264
- // though the current disk compatibility path still reads kind:'group'.
265
- out.push({ scope: `sessions/${args.sessionId}`, summary: summaries.group });
262
+ if (args.sessionId && summaries.session) {
263
+ out.push({ scope: `sessions/${args.sessionId}`, summary: summaries.session });
266
264
  }
267
265
  // VP per-session isolation (2026-06-09): the VP summary scope MUST be
268
266
  // session-qualified. The legacy bare `vp/<id>` scope was a structural
269
- // bug `summaries.vp` is actually loaded from `group/<sessionId>/vp/<id>/summary.md`
270
- // (see #loadLayerASummaries, kind:'group-vp'), so labelling it `vp/<id>`
267
+ // (see #loadLayerASummaries, kind:'group-vp'), so labelling it `vp/<id>`
271
268
  // in the Resident layer (a) collides with the ACL regex in store
272
269
  // (which only recognises `<root>/<sid>/vp/...`) and (b) makes the same
273
270
  // VP persona leak across DIFFERENT sessions whenever the AMS rehydrates
@@ -394,7 +391,7 @@ export class Engine {
394
391
  * subsequent turns only run on budget pressure or new memory.
395
392
  * @type {Map<string, boolean>}
396
393
  */
397
- #adjustRanByGroup = new Map();
394
+ #adjustRanBySession = new Map();
398
395
 
399
396
  /** @type {string|null} */
400
397
  #abortReason = null;
@@ -572,30 +569,30 @@ export class Engine {
572
569
  *
573
570
  * Scopes:
574
571
  * - user → `user/summary.md` (always attempted)
575
- * - group <gid> → `groups/<gid>/summary.md` (if sessionId)
576
- * - vp <vpId> → `vp/<vpId>/summary.md` (if vpId)
572
+ * - session <sid> → `sessions/<sid>/summary.md` (if sessionId)
573
+ * - session-vp → `sessions/<sid>/vp/<vpId>/summary.md` (if vpId)
577
574
  *
578
575
  * Each fetch is best-effort — missing files / read errors return ''. The
579
576
  * dream tick (Phase 6) is what populates these; on a fresh install they
580
577
  * all return ''.
581
578
  *
582
579
  * @param {{sessionId?: string, vpId?: string, language?: string}} ctx
583
- * @returns {Promise<{user:string, group:string, vp:string}>}
580
+ * @returns {Promise<{user:string, session:string, vp:string}>}
584
581
  */
585
582
  async #loadLayerASummaries({ sessionId, vpId, language } = {}) {
586
- if (!this.#yeaftDir) return { user: '', group: '', vp: '' };
583
+ if (!this.#yeaftDir) return { user: '', session: '', vp: '' };
587
584
  const memoryRoot = `${this.#yeaftDir}/memory`;
588
585
  const tasks = [
589
586
  readScopeSummary({ kind: 'user' }, { root: memoryRoot, language }).catch(() => ''),
590
587
  sessionId
591
- ? readScopeSummary({ kind: 'group', id: sessionId }, { root: memoryRoot, language }).catch(() => '')
588
+ ? readScopeSummary({ kind: 'session', id: sessionId }, { root: memoryRoot, language }).catch(() => '')
592
589
  : Promise.resolve(''),
593
590
  vpId && sessionId
594
- ? readScopeSummary({ kind: 'group-vp', sessionId, id: vpId }, { root: memoryRoot, language }).catch(() => '')
591
+ ? readScopeSummary({ kind: 'session-vp', sessionId, id: vpId }, { root: memoryRoot, language }).catch(() => '')
595
592
  : Promise.resolve(''),
596
593
  ];
597
- const [user, group, vp] = await Promise.all(tasks);
598
- return { user: user || '', group: group || '', vp: vp || '' };
594
+ const [user, session, vp] = await Promise.all(tasks);
595
+ return { user: user || '', session: session || '', vp: vp || '' };
599
596
  }
600
597
 
601
598
  /**
@@ -605,12 +602,12 @@ export class Engine {
605
602
  * @param {{
606
603
  * sessionId?: string,
607
604
  * ownVpId?: string|null,
608
- * summaries: { user?: string, group?: string, vp?: string },
605
+ * summaries: { user?: string, session?: string, vp?: string },
609
606
  * recallEntries: object[],
610
607
  * }} args
611
608
  * @returns {{
612
609
  * ams: import('./memory/ams.js').ActiveMemorySet,
613
- * groupKey: string,
610
+ * sessionKey: string,
614
611
  * ownVpId: string|null,
615
612
  * scopes: string[],
616
613
  * snapshotBlock: string,
@@ -619,17 +616,17 @@ export class Engine {
619
616
  */
620
617
  #prepareAms(args) {
621
618
  if (!this.#amsRegistry) return null;
622
- const groupKey = args.sessionId || 'default';
619
+ const sessionKey = args.sessionId || 'default';
623
620
  const ownVpId = args.ownVpId || null;
624
- const ams = this.#amsRegistry.getOrCreate(groupKey, { ownVpId });
621
+ const ams = this.#amsRegistry.getOrCreate(sessionKey, { ownVpId });
625
622
 
626
- // Prime #adjustRanByGroup from disk-hydrated state on first access:
623
+ // Prime #adjustRanBySession from disk-hydrated state on first access:
627
624
  // a reactivated group resumes with whatever adjustRanThisSession bit
628
625
  // it had on disconnect, so we don't burn a fresh adjust on every
629
626
  // reload. Once set true in this session we never clear it.
630
- if (!this.#adjustRanByGroup.has(groupKey)
631
- && this.#amsRegistry.adjustRanThisSession(groupKey)) {
632
- this.#adjustRanByGroup.set(groupKey, true);
627
+ if (!this.#adjustRanBySession.has(sessionKey)
628
+ && this.#amsRegistry.adjustRanThisSession(sessionKey)) {
629
+ this.#adjustRanBySession.set(sessionKey, true);
633
630
  }
634
631
 
635
632
  // (a) Resident: rebuild from the same scope summaries the worker
@@ -653,7 +650,7 @@ export class Engine {
653
650
  vpId: ownVpId,
654
651
  });
655
652
 
656
- return { ams, groupKey, ownVpId, scopes, snapshotBlock, residentEntries };
653
+ return { ams, sessionKey, ownVpId, scopes, snapshotBlock, residentEntries };
657
654
  }
658
655
 
659
656
  /**
@@ -708,7 +705,7 @@ export class Engine {
708
705
  * surface as a turn failure.
709
706
  *
710
707
  * @param {{
711
- * amsContext: { ams: import('./memory/ams.js').ActiveMemorySet, groupKey: string, ownVpId: string|null, scopes: string[] }|null,
708
+ * amsContext: { ams: import('./memory/ams.js').ActiveMemorySet, sessionKey: string, ownVpId: string|null, scopes: string[] }|null,
712
709
  * userMsg: string,
713
710
  * assistantReply: string,
714
711
  * turnTokenUsage: number,
@@ -721,7 +718,7 @@ export class Engine {
721
718
  const totalBudget = ctx.ams.budget?.total || 0;
722
719
  if (!totalBudget) return null;
723
720
 
724
- const adjustRanThisSession = this.#adjustRanByGroup.get(ctx.groupKey) === true;
721
+ const adjustRanThisSession = this.#adjustRanBySession.get(ctx.sessionKey) === true;
725
722
  try {
726
723
  const result = await runAdjust({
727
724
  trigger: {
@@ -748,12 +745,12 @@ export class Engine {
748
745
  },
749
746
  });
750
747
  if (result?.ran) {
751
- this.#adjustRanByGroup.set(ctx.groupKey, true);
748
+ this.#adjustRanBySession.set(ctx.sessionKey, true);
752
749
  // Always persist when we ran — even with no membership change,
753
750
  // the adjustRanThisSession bit is part of the on-disk state we
754
751
  // want to preserve.
755
- this.#amsRegistry.markDirty(ctx.groupKey);
756
- this.#amsRegistry.persist(ctx.groupKey, {
752
+ this.#amsRegistry.markDirty(ctx.sessionKey);
753
+ this.#amsRegistry.persist(ctx.sessionKey, {
757
754
  adjustRanThisSession: true,
758
755
  });
759
756
  }
@@ -2262,7 +2259,7 @@ export class Engine {
2262
2259
  type: 'memory_adjust',
2263
2260
  turnId: queryTurnId,
2264
2261
  threadId,
2265
- groupKey: amsContext.groupKey,
2262
+ sessionKey: amsContext.sessionKey,
2266
2263
  added: adjustResult.added,
2267
2264
  evicted: adjustResult.evicted,
2268
2265
  skipped: adjustResult.skipped || 0,
@@ -65,10 +65,13 @@ export const SCOPE_KINDS = Object.freeze([
65
65
  'chat',
66
66
  'chat-vp',
67
67
  'session',
68
+ 'session-user',
68
69
  'session-vp',
70
+ 'session-feature',
71
+ 'session-topic',
69
72
  ]);
70
73
 
71
- /** @typedef {'user'|'group'|'group-user'|'group-vp'|'group-feature'|'group-topic'|'chat'|'chat-vp'|'session'|'session-vp'} ScopeKind */
74
+ /** @typedef {'user'|'group'|'group-user'|'group-vp'|'group-feature'|'group-topic'|'chat'|'chat-vp'|'session'|'session-user'|'session-vp'|'session-feature'|'session-topic'} ScopeKind */
72
75
 
73
76
  /**
74
77
  * @typedef {Object} Scope
@@ -141,14 +144,36 @@ export function scopeDir(scope) {
141
144
  case 'session': {
142
145
  if (!scope.id) throw new Error('scopeDir: session scope requires id');
143
146
  assertSafeSegment(scope.id, 'session.id');
144
- return `session/${scope.id}`;
147
+ return `sessions/${scope.id}`;
148
+ }
149
+ case 'session-user': {
150
+ if (!scope.sessionId) throw new Error('scopeDir: session-user scope requires sessionId');
151
+ assertSafeSegment(scope.sessionId, 'session-user.sessionId');
152
+ return `sessions/${scope.sessionId}/user`;
145
153
  }
146
154
  case 'session-vp': {
147
155
  if (!scope.sessionId) throw new Error('scopeDir: session-vp scope requires sessionId');
148
156
  if (!scope.id) throw new Error('scopeDir: session-vp scope requires id');
149
157
  assertSafeSegment(scope.sessionId, 'session-vp.sessionId');
150
158
  assertSafeSegment(scope.id, 'session-vp.id');
151
- return `session/${scope.sessionId}/vp/${scope.id}`;
159
+ return `sessions/${scope.sessionId}/vp/${scope.id}`;
160
+ }
161
+ case 'session-feature': {
162
+ if (!scope.sessionId) throw new Error('scopeDir: session-feature scope requires sessionId');
163
+ if (!scope.id) throw new Error('scopeDir: session-feature scope requires id');
164
+ assertSafeSegment(scope.sessionId, 'session-feature.sessionId');
165
+ assertSafeSegment(scope.id, 'session-feature.id');
166
+ return `sessions/${scope.sessionId}/feature/${scope.id}`;
167
+ }
168
+ case 'session-topic': {
169
+ if (!scope.sessionId) throw new Error('scopeDir: session-topic scope requires sessionId');
170
+ assertSafeSegment(scope.sessionId, 'session-topic.sessionId');
171
+ const segs = Array.isArray(scope.path) ? scope.path : [];
172
+ if (segs.length === 0 || segs.length > 2) {
173
+ throw new Error('scopeDir: session-topic.path must have 1 or 2 segments');
174
+ }
175
+ for (const seg of segs) assertSafeSegment(seg, 'session-topic.path');
176
+ return `sessions/${scope.sessionId}/topic/${segs.join('/')}`;
152
177
  }
153
178
  default:
154
179
  throw new Error(`scopeDir: unknown kind ${JSON.stringify(scope.kind)}`);
@@ -189,7 +214,7 @@ function assertSafeSegment(s, ctx) {
189
214
  * @returns {boolean}
190
215
  */
191
216
  export function isValidTopic(scope) {
192
- if (!scope || scope.kind !== 'group-topic') return false;
217
+ if (!scope || (scope.kind !== 'group-topic' && scope.kind !== 'session-topic')) return false;
193
218
  if (!scope.sessionId || typeof scope.sessionId !== 'string') return false;
194
219
  if (!Array.isArray(scope.path)) return false;
195
220
  if (scope.path.length < 1 || scope.path.length > 2) return false;
@@ -578,8 +603,8 @@ export async function listScopes(opts = {}) {
578
603
  }
579
604
  }
580
605
 
581
- // session/<s>/ and session/<s>/vp/<v>/
582
- const sessionRoot = join(root, 'session');
606
+ // sessions/<s>/ and sessions/<s>/vp/<v>/
607
+ const sessionRoot = join(root, 'sessions');
583
608
  let sessions;
584
609
  try { sessions = await fsp.readdir(sessionRoot, { withFileTypes: true }); }
585
610
  catch (err) {
@@ -592,6 +617,8 @@ export async function listScopes(opts = {}) {
592
617
  if (!isSafeId(sent.name)) continue;
593
618
  const s = sent.name;
594
619
  out.push({ kind: 'session', id: s });
620
+ if (existsSync(join(sessionRoot, s, 'user'))) out.push({ kind: 'session-user', sessionId: s });
621
+
595
622
  const vpDir = join(sessionRoot, s, 'vp');
596
623
  let vps;
597
624
  try { vps = await fsp.readdir(vpDir, { withFileTypes: true }); }
@@ -601,6 +628,34 @@ export async function listScopes(opts = {}) {
601
628
  if (!isSafeId(vent.name)) continue;
602
629
  out.push({ kind: 'session-vp', sessionId: s, id: vent.name });
603
630
  }
631
+
632
+ const featureDir = join(sessionRoot, s, 'feature');
633
+ let features;
634
+ try { features = await fsp.readdir(featureDir, { withFileTypes: true }); }
635
+ catch { features = []; }
636
+ for (const fent of features) {
637
+ if (!fent.isDirectory()) continue;
638
+ if (!isSafeId(fent.name)) continue;
639
+ out.push({ kind: 'session-feature', sessionId: s, id: fent.name });
640
+ }
641
+
642
+ const topicDir = join(sessionRoot, s, 'topic');
643
+ let topics;
644
+ try { topics = await fsp.readdir(topicDir, { withFileTypes: true }); }
645
+ catch { topics = []; }
646
+ for (const tent of topics) {
647
+ if (!tent.isDirectory()) continue;
648
+ if (!isSafeId(tent.name)) continue;
649
+ out.push({ kind: 'session-topic', sessionId: s, path: [tent.name] });
650
+ let subTopics;
651
+ try { subTopics = await fsp.readdir(join(topicDir, tent.name), { withFileTypes: true }); }
652
+ catch { subTopics = []; }
653
+ for (const sub of subTopics) {
654
+ if (!sub.isDirectory()) continue;
655
+ if (!isSafeId(sub.name)) continue;
656
+ out.push({ kind: 'session-topic', sessionId: s, path: [tent.name, sub.name] });
657
+ }
658
+ }
604
659
  }
605
660
 
606
661
  return out;
@@ -83,7 +83,7 @@ export function seedDefaultSession(yeaftDir, spec = {}) {
83
83
  // root permission failure must NOT break the bootstrap flow.
84
84
  try {
85
85
  seedSummaryIfMissingSync(
86
- { kind: 'group', id: DEFAULT_SESSION_ID },
86
+ { kind: 'session', id: DEFAULT_SESSION_ID },
87
87
  buildDefaultSessionSeedSummary({ name, roster, defaultVpId }),
88
88
  { root: memoryRoot },
89
89
  );
@@ -497,14 +497,10 @@ export function deleteSession(yeaftDir, sessionId, options = {}) {
497
497
  rmSync(dir, { recursive: true, force: true });
498
498
  }
499
499
 
500
- // Cascade: drop the group's memory scope so a recreate with the same id
500
+ // Cascade: drop the session memory scope so a recreate with the same id
501
501
  // starts clean. Best-effort — never let memory cleanup fail the CRUD op.
502
- // Runs unconditionally so the idempotent path also clears stale memory.
503
502
  try {
504
503
  removeScopeDirSync({ kind: 'session', id: sessionId }, { root: memoryRoot });
505
- // Legacy pre-session memory scopes used memory/group/<id>. Delete both so
506
- // idempotent removal clears stale summaries for old grp_* sessions too.
507
- removeScopeDirSync({ kind: 'group', id: sessionId }, { root: memoryRoot });
508
504
  } catch (err) {
509
505
  console.warn(`[session-crud] failed to remove memory dir for ${sessionId}:`, err?.message || err);
510
506
  }
@@ -205,11 +205,11 @@ function getVpStatusBroker() {
205
205
  * the target VP's driver instead of being dropped.
206
206
  * 2. Each VP thread gets its own Engine (via `vpEngines`) so private state
207
207
  * (`#currentAbortCtrl`, `#__queryCounter`, `#pendingT2`,
208
- * `#abortReason`, `#adjustRanByGroup`, `#execLog`, `#currentThreadId`)
208
+ * `#abortReason`, `#adjustRanBySession`, `#execLog`, `#currentThreadId`)
209
209
  * does not collide across concurrent VP turns. Engines are keyed by
210
210
  * `${sessionId}::${vpId}::${threadId}` rather than vpId alone because
211
211
  * Engine cannot serve two concurrent queries safely — even if AMS state
212
- * partitions correctly by groupKey, the non-group-keyed private state
212
+ * partitions correctly by sessionKey, the non-session-keyed private state
213
213
  * would collide if the same VP ran turns in two groups or two threads
214
214
  * in parallel.
215
215
  */
@@ -859,7 +859,7 @@ function isPermissionErrorMsg(msg) {
859
859
  /**
860
860
  * Get-or-create the per-VP Engine. Each VP owns its own Engine instance
861
861
  * so private state (`#currentAbortCtrl`, `#__queryCounter`, `#pendingT2`,
862
- * `#abortReason`, `#adjustRanByGroup`, `#execLog`) doesn't collide when
862
+ * `#abortReason`, `#adjustRanBySession`, `#execLog`) doesn't collide when
863
863
  * VP-A and VP-B run concurrent turns. All engines share the session's
864
864
  * adapter / trace / config / stores so memory recall, conversation
865
865
  * persistence, and tool registry remain consistent.
@@ -1599,16 +1599,7 @@ export function handleYeaftRestoreSession(msg) {
1599
1599
 
1600
1600
  export function handleYeaftRenameSession(msg) {
1601
1601
  const requestId = msg && msg.requestId;
1602
- // fix-yeaft-delete-and-agent-revert: accept legacy `groupId` in
1603
- // addition to `sessionId`. The contract documented in
1604
- // `web/stores/sessions.js` header ("Inbound payloads may carry
1605
- // either sessionId (new) or groupId (legacy); both are accepted,
1606
- // prefer sessionId") was only honored on web-side reads; the agent
1607
- // handlers were silently rejecting the older wire shape that today's
1608
- // SessionSettingsModal still sends. That's what made delete/rename/
1609
- // archive/update_config/add_member/remove_member/set_default_vp
1610
- // all throw `not_found` on undefined ids.
1611
- const sessionId = (msg && (msg.sessionId || msg.groupId)) || null;
1602
+ const sessionId = (msg && msg.sessionId) || null;
1612
1603
  const name = msg && msg.name;
1613
1604
  try {
1614
1605
  const yeaftDir = ctx.CONFIG?.yeaftDir;
@@ -1638,7 +1629,7 @@ export function handleYeaftRenameSession(msg) {
1638
1629
  export function handleYeaftUpdateSession(msg) {
1639
1630
  const requestId = msg && msg.requestId;
1640
1631
  // wire-compat: accept legacy `groupId` (see handleYeaftRenameSession).
1641
- const sessionId = (msg && (msg.sessionId || msg.groupId)) || null;
1632
+ const sessionId = (msg && msg.sessionId) || null;
1642
1633
  const patch = (msg && msg.patch && typeof msg.patch === 'object') ? msg.patch : null;
1643
1634
  try {
1644
1635
  const hasName = patch && typeof patch.name === 'string' && patch.name.trim().length > 0;
@@ -1673,7 +1664,7 @@ export function handleYeaftUpdateSession(msg) {
1673
1664
  export function handleYeaftUpdateSessionConfig(msg) {
1674
1665
  const requestId = msg && msg.requestId;
1675
1666
  // wire-compat: accept legacy `groupId` (see handleYeaftRenameSession).
1676
- const sessionId = (msg && (msg.sessionId || msg.groupId)) || null;
1667
+ const sessionId = (msg && msg.sessionId) || null;
1677
1668
  const partial = (msg && msg.config && typeof msg.config === 'object') ? msg.config : null;
1678
1669
  try {
1679
1670
  if (!sessionId) throw new SessionConfigError('missing_group_id', 'sessionId required');
@@ -1696,7 +1687,7 @@ export function handleYeaftUpdateSessionConfig(msg) {
1696
1687
  export function handleYeaftArchiveSession(msg) {
1697
1688
  const requestId = msg && msg.requestId;
1698
1689
  // wire-compat: accept legacy `groupId` (see handleYeaftRenameSession).
1699
- const sessionId = (msg && (msg.sessionId || msg.groupId)) || null;
1690
+ const sessionId = (msg && msg.sessionId) || null;
1700
1691
  try {
1701
1692
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1702
1693
  const result = archiveSession(yeaftDir, sessionId);
@@ -1717,7 +1708,7 @@ export function handleYeaftArchiveSession(msg) {
1717
1708
  export function handleYeaftDeleteSession(msg) {
1718
1709
  const requestId = msg && msg.requestId;
1719
1710
  // wire-compat: accept legacy `groupId` (see handleYeaftRenameSession).
1720
- const sessionId = (msg && (msg.sessionId || msg.groupId)) || null;
1711
+ const sessionId = (msg && msg.sessionId) || null;
1721
1712
  try {
1722
1713
  const yeaftDir = ctx.CONFIG?.yeaftDir;
1723
1714
  const result = deleteSession(yeaftDir, sessionId);
@@ -1759,7 +1750,7 @@ export function handleYeaftDeleteSession(msg) {
1759
1750
  export function handleYeaftSessionAddMember(msg) {
1760
1751
  const requestId = msg && msg.requestId;
1761
1752
  // wire-compat: accept legacy `groupId` (see handleYeaftRenameSession).
1762
- const sessionId = (msg && (msg.sessionId || msg.groupId)) || null;
1753
+ const sessionId = (msg && msg.sessionId) || null;
1763
1754
  const vpId = msg && msg.vpId;
1764
1755
  try {
1765
1756
  const yeaftDir = ctx.CONFIG?.yeaftDir;
@@ -1775,7 +1766,7 @@ export function handleYeaftSessionAddMember(msg) {
1775
1766
  export function handleYeaftSessionRemoveMember(msg) {
1776
1767
  const requestId = msg && msg.requestId;
1777
1768
  // wire-compat: accept legacy `groupId` (see handleYeaftRenameSession).
1778
- const sessionId = (msg && (msg.sessionId || msg.groupId)) || null;
1769
+ const sessionId = (msg && msg.sessionId) || null;
1779
1770
  const vpId = msg && msg.vpId;
1780
1771
  try {
1781
1772
  const yeaftDir = ctx.CONFIG?.yeaftDir;
@@ -1797,7 +1788,7 @@ export function handleYeaftSessionRemoveMember(msg) {
1797
1788
  export function handleYeaftSessionSetDefaultVp(msg) {
1798
1789
  const requestId = msg && msg.requestId;
1799
1790
  // wire-compat: accept legacy `groupId` (see handleYeaftRenameSession).
1800
- const sessionId = (msg && (msg.sessionId || msg.groupId)) || null;
1791
+ const sessionId = (msg && msg.sessionId) || null;
1801
1792
  const vpId = msg && msg.vpId;
1802
1793
  try {
1803
1794
  const yeaftDir = ctx.CONFIG?.yeaftDir;
@@ -1885,11 +1876,11 @@ export function installYeaftRuntimeBridge(s) {
1885
1876
  s._dreamResultSink = async (result = {}) => {
1886
1877
  if (result?.trigger !== 'auto') return;
1887
1878
  const normalized = normalizeDreamResult(result);
1888
- const processed = Array.isArray(result.groups)
1889
- ? result.groups.filter(g => g && g.status === 'processed' && g.sessionId)
1879
+ const processed = Array.isArray(result.sessions)
1880
+ ? result.sessions.filter(row => row && row.status === 'triaged' && row.sessionId)
1890
1881
  : [];
1891
- for (const group of processed) {
1892
- const sessionId = group.sessionId;
1882
+ for (const sessionRow of processed) {
1883
+ const sessionId = sessionRow.sessionId;
1893
1884
  const snapshot = await buildDreamOutputSnapshot(session, sessionId).catch(() => null);
1894
1885
  sendToServer({
1895
1886
  type: 'yeaft_dream_result',
@@ -2230,7 +2221,7 @@ function handleEngineEvent(event, hctx) {
2230
2221
  sendYeaftEvent({
2231
2222
  type: 'memory_adjust',
2232
2223
  turnId: event.turnId,
2233
- groupKey: event.groupKey,
2224
+ sessionKey: event.sessionKey,
2234
2225
  added: event.added,
2235
2226
  evicted: event.evicted,
2236
2227
  skipped: event.skipped,
@@ -2902,7 +2893,7 @@ async function raceWithEscalation(inner, { deadlineMs, onEscalate }) {
2902
2893
  * itself; the persistent coord lives in `sessionContexts[sessionId]`. Uses
2903
2894
  * `getOrCreateVpEngine(sessionId, vpId)` so each VP runs against its own
2904
2895
  * Engine instance — private state (`#currentAbortCtrl`, `#__queryCounter`,
2905
- * `#pendingT2`, `#abortReason`, `#adjustRanByGroup`, `#execLog`) does not
2896
+ * `#pendingT2`, `#abortReason`, `#adjustRanBySession`, `#execLog`) does not
2906
2897
  * collide when VP-A and VP-B run concurrent turns.
2907
2898
  *
2908
2899
  * @param {{ prompt: string, sessionId: string, vpId: string, turnId: string, envelope: object, vpAbort: AbortController, baseSnapshot: Array }} args
@@ -3523,28 +3514,28 @@ export function __testAppendTurnToSessionHistory(...args) {
3523
3514
  * { type: 'yeaft_dream_trigger', sessionId } — per-GROUP trigger (new
3524
3515
  * in v0.1.754 — added so users can manually kick dream for a group
3525
3516
  * after seeing the Resident layer stuck on the bootstrap seed).
3526
- * Fires a scope-filtered pass via `triggerDreamForScopes(['group/X'])`
3517
+ * Fires a scope-filtered pass via `triggerDreamForScopes(['sessions/X'])`
3527
3518
  * so unrelated groups don't get processed; the result event is
3528
- * tagged with `sessionId` for the per-group UI row.
3519
+ * tagged with `sessionId` for the per-session UI row.
3529
3520
  *
3530
3521
  * Backwards-compat: when neither field is set, defaults to `vpId='default'`
3531
3522
  * which matches the pre-v0.1.754 behavior.
3532
3523
  */
3533
3524
  export function normalizeDreamResult(result) {
3534
- const groups = Array.isArray(result?.groups) ? result.groups : [];
3525
+ const sessions = Array.isArray(result?.sessions) ? result.sessions : [];
3535
3526
  const targets = Array.isArray(result?.targets) ? result.targets : [];
3536
- const groupsProcessed = groups.filter(g => g && g.status === 'triaged').length;
3537
- const skippedGroups = groups.filter(g => g && g.status === 'skipped');
3538
- const groupsSkipped = skippedGroups.length;
3527
+ const sessionsProcessed = sessions.filter(g => g && g.status === 'triaged').length;
3528
+ const skippedSessions = sessions.filter(g => g && g.status === 'skipped');
3529
+ const sessionsSkipped = skippedSessions.length;
3539
3530
  const targetsApplied = targets.filter(t => t && t.status === 'done').length;
3540
3531
  const targetErrors = targets
3541
3532
  .filter(t => t && t.status === 'error')
3542
3533
  .map(t => ({ target: t.target || null, error: t.error || 'unknown' }));
3543
3534
  const hardError = result?.error || null;
3544
3535
  const explicitSkipped = result?.skipped === true;
3545
- const skipped = !hardError && (explicitSkipped || (groupsProcessed === 0 && targetsApplied === 0));
3536
+ const skipped = !hardError && (explicitSkipped || (sessionsProcessed === 0 && targetsApplied === 0));
3546
3537
  const skippedReason = skipped
3547
- ? (result?.skippedReason || skippedGroups[0]?.reason || 'no-targets-applied')
3538
+ ? (result?.skippedReason || skippedSessions[0]?.reason || 'no-targets-applied')
3548
3539
  : null;
3549
3540
  const trigger = result?.trigger || null;
3550
3541
  const success = !hardError && targetErrors.length === 0 && !skipped && targetsApplied > 0;
@@ -3560,8 +3551,8 @@ export function normalizeDreamResult(result) {
3560
3551
  passBreakdown: result?.passBreakdown || result?.metrics?.passBreakdown || null,
3561
3552
  skipped,
3562
3553
  skippedReason,
3563
- groupsProcessed,
3564
- groupsSkipped,
3554
+ sessionsProcessed,
3555
+ sessionsSkipped,
3565
3556
  targetsApplied,
3566
3557
  targetErrors,
3567
3558
  entriesCreated: targetsApplied,
@@ -3648,7 +3639,7 @@ export async function handleYeaftDreamTrigger(msg = {}) {
3648
3639
  });
3649
3640
 
3650
3641
  const result = sessionId
3651
- ? await session.dreamScheduler.triggerDreamForScopes([`group/${sessionId}`])
3642
+ ? await session.dreamScheduler.triggerDreamForScopes([`sessions/${sessionId}`])
3652
3643
  : await session.dreamScheduler.triggerDreamNow();
3653
3644
 
3654
3645
  const normalized = normalizeDreamResult(result);
@@ -3657,7 +3648,7 @@ export async function handleYeaftDreamTrigger(msg = {}) {
3657
3648
  : null;
3658
3649
 
3659
3650
  // Spread `result` FIRST so normalized fields (success, skipped,
3660
- // skippedReason, groupsProcessed, groupsSkipped, targetsApplied,
3651
+ // skippedReason, sessionsProcessed, sessionsSkipped, targetsApplied,
3661
3652
  // targetErrors, entriesCreated, lastDreamAt) authoritatively shadow
3662
3653
  // anything the runner might grow
3663
3654
  // with the same name. Today there is no collision (runner.js returns