@yeaft/webchat-agent 0.1.520 → 0.1.522

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.
@@ -0,0 +1,588 @@
1
+ /**
2
+ * v0-to-v1.js — Legacy Yeaft → R6 group-chat layout migration.
3
+ *
4
+ * Spec: .crew/context/task-334i-migration-spec.md
5
+ *
6
+ * await runMigration({ yeaftDir, dryRun, force, onStep })
7
+ *
8
+ * Behaviour (full spec in §M5):
9
+ * - hardlink legacy files into `.backup/v0-<ts>/` before writing;
10
+ * - seed virtual-persons/unify-legacy/, groups/legacy-main/, user/memory/;
11
+ * - migrate conversation messages, memory entries, task directories;
12
+ * - state marker `.migration-state.json` allows resume;
13
+ * - on any throw, rollback = delete new tree (legacy is never touched).
14
+ *
15
+ * The migration only READS from the legacy tree and WRITES to the new
16
+ * tree (groups/, virtual-persons/, user/). It never deletes or moves
17
+ * legacy files — failure recovery is always "delete the new tree and
18
+ * re-run".
19
+ */
20
+
21
+ import {
22
+ existsSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ writeFileSync,
26
+ readdirSync,
27
+ statSync,
28
+ rmSync,
29
+ linkSync,
30
+ copyFileSync,
31
+ } from 'fs';
32
+ import { join, relative, dirname, basename } from 'path';
33
+
34
+ import { openLog, openShardStore } from '../storage/index.js';
35
+ import { detect } from './detect.js';
36
+ import {
37
+ parseFrontmatter,
38
+ mapMessageMdToJsonl,
39
+ mapMemoryEntry,
40
+ mapTaskMeta,
41
+ splitCoordinatorTurns,
42
+ LEGACY_GROUP_ID,
43
+ LEGACY_VP_ID,
44
+ } from './map-fields.js';
45
+
46
+ const MIGRATION_VERSION = 'v0-to-v1';
47
+ const STATE_FILE = '.migration-state.json';
48
+
49
+ const LEGACY_ROLE_TEMPLATE = `---
50
+ id: unify-legacy
51
+ name: Unify (legacy)
52
+ emoji: 🏛️
53
+ color: "#8888AA"
54
+ description: 归档自 v0 单 Unify session
55
+ model_preference: null
56
+ capabilities:
57
+ tools_allow: ["*"]
58
+ tools_deny: []
59
+ skills_allow: ["*"]
60
+ tone: ""
61
+ ---
62
+
63
+ ## Persona
64
+ (空 persona — 仅作为归档目标,不参与新群聊协作)
65
+ `;
66
+
67
+ const MEMORY_SCHEMA = {
68
+ shards: [
69
+ 'skill',
70
+ 'preferences',
71
+ 'relations',
72
+ 'lessons',
73
+ 'project-legacy',
74
+ ],
75
+ defaultSoftCap: { entries: 1000, bytes: 1024 * 1024 },
76
+ };
77
+
78
+ const USER_MEMORY_SCHEMA = {
79
+ shards: ['preferences'],
80
+ defaultSoftCap: { entries: 500, bytes: 256 * 1024 },
81
+ };
82
+
83
+ const STEP_NAMES = [
84
+ 'backup',
85
+ 'seedVp',
86
+ 'seedGroup',
87
+ 'migrateMessages',
88
+ 'migrateMemory',
89
+ 'migrateTasks',
90
+ 'migrateUserMemory',
91
+ 'finalize',
92
+ ];
93
+
94
+ /**
95
+ * Main entry point.
96
+ *
97
+ * @param {object} opts
98
+ * @param {string} opts.yeaftDir — required.
99
+ * @param {boolean} [opts.dryRun=false] — preview only; writes nothing.
100
+ * @param {boolean} [opts.force=false] — ignore and clear state marker.
101
+ * @param {(step, info)=>void} [opts.onStep] — progress callback.
102
+ * @returns {Promise<{status, report, state, dryRun}>}
103
+ */
104
+ export async function runMigration({ yeaftDir, dryRun = false, force = false, onStep } = {}) {
105
+ if (!yeaftDir || typeof yeaftDir !== 'string') {
106
+ throw new Error('runMigration: yeaftDir (string) required');
107
+ }
108
+ if (!existsSync(yeaftDir)) {
109
+ throw new Error(`runMigration: yeaftDir does not exist: ${yeaftDir}`);
110
+ }
111
+
112
+ const report = detect(yeaftDir);
113
+ const log = typeof onStep === 'function' ? onStep : () => {};
114
+
115
+ // Empty home → nothing to do.
116
+ if (report.empty) {
117
+ log('noop', { reason: 'empty home, nothing to migrate' });
118
+ return { status: 'noop', report, state: null, dryRun };
119
+ }
120
+
121
+ // Dry-run: walk steps but write nothing.
122
+ if (dryRun) {
123
+ const preview = buildDryRunPreview(report);
124
+ log('dry-run', preview);
125
+ return { status: 'dry-run', report, state: null, dryRun: true, preview };
126
+ }
127
+
128
+ const statePath = join(yeaftDir, STATE_FILE);
129
+ if (force && existsSync(statePath)) rmSync(statePath);
130
+
131
+ let state = loadState(statePath);
132
+ if (state && state.completedAt) {
133
+ log('already-done', { completedAt: state.completedAt });
134
+ return { status: 'already-done', report, state, dryRun };
135
+ }
136
+ if (!state) {
137
+ state = freshState();
138
+ saveState(statePath, state);
139
+ }
140
+
141
+ try {
142
+ // ─── Step 1: hardlink backup ──────────────────
143
+ if (state.steps.backup.status !== 'done') {
144
+ const backupRel = runBackupStep(yeaftDir, report, state);
145
+ state.steps.backup.status = 'done';
146
+ state.steps.backup.path = backupRel;
147
+ state.steps.backup.completedAt = nowIso();
148
+ saveState(statePath, state);
149
+ log('backup', { path: backupRel });
150
+ }
151
+
152
+ // ─── Step 2: seed VP ──────────────────
153
+ if (state.steps.seedVp.status !== 'done') {
154
+ seedVp(yeaftDir);
155
+ state.steps.seedVp.status = 'done';
156
+ state.steps.seedVp.completedAt = nowIso();
157
+ saveState(statePath, state);
158
+ log('seedVp', { vpId: LEGACY_VP_ID });
159
+ }
160
+
161
+ // ─── Step 3: seed group ──────────────────
162
+ if (state.steps.seedGroup.status !== 'done') {
163
+ seedGroup(yeaftDir);
164
+ state.steps.seedGroup.status = 'done';
165
+ state.steps.seedGroup.completedAt = nowIso();
166
+ saveState(statePath, state);
167
+ log('seedGroup', { groupId: LEGACY_GROUP_ID });
168
+ }
169
+
170
+ // ─── Step 4: migrate messages ──────────────────
171
+ if (state.steps.migrateMessages.status !== 'done') {
172
+ state.steps.migrateMessages.status = 'in_progress';
173
+ const migrated = migrateMessagesStep(yeaftDir, report, state);
174
+ state.steps.migrateMessages.status = 'done';
175
+ state.steps.migrateMessages.count = migrated;
176
+ state.steps.migrateMessages.completedAt = nowIso();
177
+ saveState(statePath, state);
178
+ log('migrateMessages', { count: migrated });
179
+ }
180
+
181
+ // ─── Step 5: migrate memory ──────────────────
182
+ if (state.steps.migrateMemory.status !== 'done') {
183
+ state.steps.migrateMemory.status = 'in_progress';
184
+ const { migrated, errors } = migrateMemoryStep(yeaftDir, report, state);
185
+ state.steps.migrateMemory.status = 'done';
186
+ state.steps.migrateMemory.count = migrated;
187
+ state.steps.migrateMemory.errors = errors;
188
+ state.steps.migrateMemory.completedAt = nowIso();
189
+ saveState(statePath, state);
190
+ log('migrateMemory', { count: migrated, errors: errors.length });
191
+ }
192
+
193
+ // ─── Step 6: migrate tasks ──────────────────
194
+ if (state.steps.migrateTasks.status !== 'done') {
195
+ state.steps.migrateTasks.status = 'in_progress';
196
+ const migrated = migrateTasksStep(yeaftDir, report, state);
197
+ state.steps.migrateTasks.status = 'done';
198
+ state.steps.migrateTasks.count = migrated;
199
+ state.steps.migrateTasks.completedAt = nowIso();
200
+ saveState(statePath, state);
201
+ log('migrateTasks', { count: migrated });
202
+ }
203
+
204
+ // ─── Step 7: migrate user memory ──────────────────
205
+ if (state.steps.migrateUserMemory.status !== 'done') {
206
+ state.steps.migrateUserMemory.status = 'in_progress';
207
+ const migrated = migrateUserMemoryStep(yeaftDir, report, state);
208
+ state.steps.migrateUserMemory.status = 'done';
209
+ state.steps.migrateUserMemory.count = migrated;
210
+ state.steps.migrateUserMemory.completedAt = nowIso();
211
+ saveState(statePath, state);
212
+ log('migrateUserMemory', { count: migrated });
213
+ }
214
+
215
+ // ─── Step 8: finalize ──────────────────
216
+ state.steps.finalize.status = 'done';
217
+ state.steps.finalize.completedAt = nowIso();
218
+ state.completedAt = nowIso();
219
+ saveState(statePath, state);
220
+ log('finalize', { completedAt: state.completedAt });
221
+
222
+ return { status: 'done', report, state, dryRun };
223
+ } catch (err) {
224
+ rollback(yeaftDir);
225
+ resetState(statePath, err);
226
+ throw err;
227
+ }
228
+ }
229
+
230
+ // ═══════════════ steps ═══════════════
231
+
232
+ function runBackupStep(yeaftDir, report, state) {
233
+ const ts = state.startedAt.replace(/[-:T]/g, '').replace(/\..+Z$/, '').replace(/Z$/, '');
234
+ const backupDir = join(yeaftDir, '.backup', `v0-${ts}`);
235
+ mkdirSync(backupDir, { recursive: true });
236
+
237
+ const files = collectBackupFiles(report);
238
+ for (const absPath of files) {
239
+ const rel = relative(yeaftDir, absPath);
240
+ const dest = join(backupDir, rel);
241
+ mkdirSync(dirname(dest), { recursive: true });
242
+ try {
243
+ linkSync(absPath, dest);
244
+ } catch {
245
+ // Cross-filesystem or other linkSync failure → fallback to copy.
246
+ try { copyFileSync(absPath, dest); } catch { /* ignore unreadable */ }
247
+ }
248
+ }
249
+ return relative(yeaftDir, backupDir);
250
+ }
251
+
252
+ function collectBackupFiles(report) {
253
+ const out = [];
254
+ const add = (v) => {
255
+ if (Array.isArray(v)) out.push(...v.filter(Boolean));
256
+ else if (v) out.push(v);
257
+ };
258
+ add(report.paths.messages);
259
+ add(report.paths.cold);
260
+ add(report.paths.conversationIndex);
261
+ add(report.paths.conversationCompact);
262
+ add(report.paths.memoryEntries);
263
+ add(report.paths.memoryAggregate);
264
+ add(report.paths.userPreferences);
265
+ add(report.paths.scopes);
266
+ add(report.paths.threads);
267
+ add(report.paths.threadsIndex);
268
+ add(report.paths.tasksIndex);
269
+ add(report.paths.tasksPlan);
270
+ for (const t of report.paths.taskDirs) {
271
+ if (t.meta) out.push(t.meta);
272
+ if (t.coordinator) out.push(t.coordinator);
273
+ }
274
+ return out;
275
+ }
276
+
277
+ function seedVp(yeaftDir) {
278
+ const vpDir = join(yeaftDir, 'virtual-persons', LEGACY_VP_ID);
279
+ mkdirSync(vpDir, { recursive: true });
280
+ mkdirSync(join(vpDir, 'memory'), { recursive: true });
281
+ writeFileSync(join(vpDir, 'role.md'), LEGACY_ROLE_TEMPLATE, 'utf8');
282
+ writeFileSync(
283
+ join(vpDir, 'state.json'),
284
+ JSON.stringify({ runtime: {}, lastMigratedAt: nowIso() }, null, 2),
285
+ 'utf8',
286
+ );
287
+ }
288
+
289
+ function seedGroup(yeaftDir) {
290
+ const groupDir = join(yeaftDir, 'groups', LEGACY_GROUP_ID);
291
+ mkdirSync(groupDir, { recursive: true });
292
+ mkdirSync(join(groupDir, 'messages'), { recursive: true });
293
+ mkdirSync(join(groupDir, 'tasks'), { recursive: true });
294
+ writeFileSync(
295
+ join(groupDir, 'group.json'),
296
+ JSON.stringify({
297
+ id: LEGACY_GROUP_ID,
298
+ name: 'Legacy Main',
299
+ roster: [LEGACY_VP_ID],
300
+ defaultVpId: LEGACY_VP_ID,
301
+ createdAt: nowIso(),
302
+ }, null, 2),
303
+ 'utf8',
304
+ );
305
+ }
306
+
307
+ function migrateMessagesStep(yeaftDir, report, state) {
308
+ const messagesDir = join(yeaftDir, 'groups', LEGACY_GROUP_ID, 'messages');
309
+ const log = openLog(messagesDir, {});
310
+
311
+ // threadId → legacy taskId mapping is captured here from threads/<id>.md
312
+ // frontmatter `taskId` field (task-307a). Absent frontmatter → null.
313
+ const threadTaskMap = new Map();
314
+ for (const tPath of report.paths.threads) {
315
+ const raw = safeRead(tPath);
316
+ const { meta } = parseFrontmatter(raw);
317
+ if (meta && meta.id && meta.taskId) {
318
+ threadTaskMap.set(meta.id, String(meta.taskId));
319
+ }
320
+ }
321
+
322
+ // Gather message md inputs: messages/ + cold/. Sort by frontmatter time
323
+ // when available, otherwise by filename.
324
+ const all = [...report.paths.messages, ...report.paths.cold]
325
+ .map((p) => {
326
+ const raw = safeRead(p);
327
+ const { meta, body } = parseFrontmatter(raw);
328
+ const originalId = basename(p, '.md');
329
+ return { path: p, meta, body, originalId };
330
+ })
331
+ .sort((a, b) => {
332
+ const ta = a.meta?.time || '';
333
+ const tb = b.meta?.time || '';
334
+ if (ta && tb) return ta < tb ? -1 : ta > tb ? 1 : 0;
335
+ return a.originalId.localeCompare(b.originalId);
336
+ });
337
+
338
+ // Resume support: cursor = last successfully migrated original file.
339
+ const cursor = state.steps.migrateMessages.cursor || null;
340
+ const alreadySeen = cursor
341
+ ? new Set(collectAlreadyAppendedIds(messagesDir))
342
+ : new Set();
343
+
344
+ let count = 0;
345
+ for (const item of all) {
346
+ const fallbackTaskId = item.meta?.threadId
347
+ ? (threadTaskMap.get(item.meta.threadId) || null)
348
+ : null;
349
+ const row = mapMessageMdToJsonl({
350
+ meta: item.meta,
351
+ body: item.body,
352
+ originalId: item.originalId,
353
+ fallbackTaskId,
354
+ });
355
+ if (alreadySeen.has(row.id)) {
356
+ state.steps.migrateMessages.cursor = relative(yeaftDir, item.path);
357
+ continue;
358
+ }
359
+ log.append(row);
360
+ count += 1;
361
+ state.steps.migrateMessages.cursor = relative(yeaftDir, item.path);
362
+ }
363
+ log.close();
364
+ return count;
365
+ }
366
+
367
+ function collectAlreadyAppendedIds(messagesDir) {
368
+ if (!existsSync(messagesDir)) return [];
369
+ const ids = [];
370
+ for (const name of readdirSync(messagesDir)) {
371
+ if (!/^\d+\.jsonl$/.test(name)) continue;
372
+ const raw = safeRead(join(messagesDir, name));
373
+ for (const line of raw.split('\n')) {
374
+ if (!line) continue;
375
+ try {
376
+ const obj = JSON.parse(line);
377
+ if (obj.id) ids.push(obj.id);
378
+ } catch { /* skip malformed line */ }
379
+ }
380
+ }
381
+ return ids;
382
+ }
383
+
384
+ function migrateMemoryStep(yeaftDir, report, state) {
385
+ const memDir = join(yeaftDir, 'virtual-persons', LEGACY_VP_ID, 'memory');
386
+ const store = openShardStore(memDir, MEMORY_SCHEMA);
387
+
388
+ const errors = [];
389
+ let migrated = 0;
390
+ const now = nowIso();
391
+ for (const p of report.paths.memoryEntries) {
392
+ const raw = safeRead(p);
393
+ const { meta, body } = parseFrontmatter(raw);
394
+ if (!meta) {
395
+ errors.push({ file: relative(yeaftDir, p), reason: 'frontmatter parse failed' });
396
+ continue;
397
+ }
398
+ const id = `mem_legacy_${safeId(meta.name || basename(p, '.md'))}`;
399
+ try {
400
+ const mapped = mapMemoryEntry({ meta, body, id, now });
401
+ if (!mapped) {
402
+ errors.push({ file: relative(yeaftDir, p), reason: 'mapMemoryEntry returned null' });
403
+ continue;
404
+ }
405
+ store.put(mapped.entry);
406
+ migrated += 1;
407
+ } catch (err) {
408
+ errors.push({ file: relative(yeaftDir, p), reason: String(err && err.message || err) });
409
+ }
410
+ }
411
+ return { migrated, errors };
412
+ }
413
+
414
+ function migrateTasksStep(yeaftDir, report, state) {
415
+ let count = 0;
416
+ for (const td of report.paths.taskDirs) {
417
+ const destDir = join(yeaftDir, 'groups', LEGACY_GROUP_ID, 'tasks', td.id);
418
+ mkdirSync(destDir, { recursive: true });
419
+ mkdirSync(join(destDir, 'messages'), { recursive: true });
420
+ mkdirSync(join(destDir, 'summaries'), { recursive: true });
421
+
422
+ // task.json
423
+ const metaRaw = td.meta ? safeRead(td.meta) : '';
424
+ const { meta } = parseFrontmatter(metaRaw);
425
+ const taskJson = mapTaskMeta({ meta, taskId: td.id });
426
+ writeFileSync(join(destDir, 'task.json'), JSON.stringify(taskJson, null, 2), 'utf8');
427
+
428
+ // summaries/current.json placeholder
429
+ writeFileSync(
430
+ join(destDir, 'summaries', 'current.json'),
431
+ JSON.stringify({ head: null, chain: [] }, null, 2),
432
+ 'utf8',
433
+ );
434
+
435
+ // coordinator.md → messages jsonl
436
+ if (td.coordinator) {
437
+ const log = openLog(join(destDir, 'messages'), {});
438
+ const turns = splitCoordinatorTurns(safeRead(td.coordinator));
439
+ let i = 0;
440
+ for (const turn of turns) {
441
+ i += 1;
442
+ log.append({
443
+ id: `msg_legacy_task_${td.id}_${String(i).padStart(3, '0')}`,
444
+ ts: turn.ts,
445
+ type: 'chat',
446
+ authorKind: turn.role === 'user' ? 'user' : 'vp',
447
+ authorId: turn.role === 'user' ? 'user:self' : LEGACY_VP_ID,
448
+ groupId: LEGACY_GROUP_ID,
449
+ taskId: td.id,
450
+ body: turn.body,
451
+ mentions: [],
452
+ replyTo: null,
453
+ viaTool: null,
454
+ _legacyRole: turn.role,
455
+ });
456
+ }
457
+ log.close();
458
+ }
459
+ count += 1;
460
+ }
461
+ return count;
462
+ }
463
+
464
+ function migrateUserMemoryStep(yeaftDir, report, state) {
465
+ // Seed user/profile.json
466
+ const userDir = join(yeaftDir, 'user');
467
+ mkdirSync(userDir, { recursive: true });
468
+ const profilePath = join(userDir, 'profile.json');
469
+ if (!existsSync(profilePath)) {
470
+ writeFileSync(
471
+ profilePath,
472
+ JSON.stringify({ id: 'self', name: null, createdAt: nowIso() }, null, 2),
473
+ 'utf8',
474
+ );
475
+ }
476
+
477
+ // user/memory shard-store with MEMORY.md + user-preferences.md merged.
478
+ const memDir = join(userDir, 'memory');
479
+ mkdirSync(memDir, { recursive: true });
480
+ const store = openShardStore(memDir, USER_MEMORY_SCHEMA);
481
+
482
+ const parts = [];
483
+ if (report.paths.memoryAggregate) parts.push(safeRead(report.paths.memoryAggregate));
484
+ if (report.paths.userPreferences) parts.push(safeRead(report.paths.userPreferences));
485
+ if (parts.length === 0) return 0;
486
+
487
+ const body = parts.join('\n\n---\n\n');
488
+ store.put({
489
+ id: 'mem_legacy_user_prefs',
490
+ shard: 'preferences',
491
+ body: body.trim(),
492
+ meta: { kind: 'preference', tags: ['legacy'], pinned: true },
493
+ });
494
+ return 1;
495
+ }
496
+
497
+ // ═══════════════ rollback + state ═══════════════
498
+
499
+ function rollback(yeaftDir) {
500
+ for (const rel of ['groups', 'virtual-persons', join('user', 'memory')]) {
501
+ const p = join(yeaftDir, rel);
502
+ if (existsSync(p)) {
503
+ try { rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ }
504
+ }
505
+ }
506
+ }
507
+
508
+ function freshState() {
509
+ const now = nowIso();
510
+ const steps = {};
511
+ for (const name of STEP_NAMES) steps[name] = { status: 'pending' };
512
+ return {
513
+ version: MIGRATION_VERSION,
514
+ startedAt: now,
515
+ steps,
516
+ completedAt: null,
517
+ };
518
+ }
519
+
520
+ function loadState(statePath) {
521
+ if (!existsSync(statePath)) return null;
522
+ try {
523
+ const raw = readFileSync(statePath, 'utf8');
524
+ return JSON.parse(raw);
525
+ } catch {
526
+ return null;
527
+ }
528
+ }
529
+
530
+ function saveState(statePath, state) {
531
+ mkdirSync(dirname(statePath), { recursive: true });
532
+ writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
533
+ }
534
+
535
+ function resetState(statePath, err) {
536
+ try {
537
+ writeFileSync(
538
+ statePath,
539
+ JSON.stringify({
540
+ version: MIGRATION_VERSION,
541
+ cleanedAt: nowIso(),
542
+ reason: String(err && err.message || err),
543
+ }, null, 2),
544
+ 'utf8',
545
+ );
546
+ } catch { /* ignore */ }
547
+ }
548
+
549
+ function buildDryRunPreview(report) {
550
+ return {
551
+ wouldMigrate: {
552
+ messages: report.counts.messages + report.counts.cold,
553
+ memoryEntries: report.counts.memoryEntries,
554
+ tasks: report.counts.tasks,
555
+ threadsConsumed: report.counts.threads,
556
+ userMemoryFiles: (report.paths.memoryAggregate ? 1 : 0) + (report.paths.userPreferences ? 1 : 0),
557
+ },
558
+ wouldCreate: {
559
+ seedVp: `virtual-persons/${LEGACY_VP_ID}/`,
560
+ seedGroup: `groups/${LEGACY_GROUP_ID}/`,
561
+ userDir: 'user/',
562
+ },
563
+ wouldBackup: countBackupFiles(report),
564
+ };
565
+ }
566
+
567
+ function countBackupFiles(report) {
568
+ return collectBackupFiles(report).length;
569
+ }
570
+
571
+ // ═══════════════ utilities ═══════════════
572
+
573
+ function safeRead(path) {
574
+ try {
575
+ if (!path || !existsSync(path) || !statSync(path).isFile()) return '';
576
+ return readFileSync(path, 'utf8');
577
+ } catch {
578
+ return '';
579
+ }
580
+ }
581
+
582
+ function safeId(s) {
583
+ return String(s).replace(/[^A-Za-z0-9_\-]/g, '_').slice(0, 64) || 'anon';
584
+ }
585
+
586
+ function nowIso() {
587
+ return new Date().toISOString();
588
+ }
package/unify/session.js CHANGED
@@ -32,6 +32,7 @@ import { createIntentClassifier } from './router/intent-classifier.js';
32
32
  import { initInputQueueStore } from './input-queue/store.js';
33
33
  import { createDispatcher } from './pipeline/dispatcher.js';
34
34
  import { join } from 'path';
35
+ import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from 'fs';
35
36
 
36
37
  /**
37
38
  * @typedef {Object} SessionOptions
@@ -104,6 +105,30 @@ export async function loadSession(options = {}) {
104
105
  // ─── 2. Load config ───────────────────────────────────
105
106
  const config = loadConfig(overrides);
106
107
 
108
+ // ─── 2.1 Migration state check (task-334i) ────────────
109
+ // If the group-chat feature flag is on but migration has not
110
+ // completed, warn the user. Do NOT auto-run migration: that is
111
+ // an explicit action via bin/yeaft-migrate.js.
112
+ try {
113
+ if (config?.features?.unifyGroupChat === true) {
114
+ const stateFile = join(yeaftDir, '.migration-state.json');
115
+ let completed = false;
116
+ if (existsSyncSafe(stateFile)) {
117
+ try {
118
+ const raw = readFileSyncSafe(stateFile, 'utf8');
119
+ const state = JSON.parse(raw || '{}');
120
+ completed = Boolean(state && state.completedAt);
121
+ } catch { /* malformed state → treat as not completed */ }
122
+ }
123
+ if (!completed) {
124
+ console.warn(
125
+ '[Yeaft] features.unifyGroupChat=true but storage migration is not complete. ' +
126
+ 'Run `yeaft-migrate` before using the new group-chat tree, or unset the flag.',
127
+ );
128
+ }
129
+ }
130
+ } catch { /* never let this warn path block session load */ }
131
+
107
132
  // ─── 2a. Permission pre-check ─────────────────────────
108
133
  // If the data dir is not writable, mark session as read-only.
109
134
  // Persistence (conversation, memory, dream) is skipped in this mode.