@yeaft/webchat-agent 1.0.575 → 1.0.576

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.575",
3
+ "version": "1.0.576",
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",
@@ -115,8 +115,8 @@ export function createCliVpEngine(loaded, sessionId, vpId) {
115
115
  trace: loaded.trace,
116
116
  config: effectiveConfig,
117
117
  conversationStore: loaded.conversationStore,
118
- memoryIndex: loaded.memoryIndex || null,
119
- amsRegistry: loaded.amsRegistry || null,
118
+ memoryIndex: null,
119
+ amsRegistry: null,
120
120
  toolRegistry: loaded.toolRegistry,
121
121
  skillManager: loaded.skillManager,
122
122
  mcpManager: loaded.mcpManager,
package/yeaft/cli.js CHANGED
@@ -480,7 +480,7 @@ async function runREPL(config, args) {
480
480
  }
481
481
 
482
482
  case 'memory': {
483
- console.log('Memory commands have been retired. Memory is now managed by Dream V2; use the in-app FTS recall.');
483
+ console.log('Dream memory is disabled. Historical memory files are retained but are not loaded automatically.');
484
484
  break;
485
485
  }
486
486
 
package/yeaft/config.js CHANGED
@@ -545,7 +545,9 @@ export function loadConfig(overrides = {}) {
545
545
  yeaft: normaliseYeaftSection(jsonConfig.yeaft),
546
546
  telemetry: normaliseTelemetrySection(jsonConfig.telemetry),
547
547
  workCenter: { enabled: jsonConfig.workCenter?.enabled === true },
548
- dream: { enabled: jsonConfig.dream?.enabled === true },
548
+ // Dream runtime is disabled. Keep the stable shape but never honor a
549
+ // persisted toggle from an older client.
550
+ dream: { enabled: false },
549
551
  browserRuntime: normaliseBrowserRuntimeSection(jsonConfig.browserRuntime),
550
552
 
551
553
  // Agent-level tools / skills / MCP server allowlists. Missing fields mean
@@ -18,12 +18,10 @@ import { createInterface } from 'readline';
18
18
  const TRACE_VERSION = 3;
19
19
  const REQUEST_RETENTION = 10;
20
20
  const MAX_HISTORY_LIMIT = 5;
21
- const MAX_DREAM_EVENTS = 100;
22
21
  // Debug records are diagnostic source data. They deliberately do not inherit
23
22
  // model-context or ordinary history budgets, and explicit detail transport is
24
23
  // chunked instead of truncating persisted values.
25
24
  const TRACE_APPEND_BATCH_MS = 100;
26
- const EVENT_FLUSH_INTERVAL_MS = 30_000;
27
25
  const MAX_SEARCH_PATTERN_CHARS = 300;
28
26
 
29
27
  function isPlainObject(value) {
@@ -924,23 +922,6 @@ export class DebugTrace {
924
922
  #appendTimer = null;
925
923
  /** @type {number} */
926
924
  #sequence = 0;
927
- /**
928
- * In-memory dream/event ring (authoritative copy; persisted async).
929
- * @type {Array<object>}
930
- */
931
- #events = [];
932
- /** @type {boolean} */
933
- #eventsDirty = false;
934
- /**
935
- * Whether the on-disk events.json has been folded into #events yet. The
936
- * events ring must merge prior-run history exactly once before any flush
937
- * overwrites the file, or a live append landing before first hydrate would
938
- * silently destroy cross-restart dream history.
939
- * @type {boolean}
940
- */
941
- #eventsHydrated = false;
942
- /** @type {NodeJS.Timeout|null} */
943
- #eventFlushTimer = null;
944
925
  /**
945
926
  * One-time metadata hydrate guard. Reads/maintenance keep only bounded
946
927
  * request headers resident. Full loop/tool payloads are loaded for one
@@ -1102,32 +1083,10 @@ export class DebugTrace {
1102
1083
  return id;
1103
1084
  }
1104
1085
 
1105
- logEvent({ traceId, eventType, eventData = null } = {}) {
1106
- const id = randomUUID();
1107
- if (!this.#acceptingWrites) return id;
1108
- // In-memory authoritative ring; persisted async (fire-and-forget). The
1109
- // dream/event sink runs on the engine hot path, so it must not block on a
1110
- // synchronous read-modify-write of events.json.
1111
- this.#events.push({
1112
- id,
1113
- traceId: traceId || String(eventType || 'event'),
1114
- eventType: eventType || 'event',
1115
- eventData: cloneJsonValue(eventData),
1116
- createdAt: Date.now(),
1117
- });
1118
- if (this.#events.length > MAX_DREAM_EVENTS) {
1119
- this.#events = this.#events.slice(-MAX_DREAM_EVENTS);
1120
- }
1121
- this.#scheduleEventFlush();
1122
- return id;
1123
- }
1124
-
1125
- event(eventType, eventData = null) {
1126
- const traceId = (eventData && typeof eventData === 'object' && (eventData.turnId || eventData.runId))
1127
- ? String(eventData.turnId || eventData.runId)
1128
- : String(eventType || 'event');
1129
- return this.logEvent({ traceId, eventType, eventData });
1130
- }
1086
+ // The global events.json ring belonged to Dream. Keep these call shapes for
1087
+ // compatibility, but never read, append, or rewrite its archived contents.
1088
+ logEvent() { return 'null'; }
1089
+ event() { return 'null'; }
1131
1090
 
1132
1091
  async queryByMessage(messageId) {
1133
1092
  await this.#drainWrites();
@@ -1163,7 +1122,7 @@ export class DebugTrace {
1163
1122
  }
1164
1123
  }
1165
1124
 
1166
- async fetchTurnDebug({ sessionId, turnId, dreamLimit = 0 } = {}) {
1125
+ async fetchTurnDebug({ sessionId, turnId } = {}) {
1167
1126
  const requestedSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null;
1168
1127
  const requestedTurnId = typeof turnId === 'string' && turnId ? turnId : null;
1169
1128
  if (!requestedSessionId || !requestedTurnId) {
@@ -1193,16 +1152,16 @@ export class DebugTrace {
1193
1152
  }
1194
1153
  }
1195
1154
  }
1196
- const dreamEvents = this.#readDreamEvents({ sessionId: requestedSessionId, dreamLimit });
1155
+ const dreamEvents = []; // Compatibility shape; retired Dream events are not replayed.
1197
1156
  if (!trace) return { loops: [], turns: [], dreamEvents, detailTurnId: requestedTurnId };
1198
1157
  const expanded = expandTrace(trace);
1199
1158
  return { ...expanded, dreamEvents, detailTurnId: requestedTurnId };
1200
1159
  }
1201
1160
 
1202
- async fetchRecentDebugHistory({ limit = MAX_HISTORY_LIMIT, dreamLimit = 5, sessionId = null, threadId = null, indexOnly = false, detailTurnId = null, search = '' } = {}) {
1161
+ async fetchRecentDebugHistory({ limit = MAX_HISTORY_LIMIT, sessionId = null, threadId = null, indexOnly = false, detailTurnId = null, search = '' } = {}) {
1203
1162
  const requestedDetailTurnId = typeof detailTurnId === 'string' && detailTurnId ? detailTurnId : null;
1204
1163
  if (requestedDetailTurnId && sessionId) {
1205
- const detail = await this.fetchTurnDebug({ sessionId, turnId: requestedDetailTurnId, dreamLimit });
1164
+ const detail = await this.fetchTurnDebug({ sessionId, turnId: requestedDetailTurnId });
1206
1165
  return { ...detail, hasMore: false, limit: detail.loops.length, indexOnly: false };
1207
1166
  }
1208
1167
  await this.#drainWrites();
@@ -1212,7 +1171,7 @@ export class DebugTrace {
1212
1171
  .map(({ trace }) => trace)
1213
1172
  .filter(trace => !threadId || trace.threadId === threadId)
1214
1173
  .filter(trace => requestedDetailTurnId || traceMatchesRegex(trace, searchRegex));
1215
- const dreamEvents = this.#readDreamEvents({ sessionId, dreamLimit });
1174
+ const dreamEvents = [];
1216
1175
  if (requestedDetailTurnId) {
1217
1176
  const trace = traces.find(t => t.requestId === requestedDetailTurnId || t.traceId === requestedDetailTurnId);
1218
1177
  if (!trace) return { loops: [], turns: [], dreamEvents, hasMore: false, limit: 0, indexOnly: false, detailTurnId: requestedDetailTurnId };
@@ -1308,7 +1267,7 @@ export class DebugTrace {
1308
1267
  turnCount += counts.loopCount;
1309
1268
  toolCount += counts.toolCount;
1310
1269
  }
1311
- const eventCount = this.#events.length;
1270
+ const eventCount = 0;
1312
1271
  const { bytes } = await countDirFiles(this.#rootDir);
1313
1272
  return { turnCount, toolCount, eventCount, dbSizeBytes: bytes, fileSizeBytes: bytes, requestCount: traces.length };
1314
1273
  }
@@ -1343,10 +1302,8 @@ export class DebugTrace {
1343
1302
  this.#initializedRequestKeys.clear();
1344
1303
  this.#reconciledRetentionSessions.clear();
1345
1304
  this.#retentionIndex.clear();
1346
- this.#events = [];
1347
1305
  this.#hydrated = false;
1348
1306
  this.#hydratePromise = null;
1349
- this.#eventsHydrated = false;
1350
1307
  this.#acceptingWrites = true;
1351
1308
  }
1352
1309
 
@@ -1497,39 +1454,19 @@ export class DebugTrace {
1497
1454
  if (this.#hydrated) return;
1498
1455
  if (this.#hydratePromise) return this.#hydratePromise;
1499
1456
  this.#hydratePromise = (async () => {
1500
- const [headers, storedEvents] = await Promise.all([
1501
- readTraceHeaders(this.#rootDir, null),
1502
- readJson(join(this.#rootDir, 'events.json')),
1503
- ]);
1457
+ const headers = await readTraceHeaders(this.#rootDir, null);
1504
1458
  for (const { trace } of headers) {
1505
1459
  if (!trace?.requestKey || this.#requestCache.has(trace.requestKey)) continue;
1506
1460
  this.#diskHeaders.set(trace.requestKey, trace.active
1507
1461
  ? { ...trace, active: false, interrupted: true }
1508
1462
  : trace);
1509
1463
  }
1510
- if (Array.isArray(storedEvents)) this.#mergeStoredEvents(storedEvents);
1511
1464
  this.#hydrated = true;
1512
1465
  this.#hydratePromise = null;
1513
1466
  })();
1514
1467
  return this.#hydratePromise;
1515
1468
  }
1516
1469
 
1517
- /**
1518
- * Fold on-disk events into the in-memory ring by id, preserving live order
1519
- * and bounding to MAX_DREAM_EVENTS. Unseen disk records are prepended (they
1520
- * are older); a live append therefore never erases prior-run history. Sets
1521
- * #eventsHydrated so this runs at most once.
1522
- */
1523
- #mergeStoredEvents(stored) {
1524
- if (this.#eventsHydrated) return;
1525
- this.#eventsHydrated = true;
1526
- if (!Array.isArray(stored) || stored.length === 0) return;
1527
- const seen = new Set(this.#events.map(e => e?.id).filter(Boolean));
1528
- const older = stored.filter(e => e && (!e.id || !seen.has(e.id)));
1529
- if (older.length === 0) return;
1530
- this.#events = [...older, ...this.#events].slice(-MAX_DREAM_EVENTS);
1531
- }
1532
-
1533
1470
  #traceFile(trace) {
1534
1471
  return tracePathFor(this.#rootDir, trace.sessionId || null, trace.requestKey);
1535
1472
  }
@@ -1580,16 +1517,6 @@ export class DebugTrace {
1580
1517
  if (typeof this.#appendTimer.unref === 'function') this.#appendTimer.unref();
1581
1518
  }
1582
1519
 
1583
- #scheduleEventFlush() {
1584
- this.#eventsDirty = true;
1585
- if (this.#eventFlushTimer) return;
1586
- this.#eventFlushTimer = setTimeout(() => {
1587
- this.#eventFlushTimer = null;
1588
- this.#flushEvents();
1589
- }, EVENT_FLUSH_INTERVAL_MS);
1590
- if (typeof this.#eventFlushTimer.unref === 'function') this.#eventFlushTimer.unref();
1591
- }
1592
-
1593
1520
  /** Queue append-only loop/tool records onto the single-writer chain. */
1594
1521
  #flushPending() {
1595
1522
  if (this.#appendTimer) {
@@ -1597,10 +1524,7 @@ export class DebugTrace {
1597
1524
  this.#appendTimer = null;
1598
1525
  }
1599
1526
  const entries = this.#pendingWrites.splice(0);
1600
- if (entries.length === 0) {
1601
- this.#flushEvents();
1602
- return;
1603
- }
1527
+ if (entries.length === 0) return;
1604
1528
  this.#chain(async () => {
1605
1529
  const batches = new Map();
1606
1530
  for (const entry of entries) {
@@ -1697,7 +1621,6 @@ export class DebugTrace {
1697
1621
  try { await this.#pruneAll(REQUEST_RETENTION); }
1698
1622
  catch (err) { console.warn('[Yeaft] debug trace prune failed:', err?.message || err); }
1699
1623
  });
1700
- this.#flushEvents();
1701
1624
  }
1702
1625
 
1703
1626
  /**
@@ -1717,26 +1640,6 @@ export class DebugTrace {
1717
1640
  return this.#flushChain;
1718
1641
  }
1719
1642
 
1720
- #flushEvents() {
1721
- if (!this.#eventsDirty) return;
1722
- this.#eventsDirty = false;
1723
- if (this.#eventFlushTimer) {
1724
- clearTimeout(this.#eventFlushTimer);
1725
- this.#eventFlushTimer = null;
1726
- }
1727
- const file = join(this.#rootDir, 'events.json');
1728
- this.#chain(async () => {
1729
- // Fold in any prior-run events we haven't loaded yet BEFORE overwriting,
1730
- // so a write that beats #ensureHydrated can't drop cross-restart history.
1731
- if (!this.#eventsHydrated) {
1732
- this.#mergeStoredEvents(await readJson(file));
1733
- }
1734
- const text = JSON.stringify(this.#events.slice(-MAX_DREAM_EVENTS));
1735
- try { await atomicWriteText(file, text); }
1736
- catch (err) { console.warn('[Yeaft] debug trace event write failed:', err?.message || err); }
1737
- });
1738
- }
1739
-
1740
1643
  /** Await every scheduled write (and prune) to settle. Used by close()/queries. */
1741
1644
  async #drainWrites() {
1742
1645
  this.#flushPending();
@@ -1757,32 +1660,6 @@ export class DebugTrace {
1757
1660
  return snapshot;
1758
1661
  }
1759
1662
 
1760
- #readDreamEvents({ sessionId = null, dreamLimit = 5 } = {}) {
1761
- const limit = Number.isFinite(Number(dreamLimit)) ? Math.max(0, Math.min(50, Number(dreamLimit))) : 5;
1762
- if (limit <= 0) return [];
1763
- // In-memory ring (hydrated once); no disk read on the query path.
1764
- const events = this.#events;
1765
- const out = [];
1766
- for (const event of events.slice().reverse()) {
1767
- const data = isPlainObject(event.eventData) ? event.eventData : {};
1768
- if (sessionId) {
1769
- const evtSessionId = typeof data.sessionId === 'string' && data.sessionId ? data.sessionId : null;
1770
- const target = typeof data.target === 'string' ? data.target : '';
1771
- const isBroadcast = !evtSessionId && !target;
1772
- const isThisSession = evtSessionId === sessionId || target === `sessions/${sessionId}` || target === `group/${sessionId}`;
1773
- if (!isBroadcast && !isThisSession) continue;
1774
- }
1775
- out.push({
1776
- type: data.type || event.eventType || 'event',
1777
- ...data,
1778
- at: event.createdAt,
1779
- ts: data.ts || data.at || event.createdAt,
1780
- });
1781
- if (out.length >= limit) break;
1782
- }
1783
- return out.reverse();
1784
- }
1785
-
1786
1663
  async #pruneAll(keep) {
1787
1664
  const sessions = new Set();
1788
1665
  for (const trace of this.#diskHeaders.values()) sessions.add(trace.sessionId || null);
package/yeaft/init.js CHANGED
@@ -86,7 +86,6 @@ const SUBDIRS = [
86
86
  'chat/cold',
87
87
  'chat/blobs',
88
88
  'sessions',
89
- 'memory/entries',
90
89
  'tasks',
91
90
  'skills',
92
91
  ];
@@ -115,19 +114,6 @@ const DEFAULT_CONFIG_JSON = `{
115
114
  }
116
115
  `;
117
116
 
118
- /** Default MEMORY.md content. */
119
- const DEFAULT_MEMORY = `# Yeaft Memory
120
-
121
- This file stores persistent memory entries. The agent will read and update this file.
122
-
123
- ## Facts
124
-
125
- ## Preferences
126
-
127
- ## Project Context
128
-
129
- `;
130
-
131
117
  /** Default mcp.json example — generated as reference for MCP server configuration. */
132
118
  const DEFAULT_MCP_EXAMPLE = `{
133
119
  "_comment": "MCP server configuration. Rename to mcp.json to enable.",
@@ -160,9 +146,10 @@ This file tracks one Yeaft message-history mode.
160
146
  * Initialize the Yeaft data directory structure.
161
147
  *
162
148
  * @param {string} [dir] — Root directory path. Defaults to ~/.yeaft/
149
+ * @param {{ migrateMemory?: boolean }} [options] — Archived Dream migration is explicit opt-in, never part of Agent/service/Session startup.
163
150
  * @returns {{ dir: string, created: string[], writable: boolean, warnings: string[], seededSkills?: number }} — The root dir, list of created paths, writability status, any warnings, and how many bundled skills were seeded
164
151
  */
165
- export function initYeaftDir(dir) {
152
+ export function initYeaftDir(dir, { migrateMemory = false } = {}) {
166
153
  const root = dir || DEFAULT_YEAFT_DIR;
167
154
  const created = [];
168
155
  const warnings = [];
@@ -211,12 +198,6 @@ export function initYeaftDir(dir) {
211
198
  }
212
199
  }
213
200
 
214
- const memoryPath = join(root, 'memory', 'MEMORY.md');
215
- if (!existsSync(memoryPath)) {
216
- safeWriteFile(memoryPath, DEFAULT_MEMORY, warnings);
217
- created.push(memoryPath);
218
- }
219
-
220
201
  const chatIndexPath = join(root, 'chat', 'index.md');
221
202
  if (!existsSync(chatIndexPath)) {
222
203
  safeWriteFile(chatIndexPath, DEFAULT_CONVERSATION_INDEX, warnings);
@@ -252,7 +233,7 @@ export function initYeaftDir(dir) {
252
233
  // Idempotent (sentinel file) so re-running on a fully-migrated dir is a
253
234
  // no-op; on a partial-crash dir, each step is independently resumable.
254
235
  try {
255
- const res = migrateSessions(root);
236
+ const res = migrateSessions(root, { migrateMemory });
256
237
  if (res && res.migrated) {
257
238
  console.log(`[yeaft] session migration complete (${res.moved} dirs moved, ${res.frontmatterRewrites} messages rewritten${res.warnings?.length ? `, ${res.warnings.length} warnings` : ''})`);
258
239
  if (res.warnings?.length) for (const w of res.warnings) console.warn(`[yeaft] migration: ${w}`);
@@ -64,19 +64,21 @@ const SENTINEL_VERSION = 3;
64
64
  * @param {string} yeaftDir
65
65
  * @returns {{ migrated: boolean, moved: number, frontmatterRewrites: number, warnings: string[] }}
66
66
  */
67
- export function migrateSessions(yeaftDir) {
67
+ export function migrateSessions(yeaftDir, { migrateMemory = true } = {}) {
68
68
  const warnings = [];
69
69
  if (!yeaftDir || !existsSync(yeaftDir)) {
70
70
  return { migrated: false, moved: 0, frontmatterRewrites: 0, warnings: ['yeaftDir missing'] };
71
71
  }
72
72
  const sentinel = join(yeaftDir, SENTINEL);
73
73
  if (existsSync(sentinel)) {
74
- // Version-aware: ignore a sentinel from an older schema. Future v3
75
- // migrations can run on v2-completed trees because they read what
76
- // we wrote and decide for themselves whether to re-execute.
77
- let v = 0;
78
- try { v = JSON.parse(readFileSync(sentinel, 'utf8'))?.version ?? 0; } catch { /* corrupt sentinel — treat as 0 */ }
79
- if (v >= SENTINEL_VERSION) {
74
+ // Version-aware: ignore a sentinel from an older schema. A runtime-only
75
+ // migration deliberately leaves memoryMigrated=false so an explicitly
76
+ // invoked full migration can still process the archived Dream data later.
77
+ let state = null;
78
+ try { state = JSON.parse(readFileSync(sentinel, 'utf8')); } catch { /* corrupt sentinel — treat as incomplete */ }
79
+ const versionComplete = (state?.version ?? 0) >= SENTINEL_VERSION;
80
+ const memoryComplete = state?.memoryMigrated !== false;
81
+ if (versionComplete && (!migrateMemory || memoryComplete)) {
80
82
  return { migrated: false, moved: 0, frontmatterRewrites: 0, warnings: [] };
81
83
  }
82
84
  }
@@ -96,8 +98,8 @@ export function migrateSessions(yeaftDir) {
96
98
  const memSessionsAmsRoot = join(memoryRoot, 'sessions');
97
99
 
98
100
  if (!existsSync(sessionsRoot)) mkdirSync(sessionsRoot, { recursive: true });
99
- if (!existsSync(memSessionRoot)) mkdirSync(memSessionRoot, { recursive: true });
100
- if (!existsSync(memSessionsAmsRoot)) mkdirSync(memSessionsAmsRoot, { recursive: true });
101
+ if (migrateMemory && !existsSync(memSessionRoot)) mkdirSync(memSessionRoot, { recursive: true });
102
+ if (migrateMemory && !existsSync(memSessionsAmsRoot)) mkdirSync(memSessionsAmsRoot, { recursive: true });
101
103
 
102
104
  // ID collision check
103
105
  const groupIds = listDirs(groupsRoot);
@@ -165,52 +167,52 @@ export function migrateSessions(yeaftDir) {
165
167
  moved++;
166
168
  }
167
169
 
168
- // 3+4. memory/{group,chat}/<id>/ → memory/session/<id>/
169
- for (const family of ['group', 'chat']) {
170
- const root = join(memoryRoot, family);
171
- if (!existsSync(root)) continue;
172
- for (const id of listDirs(root)) {
173
- const src = join(root, id);
174
- const dst = join(memSessionRoot, id);
175
- if (existsSync(dst)) {
176
- warnings.push(`memory/session/${id} already exists; skipping memory/${family}/${id}`);
177
- continue;
170
+ if (migrateMemory) {
171
+ // 3+4. memory/{group,chat}/<id>/ → memory/session/<id>/
172
+ for (const family of ['group', 'chat']) {
173
+ const root = join(memoryRoot, family);
174
+ if (!existsSync(root)) continue;
175
+ for (const id of listDirs(root)) {
176
+ const src = join(root, id);
177
+ const dst = join(memSessionRoot, id);
178
+ if (existsSync(dst)) {
179
+ warnings.push(`memory/session/${id} already exists; skipping memory/${family}/${id}`);
180
+ continue;
181
+ }
182
+ renameSync(src, dst);
183
+ rewriteSegmentScopes(dst, family, id, warnings);
178
184
  }
179
- renameSync(src, dst);
180
- rewriteSegmentScopes(dst, family, id, warnings);
181
185
  }
182
- }
183
186
 
184
- // 5. memory/groups/<g>/ams.json → memory/sessions/<g>/ams.json
185
- const amsGroupRoot = join(memoryRoot, 'groups');
186
- if (existsSync(amsGroupRoot)) {
187
- for (const id of listDirs(amsGroupRoot)) {
188
- const srcDir = join(amsGroupRoot, id);
189
- const dstDir = join(memSessionsAmsRoot, id);
190
- if (existsSync(dstDir)) {
191
- warnings.push(`memory/sessions/${id} already exists; skipping memory/groups/${id}`);
192
- continue;
187
+ // 5. memory/groups/<g>/ams.json → memory/sessions/<g>/ams.json
188
+ const amsGroupRoot = join(memoryRoot, 'groups');
189
+ if (existsSync(amsGroupRoot)) {
190
+ for (const id of listDirs(amsGroupRoot)) {
191
+ const srcDir = join(amsGroupRoot, id);
192
+ const dstDir = join(memSessionsAmsRoot, id);
193
+ if (existsSync(dstDir)) {
194
+ warnings.push(`memory/sessions/${id} already exists; skipping memory/groups/${id}`);
195
+ continue;
196
+ }
197
+ renameSync(srcDir, dstDir);
193
198
  }
194
- renameSync(srcDir, dstDir);
195
199
  }
196
- }
197
- const amsChatRoot = join(memoryRoot, 'chats');
198
- if (existsSync(amsChatRoot)) {
199
- for (const id of listDirs(amsChatRoot)) {
200
- const srcDir = join(amsChatRoot, id);
201
- const dstDir = join(memSessionsAmsRoot, id);
202
- if (existsSync(dstDir)) {
203
- warnings.push(`memory/sessions/${id} already exists; skipping memory/chats/${id}`);
204
- continue;
200
+ const amsChatRoot = join(memoryRoot, 'chats');
201
+ if (existsSync(amsChatRoot)) {
202
+ for (const id of listDirs(amsChatRoot)) {
203
+ const srcDir = join(amsChatRoot, id);
204
+ const dstDir = join(memSessionsAmsRoot, id);
205
+ if (existsSync(dstDir)) {
206
+ warnings.push(`memory/sessions/${id} already exists; skipping memory/chats/${id}`);
207
+ continue;
208
+ }
209
+ renameSync(srcDir, dstDir);
205
210
  }
206
- renameSync(srcDir, dstDir);
207
211
  }
208
- }
209
212
 
210
- // 6. Rewrite SQLite FTS index scope strings via the shared index-db module
211
- // (which already handles ABI loading). Idempotent: WHERE clause skips
212
- // already-rewritten rows. Synchronous: better-sqlite3 has no async API.
213
- rewriteFtsScopes(memoryRoot, warnings);
213
+ // 6. Rewrite SQLite FTS index scope strings via the shared index-db module.
214
+ rewriteFtsScopes(memoryRoot, warnings);
215
+ }
214
216
 
215
217
  // 7. Per-message frontmatter rewrite. Walks both the legacy flat
216
218
  // conversation directory and every per-session conversation directory
@@ -224,8 +226,11 @@ export function migrateSessions(yeaftDir) {
224
226
  moved += cleanup.moved;
225
227
 
226
228
  // 9. Sentinel — version 3 = consolidated migration plus legacy cleanup.
229
+ // Record whether the optional archived-memory steps ran; runtime startup
230
+ // skips them, while explicit migration callers retain the ability to run them.
227
231
  writeFileSync(sentinel, JSON.stringify({
228
232
  version: SENTINEL_VERSION,
233
+ memoryMigrated: migrateMemory,
229
234
  migratedAt: new Date().toISOString(),
230
235
  moved,
231
236
  frontmatterRewrites,