@yeaft/webchat-agent 1.0.90 → 1.0.94

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": "1.0.90",
3
+ "version": "1.0.94",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -10,7 +10,8 @@
10
10
  import { join } from 'node:path';
11
11
 
12
12
  import { readMemory, readSummary } from '../memory/store.js';
13
- import { readSessionState } from './state.js';
13
+ import { readDreamError, readSessionState } from './state.js';
14
+ import { buildRunDreamOpts } from './session-wiring.js';
14
15
 
15
16
  export const DREAM_SNAPSHOT_TEXT_LIMIT = 6000;
16
17
 
@@ -32,7 +33,7 @@ export async function buildDreamOutputSnapshot(sessionLike, sessionId) {
32
33
  const scope = `sessions/${sessionId}`;
33
34
  const memoryScope = { kind: 'session', id: sessionId };
34
35
  const root = join(sessionLike.yeaftDir, 'memory');
35
- const [memoryRaw, summaryRaw, state] = await Promise.all([
36
+ const [memoryRaw, summaryRaw, state, lastError] = await Promise.all([
36
37
  readMemory(memoryScope, { root }).catch(() => ''),
37
38
  readSummary(memoryScope, { root }).catch(() => ''),
38
39
  readSessionState(root, sessionId).catch(() => ({
@@ -40,7 +41,9 @@ export async function buildDreamOutputSnapshot(sessionLike, sessionId) {
40
41
  lastDreamAt: null,
41
42
  messageCount: 0,
42
43
  })),
44
+ readDreamError(root, scope).catch(() => null),
43
45
  ]);
46
+ const totalMessageCount = await countSessionMessages(sessionLike, sessionId);
44
47
  const memory = truncateDreamText(memoryRaw);
45
48
  const summary = truncateDreamText(summaryRaw);
46
49
  return {
@@ -50,10 +53,26 @@ export async function buildDreamOutputSnapshot(sessionLike, sessionId) {
50
53
  lastDreamAt: state?.lastDreamAt || null,
51
54
  lastDreamMessageId: state?.lastDreamMessageId || null,
52
55
  messageCount: Number.isFinite(state?.messageCount) ? state.messageCount : 0,
56
+ totalMessageCount,
53
57
  hasOutput: !!(memoryRaw || summaryRaw),
58
+ lastError,
54
59
  memoryText: memory.text,
55
60
  memoryTruncated: memory.truncated,
56
61
  summaryText: summary.text,
57
62
  summaryTruncated: summary.truncated,
58
63
  };
59
64
  }
65
+
66
+ async function countSessionMessages(sessionLike, sessionId) {
67
+ try {
68
+ const opts = buildRunDreamOpts({
69
+ yeaftDir: sessionLike.yeaftDir,
70
+ config: sessionLike.config || {},
71
+ adapter: { call: async () => ({ text: '', usage: {} }) },
72
+ });
73
+ const n = await opts.countMessages(sessionId);
74
+ return Number.isFinite(n) ? n : 0;
75
+ } catch {
76
+ return 0;
77
+ }
78
+ }
@@ -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 { readSessionState, writeSessionState, writeDreamError } from './state.js';
48
+ import { clearDreamError, 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';
@@ -166,7 +166,7 @@ export async function runDream(opts) {
166
166
  sessionId,
167
167
  segments,
168
168
  topicSummaries,
169
- llm: opts.llm,
169
+ llm: dreamLlmForSession(opts.llm, sessionId),
170
170
  onProgress,
171
171
  language: opts.language,
172
172
  });
@@ -208,13 +208,14 @@ export async function runDream(opts) {
208
208
  const r = await applyMergedTarget(merged, {
209
209
  root: opts.root,
210
210
  ts,
211
- llm: opts.llm,
211
+ llm: dreamLlmForSession(opts.llm, sourceSessionId(merged)),
212
212
  limits,
213
213
  nowIso: opts.nowIso || (() => nowIso),
214
214
  onProgress,
215
215
  siblingTopicsFor: opts.siblingTopicsFor,
216
216
  language: opts.language,
217
217
  });
218
+ await clearDreamError(opts.root, merged.target);
218
219
  targetsReport.push({ ...r, sources: merged.sources.length, status: 'done' });
219
220
  } catch (err) {
220
221
  targetsReport.push({
@@ -252,11 +253,12 @@ export async function runDream(opts) {
252
253
  sessionId: triage.sessionId,
253
254
  messages: triage.diff,
254
255
  targets,
255
- llm: opts.llm,
256
+ llm: dreamLlmForSession(opts.llm, triage.sessionId),
256
257
  language: opts.language,
257
258
  nowIso: opts.nowIso || (() => nowIso),
258
259
  segmentIndex: opts.segmentIndex || null,
259
260
  });
261
+ await clearDreamError(opts.root, `sessions/${triage.sessionId}`);
260
262
  segmentReports.push({ sessionId: triage.sessionId, status: 'done', ...r });
261
263
  onProgress({ phase: 'extract-segments', sessionId: triage.sessionId, status: 'done', ...r });
262
264
  } catch (err) {
@@ -335,6 +337,10 @@ function deriveSessionFilter(filter) {
335
337
  return sessions.length > 0 ? new Set(sessions) : null;
336
338
  }
337
339
 
340
+ function dreamLlmForSession(llm, sessionId) {
341
+ return (req = {}) => llm({ ...req, sessionId: req.sessionId || sessionId || null });
342
+ }
343
+
338
344
  function sourceSessionId(mergedTarget) {
339
345
  const src = Array.isArray(mergedTarget?.sources) ? mergedTarget.sources[0] : null;
340
346
  return src && typeof src.sessionId === 'string' ? src.sessionId : '';
@@ -43,6 +43,8 @@ import { join } from 'path';
43
43
  import { runDream } from './runner.js';
44
44
  import { createDreamScheduler } from './schedule.js';
45
45
  import { parseMessage, parseSeqFromId } from '../conversation/persist.js';
46
+ import { loadSessionConfig, resolveSessionConfig } from '../sessions/session-config.js';
47
+ import { listSessions as listSessionMetas } from '../sessions/session-store.js';
46
48
  import { readSessionState } from './state.js';
47
49
  import { DREAM_NUDGE_AFTER_MESSAGES, DREAM_INTERVAL_HOURS } from './limits.js';
48
50
 
@@ -83,7 +85,7 @@ export function buildRunDreamOpts(session, onProgress) {
83
85
  segmentIndex: session.memoryIndex || null,
84
86
  llm: makeLlm(session),
85
87
  listSessions: async () => {
86
- try { return listConversationSessions([sessionConversationsRoot, legacySessionConversationsRoot]); }
88
+ try { return listRegisteredSessions([sessionConversationsRoot, legacySessionConversationsRoot]); }
87
89
  catch { return []; }
88
90
  },
89
91
  countMessages: async (sessionId) => {
@@ -132,15 +134,25 @@ function translateSessionConversationMessage(m) {
132
134
  }
133
135
 
134
136
  /**
135
- * Enumerate session ids that have persisted conversation messages. `sessions/`
136
- * is the primary layout; `groups/` is read-only legacy fallback.
137
+ * Enumerate sessions from authoritative session metadata first, then fall back
138
+ * to legacy transcript-only directories. Message presence is not used as the
139
+ * source of truth: a stale/corrupt JSONL index must not make Dream forget that
140
+ * a Session exists. The runner's .dream-state cursor decides whether there is
141
+ * anything new to process.
137
142
  *
138
143
  * @param {string[]} roots session transcript roots in priority order
139
144
  * @returns {string[]}
140
145
  */
141
- function listConversationSessions(roots) {
146
+ function listRegisteredSessions(roots) {
142
147
  const out = new Set();
143
- for (const root of roots) {
148
+ const [sessionsRoot, ...legacyRoots] = roots;
149
+ try {
150
+ for (const meta of listSessionMetas(sessionsRoot)) {
151
+ if (meta?.id) out.add(meta.id);
152
+ }
153
+ } catch { /* fall through to legacy fallback */ }
154
+
155
+ for (const root of legacyRoots) {
144
156
  if (!existsSync(root)) continue;
145
157
  for (const name of readdirSync(root)) {
146
158
  if (name.startsWith('.')) continue;
@@ -149,8 +161,8 @@ function listConversationSessions(roots) {
149
161
  if (!hasReadableMessages(dir)) continue;
150
162
  out.add(name);
151
163
  } catch {
152
- // Ignore partial or old session directories. Dream only needs sessions
153
- // with readable conversation messages.
164
+ // Ignore partial old directories. Canonical sessions come from
165
+ // sessions/<id>/session.json above; this branch is read-only legacy.
154
166
  }
155
167
  }
156
168
  }
@@ -210,14 +222,13 @@ function hasJsonlMessages(dir) {
210
222
 
211
223
  function loadSessionDiskMessages(dir, sessionId) {
212
224
  const conversationDir = join(dir, 'conversation');
213
- return [
214
- // Prefer the canonical ConversationStore projection when it exists: it has
215
- // normalized role/content/tool metadata. The session append-only audit log
216
- // is still load-bearing for sessions created before/while conversation rows
217
- // were being migrated to JSONL.
218
- ...loadConversationDirMessages(conversationDir),
219
- ...loadSessionJsonlMessages(join(dir, 'messages'), sessionId),
220
- ];
225
+ // Canonical conversation history is the source Dream needs: it contains the
226
+ // full user/assistant timeline. The session audit log under messages/ is a
227
+ // fallback for older or partially migrated sessions, not a second source to
228
+ // union in otherwise the same user turn can appear twice with different ids.
229
+ const canonical = loadConversationDirMessages(conversationDir);
230
+ if (canonical.length > 0) return canonical;
231
+ return loadSessionJsonlMessages(join(dir, 'messages'), sessionId);
221
232
  }
222
233
 
223
234
  function loadConversationDirMessages(conversationDir) {
@@ -323,9 +334,13 @@ function safeDirComponent(s) {
323
334
  * @param {Object} session
324
335
  */
325
336
  function makeLlm(session) {
326
- return async ({ pass, prompt, system }) => {
337
+ return async ({ pass, prompt, system, sessionId }) => {
327
338
  const adapter = session.adapter;
328
- const model = session.config?.fastModelId || session.config?.model;
339
+ const effectiveConfig = resolveDreamSessionConfig(session, sessionId);
340
+ const model = effectiveConfig?.model || effectiveConfig?.primaryModel;
341
+ if (!model) {
342
+ throw new Error(`dream: no session model configured (pass=${pass}, sessionId=${sessionId || 'unknown'})`);
343
+ }
329
344
  if (!adapter || typeof adapter.call !== 'function') {
330
345
  throw new Error(`dream: no adapter.call available (pass=${pass})`);
331
346
  }
@@ -335,7 +350,7 @@ function makeLlm(session) {
335
350
  session._dreamLoopCounter = (session._dreamLoopCounter || 0) + 1;
336
351
  const loopNumber = session._dreamLoopCounter;
337
352
  const turnId = session._dreamTurnId || 'dream';
338
- const effectiveSystem = system || (String(session.config?.language || '').toLowerCase().startsWith('zh')
353
+ const effectiveSystem = system || (String(effectiveConfig?.language || '').toLowerCase().startsWith('zh')
339
354
  ? `你是梦境流水线 — pass: ${pass}。请用中文生成自然语言内容;JSON key 保持英文。`
340
355
  : `You are the dream pipeline — pass: ${pass}.`);
341
356
 
@@ -344,6 +359,7 @@ function makeLlm(session) {
344
359
  system: effectiveSystem,
345
360
  messages: [{ role: 'user', content: prompt }],
346
361
  maxTokens: 2048,
362
+ modelEffort: effectiveConfig?.modelEffort || undefined,
347
363
  });
348
364
 
349
365
  // Emit complete loop event (request + response) to the debug panel.
@@ -366,6 +382,7 @@ function makeLlm(session) {
366
382
  loopNumber,
367
383
  pass,
368
384
  model: model || 'unknown',
385
+ sessionId: sessionId || null,
369
386
  systemPrompt: effectiveSystem,
370
387
  messages: [{ role: 'user', content: prompt }],
371
388
  response: typeof r?.text === 'string' ? r.text : '',
@@ -387,6 +404,15 @@ function makeLlm(session) {
387
404
  }
388
405
 
389
406
 
407
+ function resolveDreamSessionConfig(session, sessionId) {
408
+ const base = session?.config || {};
409
+ if (!sessionId || !session?.yeaftDir) return { ...base };
410
+ const sessionConfig = loadSessionConfig(session.yeaftDir, sessionId);
411
+ if (!sessionConfig || Object.keys(sessionConfig).length === 0) return { ...base };
412
+ return resolveSessionConfig(base, sessionConfig);
413
+ }
414
+
415
+
390
416
  function stampDreamScope(session, evt) {
391
417
  if (!evt || typeof evt !== 'object') return evt;
392
418
  if (evt.sessionId || !session?._dreamActiveGroupId) return evt;
@@ -646,7 +672,7 @@ export async function bootInitEmptyGroups(args) {
646
672
  if (!args || !args.memoryIndex || !args.dreamScheduler) return out;
647
673
  const sessionsRoot = join(args.yeaftDir, 'sessions');
648
674
  let ids;
649
- try { ids = listSessions(sessionsRoot).map(g => g.id); }
675
+ try { ids = listSessionMetas(sessionsRoot).map(g => g.id); }
650
676
  catch { return out; }
651
677
  const empty = [];
652
678
  for (const gid of ids) {
@@ -656,11 +682,7 @@ export async function bootInitEmptyGroups(args) {
656
682
  if (segCount > 0) continue;
657
683
  let hasMessages = false;
658
684
  try {
659
- const h = openSession(sessionsRoot, gid);
660
- // Any message at all is enough — pull the first record off the
661
- // iterator and stop.
662
- const first = h.streamMessages().next();
663
- hasMessages = !first.done;
685
+ hasMessages = loadSessionConversationMessages([sessionsRoot], gid).length > 0;
664
686
  } catch { continue; }
665
687
  if (!hasMessages) continue;
666
688
  empty.push(`sessions/${gid}`);
@@ -716,7 +738,7 @@ export async function bootCatchUpStaleDream(args) {
716
738
  const now = args.now ?? Date.now();
717
739
 
718
740
  let sessionIds;
719
- try { sessionIds = listSessions(sessionsRoot).map(g => g.id); }
741
+ try { sessionIds = listSessionMetas(sessionsRoot).map(g => g.id); }
720
742
  catch { return out; }
721
743
 
722
744
  // Find the newest lastDreamAt across all groups.
@@ -732,9 +754,7 @@ export async function bootCatchUpStaleDream(args) {
732
754
  }
733
755
  if (!anyTraffic) {
734
756
  try {
735
- const h = openSession(sessionsRoot, gid);
736
- const first = h.streamMessages().next();
737
- if (!first.done) anyTraffic = true;
757
+ if (loadSessionConversationMessages([sessionsRoot], gid).length > 0) anyTraffic = true;
738
758
  } catch { /* keep going */ }
739
759
  }
740
760
  }
@@ -187,6 +187,19 @@ export async function writeDreamError(root, scope, info) {
187
187
  }
188
188
  }
189
189
 
190
+ /**
191
+ * Clear a scope's last Dream error after a successful pass. Best-effort: a
192
+ * missing file is fine and I/O failure must not turn success into failure.
193
+ *
194
+ * @param {string} root
195
+ * @param {string} scope
196
+ * @returns {Promise<void>}
197
+ */
198
+ export async function clearDreamError(root, scope) {
199
+ try { await fsp.unlink(join(scopeDirFor(root, scope), ERROR_FILE)); }
200
+ catch { /* best-effort */ }
201
+ }
202
+
190
203
  /**
191
204
  * Read the last dream error JSON for a scope, or null if absent. Used
192
205
  * by the debug panel and by tests. Tolerates a malformed file by
@@ -0,0 +1,187 @@
1
+ /**
2
+ * recovery.js — best-effort Session disk self-recovery.
3
+ *
4
+ * Instances can contain old partially migrated Session dirs where the
5
+ * conversation transcript still exists under conversation/messages/*.md, but
6
+ * session.json is missing. This module repairs Session metadata and the
7
+ * coordinator audit-log index only. It deliberately does not convert
8
+ * conversation Markdown into the audit log: the engine/UI history source is
9
+ * ConversationStore under conversation/, not sessions/<id>/messages/.
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ readdirSync,
17
+ rmSync,
18
+ statSync,
19
+ } from 'fs';
20
+ import { join } from 'path';
21
+ import { writeAtomic, openLog } from '../storage/index.js';
22
+ import { parseMessage, parseSeqFromId } from '../conversation/persist.js';
23
+ import { loadSessionMeta, SESSION_META_FILE } from './session-store.js';
24
+
25
+ const SESSION_ID_RE = /^session_[A-Za-z0-9._-]+$/;
26
+ const DEFAULT_VP_ID = 'omni';
27
+ const AUDIT_INDEX_FILE = 'index.json';
28
+
29
+ export function repairSessionStore(yeaftDir, options = {}) {
30
+ const root = join(yeaftDir, 'sessions');
31
+ if (!existsSync(root)) return { repaired: 0, sessions: [] };
32
+ const sessionIds = safeReadDir(root)
33
+ .filter(name => !name.startsWith('.') && SESSION_ID_RE.test(name))
34
+ .filter(name => isDirectory(join(root, name)));
35
+ const sessions = [];
36
+ for (const sessionId of sessionIds) {
37
+ const result = repairSessionDir(root, sessionId, options);
38
+ if (result.changed) sessions.push(result);
39
+ }
40
+ return { repaired: sessions.length, sessions };
41
+ }
42
+
43
+ export function repairSessionDir(sessionsRoot, sessionId, options = {}) {
44
+ const dir = join(sessionsRoot, sessionId);
45
+ const result = {
46
+ sessionId,
47
+ changed: false,
48
+ metaCreated: false,
49
+ auditIndexRebuilt: false,
50
+ };
51
+ if (!isDirectory(dir)) return result;
52
+
53
+ if (!loadSessionMeta(dir)) {
54
+ const meta = inferSessionMeta(dir, sessionId, options);
55
+ writeAtomic(join(dir, SESSION_META_FILE), JSON.stringify(meta, null, 2));
56
+ result.metaCreated = true;
57
+ result.changed = true;
58
+ }
59
+
60
+ if (repairAuditIndexIfNeeded(join(dir, 'messages'))) {
61
+ result.auditIndexRebuilt = true;
62
+ result.changed = true;
63
+ }
64
+
65
+ return result;
66
+ }
67
+
68
+ function inferSessionMeta(dir, sessionId, options = {}) {
69
+ const first = readFirstConversationMarkdownRow(dir, sessionId);
70
+ const createdAt = first?.time || safeStatTime(dir) || new Date().toISOString();
71
+ const name = inferSessionName(sessionId);
72
+ const roster = Array.isArray(options.defaultRoster)
73
+ ? options.defaultRoster.filter(v => typeof v === 'string' && v)
74
+ : [DEFAULT_VP_ID];
75
+ const defaultVpId = typeof options.defaultVpId === 'string'
76
+ ? options.defaultVpId
77
+ : (roster.includes(DEFAULT_VP_ID) ? DEFAULT_VP_ID : (roster[0] || null));
78
+ return {
79
+ id: sessionId,
80
+ name,
81
+ roster,
82
+ defaultVpId,
83
+ announcement: '',
84
+ workDir: typeof options.workDir === 'string' ? options.workDir : '',
85
+ createdAt,
86
+ };
87
+ }
88
+
89
+ function inferSessionName(sessionId) {
90
+ const raw = sessionId
91
+ .replace(/^session_/, '')
92
+ .replace(/_[A-Z0-9]{8}$/, '')
93
+ .replace(/[-_]+/g, ' ')
94
+ .trim();
95
+ return raw ? raw.replace(/\b\w/g, ch => ch.toUpperCase()) : sessionId;
96
+ }
97
+
98
+ function readFirstConversationMarkdownRow(sessionDir, sessionId) {
99
+ const dirs = [
100
+ join(sessionDir, 'conversation', 'cold'),
101
+ join(sessionDir, 'conversation', 'messages'),
102
+ ];
103
+ let first = null;
104
+ for (const dir of dirs) {
105
+ if (!existsSync(dir)) continue;
106
+ for (const file of safeReadDir(dir).filter(f => /^m\d+\.md$/.test(f)).sort()) {
107
+ const msg = parseMessage(safeRead(join(dir, file)));
108
+ if (!msg) continue;
109
+ if (msg.sessionId && msg.sessionId !== sessionId) continue;
110
+ if (!first || compareMarkdownMessages(msg, first) < 0) first = msg;
111
+ }
112
+ }
113
+ return first;
114
+ }
115
+
116
+ function repairAuditIndexIfNeeded(messagesDir) {
117
+ if (!existsSync(messagesDir)) return false;
118
+ const segmentFiles = safeReadDir(messagesDir).filter(f => /^\d+\.jsonl$/.test(f)).sort();
119
+ if (segmentFiles.length === 0) return false;
120
+
121
+ const indexPath = join(messagesDir, AUDIT_INDEX_FILE);
122
+ if (!auditIndexNeedsRebuild(messagesDir, indexPath, segmentFiles)) return false;
123
+
124
+ rmSync(indexPath, { force: true });
125
+ const log = openLog(messagesDir);
126
+ log.close();
127
+ return true;
128
+ }
129
+
130
+ function auditIndexNeedsRebuild(messagesDir, indexPath, segmentFiles) {
131
+ const parsed = readJson(indexPath);
132
+ if (!parsed || !Array.isArray(parsed.segments)) return true;
133
+ const indexFiles = parsed.segments.map(seg => seg && seg.file).filter(Boolean).sort();
134
+ if (indexFiles.length !== segmentFiles.length) return true;
135
+ for (let i = 0; i < segmentFiles.length; i++) {
136
+ if (indexFiles[i] !== segmentFiles[i]) return true;
137
+ }
138
+
139
+ const byFile = new Map(parsed.segments.map(seg => [seg.file, seg]));
140
+ for (const file of segmentFiles) {
141
+ const seg = byFile.get(file);
142
+ if (!seg) return true;
143
+ const bytes = safeSize(join(messagesDir, file));
144
+ // This catches the broken real-world case: index says an existing segment
145
+ // is empty even though the JSONL file has data. Full validation is left to
146
+ // openLog's rebuild path after we remove the stale index.
147
+ if (bytes > 0 && ((Number(seg.count) || 0) === 0 || (Number(seg.bytes) || 0) === 0)) return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ function compareMarkdownMessages(a, b) {
153
+ const as = parseSeqFromId(a?.id);
154
+ const bs = parseSeqFromId(b?.id);
155
+ if (Number.isFinite(as) && Number.isFinite(bs) && as !== bs) return as - bs;
156
+ return String(a?.time || '').localeCompare(String(b?.time || ''));
157
+ }
158
+
159
+ function readJson(path) {
160
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
161
+ catch { return null; }
162
+ }
163
+
164
+ function safeRead(path) {
165
+ try { return readFileSync(path, 'utf8'); }
166
+ catch { return ''; }
167
+ }
168
+
169
+ function safeReadDir(dir) {
170
+ try { return readdirSync(dir); }
171
+ catch { return []; }
172
+ }
173
+
174
+ function isDirectory(path) {
175
+ try { return statSync(path).isDirectory(); }
176
+ catch { return false; }
177
+ }
178
+
179
+ function safeSize(path) {
180
+ try { return statSync(path).size; }
181
+ catch { return 0; }
182
+ }
183
+
184
+ function safeStatTime(path) {
185
+ try { return statSync(path).mtime.toISOString(); }
186
+ catch { return null; }
187
+ }
@@ -57,6 +57,7 @@ import { nextSessionId, validateVpId, isReservedVpId } from './ids.js';
57
57
  import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
58
58
  import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store.js';
59
59
  import { ensureSessionConfigFile, saveSessionConfig, loadSessionConfig } from './session-config.js';
60
+ import { repairSessionStore } from './recovery.js';
60
61
  import {
61
62
  addOrUpdateManifestSession,
62
63
  ensureSessionsManifest,
@@ -185,6 +186,17 @@ function copySessionExtras(sourceYeaftDir, destYeaftDir, sessionId) {
185
186
  }
186
187
  }
187
188
 
189
+ function repairSessionStoreAndManifest(yeaftDir, options = {}) {
190
+ const repaired = repairSessionStore(yeaftDir, options);
191
+ ensureSessionManifestReady(yeaftDir);
192
+ for (const row of repaired.sessions || []) {
193
+ const dir = join(sessionsRoot(yeaftDir), row.sessionId);
194
+ const meta = loadSessionMeta(dir);
195
+ if (meta) addOrUpdateManifestSession(yeaftDir, meta, dir);
196
+ }
197
+ return repaired;
198
+ }
199
+
188
200
  /**
189
201
  * One-way compatibility migration for sessions previously stored under a
190
202
  * project `.yeaft/sessions` tree. New steady-state discovery uses
@@ -380,7 +392,9 @@ function scanSortedVpIds(libDir) {
380
392
  export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
381
393
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
382
394
  const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
383
- ensureSessionManifestReady(yeaftDir);
395
+ repairSessionStoreAndManifest(yeaftDir, {
396
+ defaultRoster: scanSortedVpIds(libDir),
397
+ });
384
398
  const existing = listManifestSessions(yeaftDir).map(row => row.meta);
385
399
  if (existing.length > 0) {
386
400
  return { seeded: false, sessionId: existing[0].id };
@@ -718,7 +732,7 @@ export function requireSession(yeaftDir, sessionId) {
718
732
 
719
733
  /** Convenience: snapshot all non-archived groups for WS broadcast. */
720
734
  export function snapshotSessions(yeaftDir) {
721
- ensureSessionManifestReady(yeaftDir);
735
+ repairSessionStoreAndManifest(yeaftDir);
722
736
  const byId = new Map();
723
737
  for (const row of listManifestSessions(yeaftDir)) {
724
738
  byId.set(row.meta.id, row.meta);