@yeaft/webchat-agent 0.1.916 → 0.1.917

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.
Files changed (38) hide show
  1. package/package.json +2 -2
  2. package/yeaft/conversation/persist.js +0 -12
  3. package/yeaft/{dream-v2 → dream}/apply.js +3 -3
  4. package/yeaft/{dream-v2 → dream}/limits.js +1 -1
  5. package/yeaft/{dream-v2 → dream}/merge.js +1 -1
  6. package/yeaft/{dream-v2 → dream}/prompts/index.js +1 -1
  7. package/yeaft/{dream-v2 → dream}/runner.js +2 -2
  8. package/yeaft/{dream-v2 → dream}/schedule.js +1 -1
  9. package/yeaft/{dream-v2 → dream}/segment.js +1 -1
  10. package/yeaft/{dream-v2 → dream}/session-wiring.js +6 -6
  11. package/yeaft/{dream-v2 → dream}/snapshot.js +1 -1
  12. package/yeaft/{dream-v2 → dream}/state.js +3 -3
  13. package/yeaft/{dream-v2 → dream}/triage.js +2 -2
  14. package/yeaft/engine.js +2 -2
  15. package/yeaft/history-compact.js +1 -1
  16. package/yeaft/init.js +19 -25
  17. package/yeaft/memory/adjust.js +1 -1
  18. package/yeaft/memory/ams.js +1 -1
  19. package/yeaft/memory/preflow.js +1 -1
  20. package/yeaft/memory/seed-backfill.js +1 -1
  21. package/yeaft/memory/{store-v2.js → store.js} +2 -2
  22. package/yeaft/migrate/sessions.js +485 -0
  23. package/yeaft/session.js +4 -4
  24. package/yeaft/sessions/seed-default.js +1 -1
  25. package/yeaft/sessions/session-crud.js +1 -1
  26. package/yeaft/stop-hooks.js +1 -1
  27. package/yeaft/vp/vp-crud.js +1 -1
  28. package/yeaft/web-bridge.js +1 -1
  29. package/yeaft/migrate/sessions-v1.js +0 -295
  30. /package/yeaft/{dream-v2 → dream}/prompts/create.md +0 -0
  31. /package/yeaft/{dream-v2 → dream}/prompts/extract-session.md +0 -0
  32. /package/yeaft/{dream-v2 → dream}/prompts/extract-topic.md +0 -0
  33. /package/yeaft/{dream-v2 → dream}/prompts/extract-user.md +0 -0
  34. /package/yeaft/{dream-v2 → dream}/prompts/extract-vp.md +0 -0
  35. /package/yeaft/{dream-v2 → dream}/prompts/summarize-scope.md +0 -0
  36. /package/yeaft/{dream-v2 → dream}/prompts/triage-pass1.md +0 -0
  37. /package/yeaft/{dream-v2 → dream}/prompts/triage-pass2.md +0 -0
  38. /package/yeaft/{dream-v2 → dream}/prompts/update.md +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.916",
3
+ "version": "0.1.917",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "files": [
37
37
  "**/*.js",
38
38
  "yeaft/templates/**/*.md",
39
- "yeaft/dream-v2/prompts/**/*.md"
39
+ "yeaft/dream/prompts/**/*.md"
40
40
  ],
41
41
  "license": "MIT",
42
42
  "author": "Yeaft",
@@ -320,18 +320,6 @@ export function parseMessage(raw) {
320
320
  case 'threadId': msg.threadId = value; break;
321
321
  case 'sourceThreadId': msg.sourceThreadId = value; break;
322
322
  case 'sessionId': msg.sessionId = value; break;
323
- // Legacy alias: pre-rename messages stamped `groupId:` (PR #881
324
- // groups → sessions sweep). Without this case, files in
325
- // ~/.yeaft/conversation/messages/*.md from before the rename
326
- // parse with msg.sessionId undefined and #loadSessionHotMessages'
327
- // `.filter(m => m?.sessionId)` drops every row — LLM replay sees
328
- // an empty history. `if (!msg.sessionId)` keeps new-key precedence
329
- // for hand-edited files that carry both keys. See
330
- // migrate/sessions-v1.js — that pass moved directories but did
331
- // NOT rewrite per-message frontmatter, which is why this alias
332
- // has to live forever (or until a frontmatter-rewrite migration
333
- // lands and the legacy dir is empty).
334
- case 'groupId': if (!msg.sessionId) msg.sessionId = value; break;
335
323
  case 'chatId': msg.chatId = value; break;
336
324
  case 'speakerVpId': msg.speakerVpId = value; break;
337
325
  case 'attachmentsB64':
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dream-v2/apply.js.
2
+ * dream/apply.js.
3
3
  *
4
4
  * Execute one merged-target action: either UPDATE an existing scope's
5
5
  * memory.md/summary.md, or CREATE a new one (currently topic-only).
@@ -21,7 +21,7 @@
21
21
  import { promises as fsp } from 'fs';
22
22
  import { join, dirname } from 'path';
23
23
 
24
- import { writeMemory, writeSummary, readMemory, readSummary } from '../memory/store-v2.js';
24
+ import { writeMemory, writeSummary, readMemory, readSummary } from '../memory/store.js';
25
25
  import { withDreamMarker } from './state.js';
26
26
  import { batchSourcesForApply, needsBatchedApply, truncateMessage } from './segment.js';
27
27
  import { snapshotScope } from './snapshot.js';
@@ -111,7 +111,7 @@ function renderSourceBlocks(sources, language) {
111
111
 
112
112
  /**
113
113
  * Translate `target` like 'group/g-eng' or 'topic/sci/phys' to a Scope
114
- * understood by store-v2. Throws if the path is malformed.
114
+ * understood by store. Throws if the path is malformed.
115
115
  *
116
116
  * @param {string} target
117
117
  * @returns {{ kind: string, id?: string, path?: string[] }}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dream-v2/limits.js constants.
2
+ * dream/limits.js constants.
3
3
  *
4
4
  * Centralised so tests and runtime share one source of truth. Exposed
5
5
  * as both named exports and a `DEFAULT_LIMITS` object for `runDream()`
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dream-v2/merge.js.
2
+ * dream/merge.js.
3
3
  *
4
4
  * Pure-code stage between Triage and Apply.
5
5
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dream-v2/prompts/index.js — load + render the four dream prompts.
2
+ * dream/prompts/index.js — load + render the four dream prompts.
3
3
  *
4
4
  * Prompts live as `.md` files in this directory. We read once and cache.
5
5
  * Rendering is plain `{{name}}` substitution; no nested logic, no escaping —
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dream-v2/runner.js.
2
+ * dream/runner.js.
3
3
  *
4
4
  * Orchestrates one full dream pass:
5
5
  *
@@ -41,7 +41,7 @@
41
41
  import { existsSync } from 'fs';
42
42
  import { join } from 'path';
43
43
 
44
- import { listScopes, readSummary } from '../memory/store-v2.js';
44
+ import { listScopes, readSummary } from '../memory/store.js';
45
45
  import {
46
46
  DEFAULT_LIMITS,
47
47
  } from './limits.js';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dream-v2/schedule.js.
2
+ * dream/schedule.js.
3
3
  *
4
4
  * Three trigger paths:
5
5
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dream-v2/segment.js.
2
+ * dream/segment.js.
3
3
  *
4
4
  * Three independent length-control concerns, kept pure so they can be
5
5
  * unit-tested without touching disk or any LLM:
@@ -1,7 +1,7 @@
1
1
  /**
2
- * dream-v2/session-wiring.js wire-up for session.js.
2
+ * dream/session-wiring.js wire-up for session.js.
3
3
  *
4
- * Bridges the framework-agnostic `runDream` orchestrator (dream-v2/runner.js)
4
+ * Bridges the framework-agnostic `runDream` orchestrator (dream/runner.js)
5
5
  * to the live yeaft session: groups store, conversation log, LLM adapter,
6
6
  * and the engine's progress event sink.
7
7
  *
@@ -137,7 +137,7 @@ function makeLlm(session) {
137
137
  const adapter = session.adapter;
138
138
  const model = session.config?.fastModelId || session.config?.model;
139
139
  if (!adapter || typeof adapter.call !== 'function') {
140
- throw new Error(`dream-v2: no adapter.call available (pass=${pass})`);
140
+ throw new Error(`dream: no adapter.call available (pass=${pass})`);
141
141
  }
142
142
 
143
143
  // Bug 2: emit loop event so the debug panel shows dream LLM API calls.
@@ -304,7 +304,7 @@ export function createV2DreamScheduler(session) {
304
304
  // Best-effort console for debug builds.
305
305
  if (session.config?.debug) {
306
306
  // eslint-disable-next-line no-console
307
- console.log('[dream-v2]', stampedEvt);
307
+ console.log('[dream]', stampedEvt);
308
308
  }
309
309
  } catch { /* never let progress reporting kill the run */ }
310
310
  };
@@ -473,7 +473,7 @@ export async function bootInitEmptyGroups(args) {
473
473
  if (empty.length === 0) return out;
474
474
  if (args.config?.debug) {
475
475
  // eslint-disable-next-line no-console
476
- console.log(`[dream-v2] boot init: triggering empty-AMS dream for ${empty.length} group(s):`, empty);
476
+ console.log(`[dream] boot init: triggering empty-AMS dream for ${empty.length} group(s):`, empty);
477
477
  }
478
478
  // Fire-and-forget; the scheduler swallows its own failures.
479
479
  Promise.resolve(args.dreamScheduler.triggerDreamForScopes(empty)).catch(() => {});
@@ -554,7 +554,7 @@ export async function bootCatchUpStaleDream(args) {
554
554
 
555
555
  if (args.config?.debug) {
556
556
  // eslint-disable-next-line no-console
557
- console.log(`[dream-v2] boot catch-up: stale (lastDreamAt=${out.lastDreamAt}, ageMs=${out.ageMs}); firing one non-manual tick.`);
557
+ console.log(`[dream] boot catch-up: stale (lastDreamAt=${out.lastDreamAt}, ageMs=${out.ageMs}); firing one non-manual tick.`);
558
558
  }
559
559
 
560
560
  // Non-manual: MIN_NEW_PER_GROUP still applies. Use the public
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dream-v2/snapshot.js.
2
+ * dream/snapshot.js.
3
3
  *
4
4
  * Pre-Apply backup of memory.md + summary.md to
5
5
  * `~/.yeaft/memory/.dream-bak/<ts>/<scope-path>/`. The runner takes a
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dream-v2/state.js.
2
+ * dream/state.js.
3
3
  *
4
4
  * Three pieces of state, tracked separately:
5
5
  *
@@ -115,7 +115,7 @@ function parseGroupState(raw) {
115
115
 
116
116
  // ─── per-scope dream error sink ────────────────────────────────
117
117
  //
118
- // Why: dream-v2 silently swallowed exceptions at the triage / apply
118
+ // Why: dream silently swallowed exceptions at the triage / apply
119
119
  // catch sites — the only sink was `trace.event('dream_progress', evt)`
120
120
  // and a `config.debug`-gated `console.log` in `session-wiring.js`. With
121
121
  // `debug=false` (the default), there was no on-disk evidence that a
@@ -130,7 +130,7 @@ function parseGroupState(raw) {
130
130
 
131
131
  /**
132
132
  * Resolve a memoryRoot + scope-string to the scope directory.
133
- * The scope string is the same shape dream-v2 already uses internally:
133
+ * The scope string is the same shape dream already uses internally:
134
134
  * `'user'`, `'vp/<vpId>'`, `'group/<sessionId>'`, `'feature/<id>'`, etc.
135
135
  *
136
136
  * Pure path-join; does NOT create the directory. The writer creates it.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * dream-v2/triage.js.
2
+ * dream/triage.js.
3
3
  *
4
4
  * Decide, for one group's diff, which scopes should be touched by Apply.
5
5
  * The decision is two-staged on purpose:
@@ -35,7 +35,7 @@
35
35
  * below.)
36
36
  */
37
37
 
38
- import { isValidTopic } from '../memory/store-v2.js';
38
+ import { isValidTopic } from '../memory/store.js';
39
39
  import { truncateMessage } from './segment.js';
40
40
  import { render } from './prompts/index.js';
41
41
 
package/yeaft/engine.js CHANGED
@@ -28,7 +28,7 @@ import { runCompact as runCompactOrchestrator } from './compact/orchestrator.js'
28
28
  import { evaluateCompactTriggers } from './compact/triggers.js';
29
29
  import { archiveTurn } from './archive/turn-archive.js';
30
30
  import { archiveToolResults } from './archive/tool-results.js';
31
- import { readSummary as readScopeSummary } from './memory/store-v2.js';
31
+ import { readSummary as readScopeSummary } from './memory/store.js';
32
32
  import { runAdjust } from './memory/adjust.js';
33
33
  import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
34
34
  import { runStopHooks } from './stop-hooks.js';
@@ -261,7 +261,7 @@ export function buildResidentEntries(args) {
261
261
  // session-qualified. The legacy bare `vp/<id>` scope was a structural
262
262
  // bug — `summaries.vp` is actually loaded from `group/<sessionId>/vp/<id>/summary.md`
263
263
  // (see #loadLayerASummaries, kind:'group-vp'), so labelling it `vp/<id>`
264
- // in the Resident layer (a) collides with the ACL regex in store-v2
264
+ // in the Resident layer (a) collides with the ACL regex in store
265
265
  // (which only recognises `<root>/<sid>/vp/...`) and (b) makes the same
266
266
  // VP persona leak across DIFFERENT sessions whenever the AMS rehydrates
267
267
  // by id rather than by full scope path. The session-qualified form
@@ -150,7 +150,7 @@ export const DEFAULT_MESSAGE_TOKEN_BUDGET = 32768;
150
150
 
151
151
  /**
152
152
  * Estimate the token weight of a single message including role overhead
153
- * and any tool-call structure. Mirrors `dream-v2/segment.js` approach: a
153
+ * and any tool-call structure. Mirrors `dream/segment.js` approach: a
154
154
  * couple of tokens per message for role/wrapping plus the body.
155
155
  *
156
156
  * @param {{role:string, content?:string, toolCalls?:Array, toolCallId?:string}} m
package/yeaft/init.js CHANGED
@@ -8,10 +8,11 @@
8
8
  import { existsSync, mkdirSync, writeFileSync, accessSync, constants } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { homedir } from 'os';
11
- // NOTE: migrateSessionsV1 is called at the end of initYeaftDir() to collapse
12
- // any legacy groups/ + chats/ + memory/{group,chat}/ data into the unified
13
- // sessions/ layout. Idempotent via sentinel file.
14
- import { migrateSessionsV1 } from './migrate/sessions-v1.js';
11
+ // NOTE: migrateSessions runs at the end of initYeaftDir(). It collapses
12
+ // legacy groups/ + chats/ + memory/{group,chat}/ into the unified sessions/
13
+ // layout AND rewrites pre-rename per-message frontmatter (groupId → sessionId).
14
+ // Idempotent via the `.yeaft-migration.done` sentinel file.
15
+ import { migrateSessions } from './migrate/sessions.js';
15
16
 
16
17
  /**
17
18
  * Check if an error is a permission error (EACCES or EPERM).
@@ -215,27 +216,20 @@ export function initYeaftDir(dir) {
215
216
  created.push(mcpExamplePath);
216
217
  }
217
218
 
218
- // NOTE: sessions-v1 migration (collapse groups/ + chats/ sessions/) is
219
- // intentionally NOT wired here yet Phase 1 ships the session-store +
220
- // migration script + scope vocab as foundation only. Activating the
221
- // migration before the runtime reads from sessions/ would move data out
222
- // from under the live group/chat code paths. Phase 2 flips the runtime
223
- // and then hooks `migrateSessionsV1(root)` here.
224
-
225
- // NOTE: sessions-v1 migration runs at end. Fire-and-log: keep
226
- // initYeaftDir() sync so existing callers don't break. The migration is
227
- // idempotent (sentinel file) so a partial run on crash is safe.
228
- Promise.resolve()
229
- .then(() => migrateSessionsV1(root))
230
- .then((res) => {
231
- if (res && res.migrated) {
232
- console.log(`[yeaft] session migration complete (${res.moved} dirs moved${res.warnings?.length ? `, ${res.warnings.length} warnings` : ''})`);
233
- if (res.warnings?.length) for (const w of res.warnings) console.warn(`[yeaft] migration: ${w}`);
234
- }
235
- })
236
- .catch((err) => {
237
- console.warn(`[yeaft] session migration failed (continuing): ${err?.message || err}`);
238
- });
219
+ // NOTE: sessions migration runs synchronously here. It MUST complete before
220
+ // any LLM request fires, because step 7 (per-message frontmatter rewrite)
221
+ // is what lets the persist.js parser drop the legacy `groupId:` row alias.
222
+ // Idempotent (sentinel file) so re-running on a fully-migrated dir is a
223
+ // no-op; on a partial-crash dir, each step is independently resumable.
224
+ try {
225
+ const res = migrateSessions(root);
226
+ if (res && res.migrated) {
227
+ console.log(`[yeaft] session migration complete (${res.moved} dirs moved, ${res.frontmatterRewrites} messages rewritten${res.warnings?.length ? `, ${res.warnings.length} warnings` : ''})`);
228
+ if (res.warnings?.length) for (const w of res.warnings) console.warn(`[yeaft] migration: ${w}`);
229
+ }
230
+ } catch (err) {
231
+ console.warn(`[yeaft] session migration failed (continuing): ${err?.message || err}`);
232
+ }
239
233
 
240
234
  return { dir: root, created, writable, warnings };
241
235
  }
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { approxTokens } from './budget.js';
31
- import { isVpForeign } from './store-v2.js';
31
+ import { isVpForeign } from './store.js';
32
32
 
33
33
  /**
34
34
  * @typedef {object} AdjustTriggerInput
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { approxTokens, packWithinBudget } from './budget.js';
20
- import { isVpForeign } from './store-v2.js';
20
+ import { isVpForeign } from './store.js';
21
21
 
22
22
  const RECENT_DEFAULT_CAPACITY = 64;
23
23
 
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { extractKeywords } from './keywords.js';
19
19
  import { approxTokens } from './budget.js';
20
- import { isVpForeign } from './store-v2.js';
20
+ import { isVpForeign } from './store.js';
21
21
 
22
22
  /**
23
23
  * @typedef {object} PreflowOptions
@@ -18,7 +18,7 @@
18
18
  * of truth for own-VP identity; surfacing the stub in Section 6 would
19
19
  * dup the same `# Name / Role` text. New seed paths (vp-crud.js,
20
20
  * group-crud.js, seed-default.js) use `seedSummaryIfMissingSync` from
21
- * store-v2.js to write directly to the correct scope dir.
21
+ * store.js to write directly to the correct scope dir.
22
22
  *
23
23
  * - `archiveLegacyScopes(root)`: one-shot migration that moves the truly
24
24
  * dead top-level `vp/`, `feature/`, `topic/` dirs to `.legacy/`. Per
@@ -1,5 +1,5 @@
1
1
  /**
2
- * memory/store-v2.js — per-scope memory.md + summary.md (Layer-A storage).
2
+ * memory/store.js — per-scope memory.md + summary.md (Layer-A storage).
3
3
  *
4
4
  * One pair of files per scope. No shards, no entries/, no index.md, no
5
5
  * index.json. The five scope kinds — user, vp, group, feature, topic — share
@@ -34,7 +34,7 @@
34
34
  * What this module deliberately does NOT do:
35
35
  * - No frontmatter parsing. memory.md and summary.md are pure markdown;
36
36
  * the dream-state metadata block lives at the file's tail and is read
37
- * by `dream-v2/state.js`, not here.
37
+ * by `dream/state.js`, not here.
38
38
  * - No LLM calls, no extraction, no summarisation. Pure I/O.
39
39
  *
40
40
  * Reference: agent/yeaft/memory/DESIGN-H2-AMS.md.
@@ -0,0 +1,485 @@
1
+ /**
2
+ * migrate/sessions.js — One-shot migration that consolidates legacy on-disk
3
+ * layouts into the current `sessions/`-based shape.
4
+ *
5
+ * Idempotent. Marks completion with `<yeaftDir>/.yeaft-migration.done`.
6
+ * Synchronous: callers (notably initYeaftDir) rely on the migration
7
+ * completing before the engine reads any conversation rows.
8
+ *
9
+ * Steps:
10
+ * 0. Snapshot conversation directories into
11
+ * `<yeaftDir>/.legacy-backup-<YYYYMMDD>/` so the in-place rewrites
12
+ * below have a recovery point. Skipped if today's backup exists.
13
+ * 1. ~/.yeaft/groups/<g>/ → ~/.yeaft/sessions/<g>/
14
+ * group.json → meta.json with shape:
15
+ * { id, vpIds: roster, displayName: name, workDir, createdAt,
16
+ * lastTurnAt: null, archivedAt? }
17
+ * 2. ~/.yeaft/chats/<c>/ → ~/.yeaft/sessions/<c>/
18
+ * chat.json → meta.json with vpIds: [vpId]
19
+ * 3. ~/.yeaft/memory/group/<g>/ → ~/.yeaft/memory/session/<g>/
20
+ * 4. ~/.yeaft/memory/chat/<c>/ → ~/.yeaft/memory/session/<c>/
21
+ * Rewrites front-matter `scope:` fields from group/<id> / chat/<id>
22
+ * → session/<id>.
23
+ * 5. ~/.yeaft/memory/groups/<g>/ams.json → ~/.yeaft/memory/sessions/<g>/ams.json
24
+ * 6. SQLite FTS index `memory_segments.scope` rows: rewrite group/* / chat/*
25
+ * → session/*.
26
+ * 7. Per-message frontmatter: rewrite `groupId: X` → `sessionId: X` in every
27
+ * `*.md` under `conversation/messages|cold/` and
28
+ * `sessions/<id>/conversation/messages|cold/`. This is what closes the
29
+ * loop on PR #881's rename — without it, the parser would have to carry
30
+ * a permanent `groupId` alias.
31
+ *
32
+ * Collision policy: if `sessions/<x>` already exists, log + skip (we assume
33
+ * a prior partial run already moved it). If both groups/<x> and chats/<x>
34
+ * exist with the same id, bail loudly — that shouldn't happen because
35
+ * group ids are `grp_*` and chat ids are `chat_*`.
36
+ */
37
+
38
+ import {
39
+ cpSync,
40
+ existsSync,
41
+ mkdirSync,
42
+ readdirSync,
43
+ readFileSync,
44
+ renameSync,
45
+ statSync,
46
+ unlinkSync,
47
+ writeFileSync,
48
+ } from 'fs';
49
+ import { join } from 'path';
50
+ import { openSegmentIndex } from '../memory/index-db.js';
51
+
52
+ const SENTINEL = '.yeaft-migration.done';
53
+ const SENTINEL_VERSION = 2;
54
+
55
+ /**
56
+ * Run the sessions migration. No-op when sentinel exists.
57
+ *
58
+ * Synchronous: the per-message frontmatter rewrite (step 7) MUST finish
59
+ * before any conversation row is loaded, otherwise the parser would drop
60
+ * legacy `groupId:` rows. Keeping this synchronous lets initYeaftDir stay
61
+ * synchronous too — its 5+ callers all expect that.
62
+ *
63
+ * @param {string} yeaftDir
64
+ * @returns {{ migrated: boolean, moved: number, frontmatterRewrites: number, warnings: string[] }}
65
+ */
66
+ export function migrateSessions(yeaftDir) {
67
+ const warnings = [];
68
+ if (!yeaftDir || !existsSync(yeaftDir)) {
69
+ return { migrated: false, moved: 0, frontmatterRewrites: 0, warnings: ['yeaftDir missing'] };
70
+ }
71
+ const sentinel = join(yeaftDir, SENTINEL);
72
+ if (existsSync(sentinel)) {
73
+ // Version-aware: ignore a sentinel from an older schema. Future v3
74
+ // migrations can run on v2-completed trees because they read what
75
+ // we wrote and decide for themselves whether to re-execute.
76
+ let v = 0;
77
+ try { v = JSON.parse(readFileSync(sentinel, 'utf8'))?.version ?? 0; } catch { /* corrupt sentinel — treat as 0 */ }
78
+ if (v >= SENTINEL_VERSION) {
79
+ return { migrated: false, moved: 0, frontmatterRewrites: 0, warnings: [] };
80
+ }
81
+ }
82
+
83
+ // Step 0 — backup conversation dirs before any in-place rewrite.
84
+ // Only operates on the conversation tree (the only thing step 7 touches
85
+ // in-place); directory renames in steps 1–5 don't need a backup because
86
+ // they're reversible by moving the directory back.
87
+ const backupRoot = join(yeaftDir, `.legacy-backup-${ymdLocal(new Date())}`);
88
+ backupConversationTrees(yeaftDir, backupRoot, warnings);
89
+
90
+ const sessionsRoot = join(yeaftDir, 'sessions');
91
+ const groupsRoot = join(yeaftDir, 'groups');
92
+ const chatsRoot = join(yeaftDir, 'chats');
93
+ const memoryRoot = join(yeaftDir, 'memory');
94
+ const memSessionRoot = join(memoryRoot, 'session');
95
+ const memSessionsAmsRoot = join(memoryRoot, 'sessions');
96
+
97
+ if (!existsSync(sessionsRoot)) mkdirSync(sessionsRoot, { recursive: true });
98
+ if (!existsSync(memSessionRoot)) mkdirSync(memSessionRoot, { recursive: true });
99
+ if (!existsSync(memSessionsAmsRoot)) mkdirSync(memSessionsAmsRoot, { recursive: true });
100
+
101
+ // ID collision check
102
+ const groupIds = listDirs(groupsRoot);
103
+ const chatIds = listDirs(chatsRoot);
104
+ const overlap = groupIds.filter((id) => chatIds.includes(id));
105
+ if (overlap.length > 0) {
106
+ throw new Error(`sessions migration: id collision between groups/ and chats/: ${overlap.join(',')}`);
107
+ }
108
+
109
+ let moved = 0;
110
+
111
+ // Reconcile any half-migrated sessions/<id>/ from a prior crash:
112
+ // a leftover group.json/chat.json with no meta.json means the rename
113
+ // succeeded but the rewrite didn't. Repair before moving on so the
114
+ // session is usable post-migration.
115
+ for (const id of listDirs(sessionsRoot)) {
116
+ const dst = join(sessionsRoot, id);
117
+ if (existsSync(join(dst, 'meta.json'))) continue;
118
+ if (existsSync(join(dst, 'group.json'))) {
119
+ rewriteGroupMetaToSessionMeta(dst, warnings);
120
+ warnings.push(`reconciled partial migration at sessions/${id}`);
121
+ } else if (existsSync(join(dst, 'chat.json'))) {
122
+ rewriteChatMetaToSessionMeta(dst, warnings);
123
+ warnings.push(`reconciled partial migration at sessions/${id}`);
124
+ }
125
+ }
126
+
127
+ // 1. groups/<g>/ → sessions/<g>/
128
+ for (const id of groupIds) {
129
+ const src = join(groupsRoot, id);
130
+ const dst = join(sessionsRoot, id);
131
+ if (existsSync(dst)) {
132
+ // Partial-run reconcile: if meta.json wasn't written yet, retry the
133
+ // rewrite from the leftover group.json at the destination. Avoids
134
+ // permanently broken sessions when the prior run crashed between
135
+ // renameSync and rewriteGroupMetaToSessionMeta.
136
+ if (!existsSync(join(dst, 'meta.json')) && existsSync(join(dst, 'group.json'))) {
137
+ rewriteGroupMetaToSessionMeta(dst, warnings);
138
+ warnings.push(`reconciled partial migration at sessions/${id}`);
139
+ } else {
140
+ warnings.push(`sessions/${id} already exists; skipping groups/${id}`);
141
+ }
142
+ continue;
143
+ }
144
+ renameSync(src, dst);
145
+ rewriteGroupMetaToSessionMeta(dst, warnings);
146
+ moved++;
147
+ }
148
+
149
+ // 2. chats/<c>/ → sessions/<c>/
150
+ for (const id of chatIds) {
151
+ const src = join(chatsRoot, id);
152
+ const dst = join(sessionsRoot, id);
153
+ if (existsSync(dst)) {
154
+ if (!existsSync(join(dst, 'meta.json')) && existsSync(join(dst, 'chat.json'))) {
155
+ rewriteChatMetaToSessionMeta(dst, warnings);
156
+ warnings.push(`reconciled partial migration at sessions/${id}`);
157
+ } else {
158
+ warnings.push(`sessions/${id} already exists; skipping chats/${id}`);
159
+ }
160
+ continue;
161
+ }
162
+ renameSync(src, dst);
163
+ rewriteChatMetaToSessionMeta(dst, warnings);
164
+ moved++;
165
+ }
166
+
167
+ // 3+4. memory/{group,chat}/<id>/ → memory/session/<id>/
168
+ for (const family of ['group', 'chat']) {
169
+ const root = join(memoryRoot, family);
170
+ if (!existsSync(root)) continue;
171
+ for (const id of listDirs(root)) {
172
+ const src = join(root, id);
173
+ const dst = join(memSessionRoot, id);
174
+ if (existsSync(dst)) {
175
+ warnings.push(`memory/session/${id} already exists; skipping memory/${family}/${id}`);
176
+ continue;
177
+ }
178
+ renameSync(src, dst);
179
+ rewriteSegmentScopes(dst, family, id, warnings);
180
+ }
181
+ }
182
+
183
+ // 5. memory/groups/<g>/ams.json → memory/sessions/<g>/ams.json
184
+ const amsGroupRoot = join(memoryRoot, 'groups');
185
+ if (existsSync(amsGroupRoot)) {
186
+ for (const id of listDirs(amsGroupRoot)) {
187
+ const srcDir = join(amsGroupRoot, id);
188
+ const dstDir = join(memSessionsAmsRoot, id);
189
+ if (existsSync(dstDir)) {
190
+ warnings.push(`memory/sessions/${id} already exists; skipping memory/groups/${id}`);
191
+ continue;
192
+ }
193
+ renameSync(srcDir, dstDir);
194
+ }
195
+ }
196
+ const amsChatRoot = join(memoryRoot, 'chats');
197
+ if (existsSync(amsChatRoot)) {
198
+ for (const id of listDirs(amsChatRoot)) {
199
+ const srcDir = join(amsChatRoot, id);
200
+ const dstDir = join(memSessionsAmsRoot, id);
201
+ if (existsSync(dstDir)) {
202
+ warnings.push(`memory/sessions/${id} already exists; skipping memory/chats/${id}`);
203
+ continue;
204
+ }
205
+ renameSync(srcDir, dstDir);
206
+ }
207
+ }
208
+
209
+ // 6. Rewrite SQLite FTS index scope strings via the shared index-db module
210
+ // (which already handles ABI loading). Idempotent: WHERE clause skips
211
+ // already-rewritten rows. Synchronous: better-sqlite3 has no async API.
212
+ rewriteFtsScopes(memoryRoot, warnings);
213
+
214
+ // 7. Per-message frontmatter rewrite. Walks both the legacy flat
215
+ // conversation directory and every per-session conversation directory
216
+ // so that any pre-rename row still on disk gets the new key shape.
217
+ const frontmatterRewrites = rewriteAllMessageFrontmatter(yeaftDir, warnings);
218
+
219
+ // 8. Sentinel — version 2 = this consolidated migration (covers what the
220
+ // old `.session-migration-v1.done` sentinel covered plus the frontmatter
221
+ // rewrite).
222
+ writeFileSync(sentinel, JSON.stringify({
223
+ version: SENTINEL_VERSION,
224
+ migratedAt: new Date().toISOString(),
225
+ moved,
226
+ frontmatterRewrites,
227
+ warnings,
228
+ }, null, 2), 'utf8');
229
+
230
+ return { migrated: true, moved, frontmatterRewrites, warnings };
231
+ }
232
+
233
+ /** YYYYMMDD in local time. */
234
+ function ymdLocal(d) {
235
+ const y = d.getFullYear();
236
+ const m = String(d.getMonth() + 1).padStart(2, '0');
237
+ const day = String(d.getDate()).padStart(2, '0');
238
+ return `${y}${m}${day}`;
239
+ }
240
+
241
+ /**
242
+ * Snapshot the conversation trees so step 7's in-place frontmatter rewrite
243
+ * is reversible. Only the dirs step 7 touches get backed up — directory
244
+ * renames in steps 1–5 are reversible by moving the directory back.
245
+ *
246
+ * Skips per-source-dir if the destination already exists (resume safety).
247
+ */
248
+ function backupConversationTrees(yeaftDir, backupRoot, warnings) {
249
+ const sources = [
250
+ join(yeaftDir, 'conversation'),
251
+ join(yeaftDir, 'chat'),
252
+ join(yeaftDir, 'sessions'),
253
+ // Step 1/2 move groups/<g>/ and chats/<c>/ into sessions/<x>/, and
254
+ // step 7 then rewrites *.md inside the moved trees. Without backing
255
+ // these up at step 0, a regex bug would silently destroy the only
256
+ // copy of pre-rename conversation messages.
257
+ join(yeaftDir, 'groups'),
258
+ join(yeaftDir, 'chats'),
259
+ ];
260
+ for (const src of sources) {
261
+ if (!existsSync(src)) continue;
262
+ const rel = src.slice(yeaftDir.length + 1);
263
+ const dst = join(backupRoot, rel);
264
+ if (existsSync(dst)) continue; // resume / second run today
265
+ try {
266
+ mkdirSync(dst, { recursive: true });
267
+ cpSync(src, dst, { recursive: true });
268
+ } catch (err) {
269
+ warnings.push(`backup ${src} → ${dst} failed: ${err.message}`);
270
+ }
271
+ }
272
+ }
273
+
274
+ function rewriteFtsScopes(memoryRoot, warnings) {
275
+ const dbPath = join(memoryRoot, 'index.db');
276
+ if (!existsSync(dbPath)) return;
277
+ // Best-effort: index-db.js is the single ABI-load site, so we route through
278
+ // it. Errors here are non-fatal — the FTS index can be rebuilt at runtime.
279
+ let idx;
280
+ try {
281
+ idx = openSegmentIndex(dbPath);
282
+ } catch (err) {
283
+ warnings.push(`FTS rewrite skipped: ${err.message}`);
284
+ return;
285
+ }
286
+ try {
287
+ const db = idx._db;
288
+ db.exec('BEGIN');
289
+ db.exec("UPDATE memory_segments SET scope = REPLACE(scope, 'group/', 'session/') WHERE scope LIKE 'group/%'");
290
+ db.exec("UPDATE memory_segments SET scope = REPLACE(scope, 'chat/', 'session/') WHERE scope LIKE 'chat/%'");
291
+ db.exec('COMMIT');
292
+ } catch (err) {
293
+ try { idx._db.exec('ROLLBACK'); } catch { /* ignore */ }
294
+ warnings.push(`FTS scope rewrite failed: ${err.message}`);
295
+ } finally {
296
+ try { idx.close(); } catch { /* ignore */ }
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Rewrite `groupId: X` → `sessionId: X` in every message frontmatter under
302
+ * the known conversation directories. Returns the number of files mutated.
303
+ *
304
+ * Idempotent: a second run finds nothing to rewrite and returns 0.
305
+ */
306
+ function rewriteAllMessageFrontmatter(yeaftDir, warnings) {
307
+ let count = 0;
308
+ const dirs = [];
309
+ // Legacy flat layout.
310
+ for (const sub of ['messages', 'cold']) {
311
+ const d = join(yeaftDir, 'conversation', sub);
312
+ if (existsSync(d)) dirs.push(d);
313
+ }
314
+ // Defensive: chat mode dir (empty in production today, but cheap to scan).
315
+ for (const sub of ['messages', 'cold']) {
316
+ const d = join(yeaftDir, 'chat', sub);
317
+ if (existsSync(d)) dirs.push(d);
318
+ }
319
+ // Per-session layout.
320
+ const sessionsRoot = join(yeaftDir, 'sessions');
321
+ if (existsSync(sessionsRoot)) {
322
+ for (const id of listDirs(sessionsRoot)) {
323
+ for (const sub of ['messages', 'cold']) {
324
+ const d = join(sessionsRoot, id, 'conversation', sub);
325
+ if (existsSync(d)) dirs.push(d);
326
+ }
327
+ }
328
+ }
329
+ for (const dir of dirs) {
330
+ count += rewriteFrontmatterInDir(dir, warnings);
331
+ }
332
+ return count;
333
+ }
334
+
335
+ /**
336
+ * Walk a single directory and rewrite any `.md` file whose YAML frontmatter
337
+ * carries `groupId:` but not `sessionId:`. If both keys are already present
338
+ * the redundant `groupId:` line is dropped. Atomic via tmp-then-rename.
339
+ *
340
+ * Returns the number of files mutated.
341
+ */
342
+ function rewriteFrontmatterInDir(dir, warnings) {
343
+ let mutated = 0;
344
+ let entries;
345
+ try { entries = readdirSync(dir); } catch { return 0; }
346
+ for (const name of entries) {
347
+ if (!name.endsWith('.md')) continue;
348
+ const path = join(dir, name);
349
+ try {
350
+ const raw = readFileSync(path, 'utf8');
351
+ // Frontmatter must open with `---` (LF or CRLF) for us to touch it.
352
+ // Skip the legacy human-readable conv-XXXX.md transcript files (no
353
+ // frontmatter) — they're informational dumps, not parser inputs.
354
+ const openMatch = raw.match(/^---\r?\n/);
355
+ if (!openMatch) continue;
356
+ const openLen = openMatch[0].length;
357
+ // Find the closing `\n---` (allow CRLF before the close too).
358
+ const endIdx = raw.search(/\r?\n---/);
359
+ if (endIdx === -1 || endIdx < openLen) continue;
360
+ const fmBody = raw.slice(openLen, endIdx);
361
+ const after = raw.slice(endIdx);
362
+ // Detect line ending used by the file so we round-trip it.
363
+ const eol = raw.slice(0, openLen).includes('\r\n') ? '\r\n' : '\n';
364
+ const lines = fmBody.split(/\r?\n/);
365
+ const groupIdLineIdx = lines.findIndex((l) => /^groupId:\s*/.test(l));
366
+ if (groupIdLineIdx === -1) continue;
367
+ const hasSessionId = lines.some((l) => /^sessionId:\s*\S/.test(l));
368
+ const m = lines[groupIdLineIdx].match(/^groupId:\s*(.*?)\s*$/);
369
+ const value = m ? m[1] : '';
370
+ if (!hasSessionId && !value) {
371
+ // Pathological: `groupId:` with no value AND no existing sessionId.
372
+ // Don't manufacture `sessionId: ` — that would silently mint a
373
+ // useless row. Leave the file alone and log it so we can audit.
374
+ warnings.push(`frontmatter rewrite skipped (empty groupId, no sessionId): ${path}`);
375
+ continue;
376
+ }
377
+ let newLines;
378
+ if (hasSessionId) {
379
+ // Redundant — drop the groupId line.
380
+ newLines = lines.filter((_, i) => i !== groupIdLineIdx);
381
+ } else {
382
+ // Replace in place to preserve frontmatter ordering.
383
+ newLines = lines.slice();
384
+ newLines[groupIdLineIdx] = `sessionId: ${value}`;
385
+ }
386
+ const next = `---${eol}${newLines.join(eol)}${after}`;
387
+ // Collision-safe tmp name: pid + counter. Parallel migration runs
388
+ // shouldn't happen (sentinel gates everything) but the atomic-rename
389
+ // contract is cheap insurance.
390
+ const tmp = `${path}.tmp.${process.pid}`;
391
+ writeFileSync(tmp, next, 'utf8');
392
+ renameSync(tmp, path);
393
+ mutated++;
394
+ } catch (err) {
395
+ warnings.push(`frontmatter rewrite failed ${path}: ${err.message}`);
396
+ }
397
+ }
398
+ return mutated;
399
+ }
400
+
401
+ function listDirs(root) {
402
+ if (!root || !existsSync(root)) return [];
403
+ const out = [];
404
+ for (const name of readdirSync(root)) {
405
+ if (name.startsWith('.')) continue;
406
+ try {
407
+ if (statSync(join(root, name)).isDirectory()) out.push(name);
408
+ } catch { /* ignore */ }
409
+ }
410
+ return out;
411
+ }
412
+
413
+ function rewriteGroupMetaToSessionMeta(sessionDir, warnings) {
414
+ const oldPath = join(sessionDir, 'group.json');
415
+ const newPath = join(sessionDir, 'meta.json');
416
+ if (!existsSync(oldPath)) {
417
+ if (!existsSync(newPath)) warnings.push(`no group.json in ${sessionDir}`);
418
+ return;
419
+ }
420
+ try {
421
+ const raw = JSON.parse(readFileSync(oldPath, 'utf8'));
422
+ const meta = {
423
+ id: raw.id,
424
+ displayName: raw.name || raw.id,
425
+ vpIds: Array.isArray(raw.roster) && raw.roster.length > 0 ? raw.roster.slice() : ['omni'],
426
+ // Preserve defaultVpId so Phase 2 coordinator can resolve "which VP
427
+ // answers when no @-mention". Easy to keep now, hard to backfill later.
428
+ ...(raw.defaultVpId ? { defaultVpId: raw.defaultVpId } : {}),
429
+ workDir: typeof raw.workDir === 'string' ? raw.workDir : '',
430
+ createdAt: raw.createdAt || new Date().toISOString(),
431
+ lastTurnAt: null,
432
+ };
433
+ writeFileSync(newPath, JSON.stringify(meta, null, 2), 'utf8');
434
+ try { unlinkSync(oldPath); } catch { /* keep both if cannot delete */ }
435
+ } catch (err) {
436
+ warnings.push(`failed to rewrite ${oldPath}: ${err.message}`);
437
+ }
438
+ }
439
+
440
+ function rewriteChatMetaToSessionMeta(sessionDir, warnings) {
441
+ const oldPath = join(sessionDir, 'chat.json');
442
+ const newPath = join(sessionDir, 'meta.json');
443
+ if (!existsSync(oldPath)) {
444
+ if (!existsSync(newPath)) warnings.push(`no chat.json in ${sessionDir}`);
445
+ return;
446
+ }
447
+ try {
448
+ const raw = JSON.parse(readFileSync(oldPath, 'utf8'));
449
+ const meta = {
450
+ id: raw.id,
451
+ displayName: raw.displayName || raw.id,
452
+ vpIds: [raw.vpId || 'omni'],
453
+ workDir: typeof raw.workDir === 'string' ? raw.workDir : '',
454
+ createdAt: raw.createdAt || new Date().toISOString(),
455
+ lastTurnAt: raw.lastTurnAt || null,
456
+ };
457
+ writeFileSync(newPath, JSON.stringify(meta, null, 2), 'utf8');
458
+ try { unlinkSync(oldPath); } catch { /* */ }
459
+ } catch (err) {
460
+ warnings.push(`failed to rewrite ${oldPath}: ${err.message}`);
461
+ }
462
+ }
463
+
464
+ function rewriteSegmentScopes(sessionMemoryDir, oldFamily, id, warnings) {
465
+ const segDir = join(sessionMemoryDir, 'segments');
466
+ if (!existsSync(segDir)) return;
467
+ let entries;
468
+ try { entries = readdirSync(segDir); } catch { return; }
469
+ const re = new RegExp(`^(\\s*scope:\\s*)${oldFamily}/${escapeRe(id)}\\b`, 'gm');
470
+ for (const name of entries) {
471
+ if (!name.endsWith('.md')) continue;
472
+ const path = join(segDir, name);
473
+ try {
474
+ const src = readFileSync(path, 'utf8');
475
+ const next = src.replace(re, `$1session/${id}`);
476
+ if (next !== src) writeFileSync(path, next, 'utf8');
477
+ } catch (err) {
478
+ warnings.push(`segment rewrite failed ${path}: ${err.message}`);
479
+ }
480
+ }
481
+ }
482
+
483
+ function escapeRe(s) {
484
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
485
+ }
package/yeaft/session.js CHANGED
@@ -47,7 +47,7 @@ import { ensureDefaultSessionIfEmpty } from './sessions/session-crud.js';
47
47
  import { seedDefaultVps } from './vp/seed-defaults.js';
48
48
  import { topUpDefaultVps } from './vp/seed-topup.js';
49
49
  import { archiveLegacyScopes } from './memory/seed-backfill.js';
50
- import { createV2DreamScheduler, bootInitEmptyGroups, bootCatchUpStaleDream } from './dream-v2/session-wiring.js';
50
+ import { createV2DreamScheduler, bootInitEmptyGroups, bootCatchUpStaleDream } from './dream/session-wiring.js';
51
51
  import { openSegmentIndex } from './memory/index-db.js';
52
52
  import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
53
53
  import { openAmsRegistry } from './memory/ams-registry.js';
@@ -204,7 +204,7 @@ export async function loadSession(options = {}) {
204
204
 
205
205
  // ─── 2.2 R6 → v2 auto-migration retired ───────────────
206
206
  // The R6 shard layout is gone — memory writes go through
207
- // dream-v2 directly. Existing users have already migrated
207
+ // dream directly. Existing users have already migrated
208
208
  // (state file in ~/.yeaft/.memory-v2-migration.json).
209
209
 
210
210
  // ─── 2a. Permission pre-check ─────────────────────────
@@ -362,7 +362,7 @@ export async function loadSession(options = {}) {
362
362
  // — kind:'group-vp'). The backfill therefore generated orphan files
363
363
  // on every boot. See `memory/seed-backfill.js` for the historical
364
364
  // context. Real seeding happens at create time via
365
- // `seedSummaryIfMissingSync` from `store-v2.js`, called by vp-crud /
365
+ // `seedSummaryIfMissingSync` from `store.js`, called by vp-crud /
366
366
  // group-crud / seed-default — those write to the correct scope dirs.
367
367
  }
368
368
 
@@ -468,7 +468,7 @@ export async function loadSession(options = {}) {
468
468
 
469
469
  // ─── 9a. Create dream scheduler ────────────
470
470
  // The legacy R6 dream-scheduler was retired alongside recall-r6;
471
- // dream-v2 is the only active path (the `config.memoryV2` opt-out
471
+ // dream is the only active path (the `config.memoryV2` opt-out
472
472
  // flag was retired in task-710 — wiring is unconditional).
473
473
  // partialSession lets the v2 scheduler dereference adapter/config/
474
474
  // engine/trace lazily — safe because callers attach more fields
@@ -16,7 +16,7 @@ import { existsSync, mkdirSync } from 'fs';
16
16
  import { join } from 'path';
17
17
  import { homedir } from 'os';
18
18
  import { openSession, createSession, loadSessionMeta } from './session-store.js';
19
- import { seedSummaryIfMissingSync } from '../memory/store-v2.js';
19
+ import { seedSummaryIfMissingSync } from '../memory/store.js';
20
20
 
21
21
  export const DEFAULT_SESSION_ID = 'grp_default';
22
22
 
@@ -52,7 +52,7 @@ import { addVp as rosterAdd, removeVp as rosterRemove, setDefaultVp } from './ro
52
52
  import { seedDefaultSession, DEFAULT_SESSION_ID } from './seed-default.js';
53
53
  import { nextSessionId, validateVpId, isReservedVpId } from './ids.js';
54
54
  import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
55
- import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store-v2.js';
55
+ import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store.js';
56
56
  import { ensureSessionConfigFile, saveSessionConfig, loadSessionConfig } from './session-config.js';
57
57
 
58
58
  /**
@@ -9,7 +9,7 @@
9
9
  * entries-store extraction was retired in the H2-AMS rip.
10
10
  *
11
11
  * Dream V2 owns all background memory maintenance (scope summaries +
12
- * memory writes via dream-v2/session-wiring.js → createV2DreamScheduler).
12
+ * memory writes via dream/session-wiring.js → createV2DreamScheduler).
13
13
  *
14
14
  * Reference: yeaft-yeaft-core-systems.md §4.4
15
15
  */
@@ -29,7 +29,7 @@ import { STOCK_VP_IDS } from './stock-ids.js';
29
29
  /**
30
30
  * Default memory root used when callers don't pass `options.memoryRoot`.
31
31
  * Memory lives at `<root>/vp/<id>/{summary.md,memory.md,segments/…}` —
32
- * see `store-v2.scopeDir`. Production sites should thread the configured
32
+ * see `store.scopeDir`. Production sites should thread the configured
33
33
  * `<yeaftDir>/memory` through `options.memoryRoot` so a non-default yeaft
34
34
  * directory (e.g. tests, sandboxed CI) doesn't write under `~/.yeaft/`.
35
35
  */
@@ -3519,7 +3519,7 @@ export async function handleYeaftDreamTrigger(msg = {}) {
3519
3519
  // otherwise auto-run events can be persisted under the clicked group.
3520
3520
  // The scheduler also short-circuits the underlying run for same-group,
3521
3521
  // and a different group's filter would have been silently dropped
3522
- // anyway (see dream-v2/schedule.js inflight reuse), so the user-facing
3522
+ // anyway (see dream/schedule.js inflight reuse), so the user-facing
3523
3523
  // semantics are unchanged ("you already asked").
3524
3524
  if (sessionId && (inflightScopedDreamGroups.size > 0 || session.dreamScheduler.isRunning)) {
3525
3525
  const skippedResult = {
@@ -1,295 +0,0 @@
1
- /**
2
- * migrate/sessions-v1.js — One-shot migration: collapse groups/ + chats/ → sessions/
3
- *
4
- * Idempotent. Marks completion with a sentinel file
5
- * `<yeaftDir>/.session-migration-v1.done`.
6
- *
7
- * Migrates:
8
- * 1. ~/.yeaft/groups/<g>/ → ~/.yeaft/sessions/<g>/
9
- * group.json → meta.json with shape:
10
- * { id, vpIds: roster, displayName: name, workDir, createdAt,
11
- * lastTurnAt: null, archivedAt? }
12
- * 2. ~/.yeaft/chats/<c>/ → ~/.yeaft/sessions/<c>/
13
- * chat.json → meta.json with vpIds: [vpId]
14
- * 3. ~/.yeaft/memory/group/<g>/ → ~/.yeaft/memory/session/<g>/
15
- * 4. ~/.yeaft/memory/chat/<c>/ → ~/.yeaft/memory/session/<c>/
16
- * Rewrites front-matter `scope:` fields from group/<id> / chat/<id>
17
- * → session/<id>.
18
- * 5. ~/.yeaft/memory/groups/<g>/ams.json → ~/.yeaft/memory/sessions/<g>/ams.json
19
- *
20
- * Collision policy: if `sessions/<x>` already exists, log + skip (we assume
21
- * a prior partial run already moved it). If both groups/<x> and chats/<x>
22
- * exist with the same id, bail loudly — that shouldn't happen because
23
- * group ids are `grp_*` and chat ids are `chat_*`.
24
- */
25
-
26
- import {
27
- existsSync,
28
- mkdirSync,
29
- readdirSync,
30
- readFileSync,
31
- renameSync,
32
- statSync,
33
- unlinkSync,
34
- writeFileSync,
35
- } from 'fs';
36
- import { join } from 'path';
37
-
38
- const SENTINEL = '.session-migration-v1.done';
39
-
40
- /**
41
- * Run the v1 sessions migration. No-op when sentinel exists.
42
- *
43
- * @param {string} yeaftDir
44
- * @returns {{ migrated: boolean, moved: number, warnings: string[] }}
45
- */
46
- export async function migrateSessionsV1(yeaftDir) {
47
- const warnings = [];
48
- if (!yeaftDir || !existsSync(yeaftDir)) {
49
- return { migrated: false, moved: 0, warnings: ['yeaftDir missing'] };
50
- }
51
- const sentinel = join(yeaftDir, SENTINEL);
52
- if (existsSync(sentinel)) {
53
- return { migrated: false, moved: 0, warnings: [] };
54
- }
55
-
56
- const sessionsRoot = join(yeaftDir, 'sessions');
57
- const groupsRoot = join(yeaftDir, 'groups');
58
- const chatsRoot = join(yeaftDir, 'chats');
59
- const memoryRoot = join(yeaftDir, 'memory');
60
- const memSessionRoot = join(memoryRoot, 'session');
61
- const memSessionsAmsRoot = join(memoryRoot, 'sessions');
62
-
63
- if (!existsSync(sessionsRoot)) mkdirSync(sessionsRoot, { recursive: true });
64
- if (!existsSync(memSessionRoot)) mkdirSync(memSessionRoot, { recursive: true });
65
- if (!existsSync(memSessionsAmsRoot)) mkdirSync(memSessionsAmsRoot, { recursive: true });
66
-
67
- // ID collision check
68
- const groupIds = listDirs(groupsRoot);
69
- const chatIds = listDirs(chatsRoot);
70
- const overlap = groupIds.filter((id) => chatIds.includes(id));
71
- if (overlap.length > 0) {
72
- throw new Error(`sessions migration: id collision between groups/ and chats/: ${overlap.join(',')}`);
73
- }
74
-
75
- let moved = 0;
76
-
77
- // 0. Reconcile any half-migrated sessions/<id>/ from a prior crash:
78
- // a leftover group.json/chat.json with no meta.json means the rename
79
- // succeeded but the rewrite didn't. Repair before moving on so the
80
- // session is usable post-migration.
81
- for (const id of listDirs(sessionsRoot)) {
82
- const dst = join(sessionsRoot, id);
83
- if (existsSync(join(dst, 'meta.json'))) continue;
84
- if (existsSync(join(dst, 'group.json'))) {
85
- rewriteGroupMetaToSessionMeta(dst, warnings);
86
- warnings.push(`reconciled partial migration at sessions/${id}`);
87
- } else if (existsSync(join(dst, 'chat.json'))) {
88
- rewriteChatMetaToSessionMeta(dst, warnings);
89
- warnings.push(`reconciled partial migration at sessions/${id}`);
90
- }
91
- }
92
-
93
- // 1. groups/<g>/ → sessions/<g>/
94
- for (const id of groupIds) {
95
- const src = join(groupsRoot, id);
96
- const dst = join(sessionsRoot, id);
97
- if (existsSync(dst)) {
98
- // Partial-run reconcile: if meta.json wasn't written yet, retry the
99
- // rewrite from the leftover group.json at the destination. Avoids
100
- // permanently broken sessions when the prior run crashed between
101
- // renameSync and rewriteGroupMetaToSessionMeta.
102
- if (!existsSync(join(dst, 'meta.json')) && existsSync(join(dst, 'group.json'))) {
103
- rewriteGroupMetaToSessionMeta(dst, warnings);
104
- warnings.push(`reconciled partial migration at sessions/${id}`);
105
- } else {
106
- warnings.push(`sessions/${id} already exists; skipping groups/${id}`);
107
- }
108
- continue;
109
- }
110
- renameSync(src, dst);
111
- rewriteGroupMetaToSessionMeta(dst, warnings);
112
- moved++;
113
- }
114
-
115
- // 2. chats/<c>/ → sessions/<c>/
116
- for (const id of chatIds) {
117
- const src = join(chatsRoot, id);
118
- const dst = join(sessionsRoot, id);
119
- if (existsSync(dst)) {
120
- if (!existsSync(join(dst, 'meta.json')) && existsSync(join(dst, 'chat.json'))) {
121
- rewriteChatMetaToSessionMeta(dst, warnings);
122
- warnings.push(`reconciled partial migration at sessions/${id}`);
123
- } else {
124
- warnings.push(`sessions/${id} already exists; skipping chats/${id}`);
125
- }
126
- continue;
127
- }
128
- renameSync(src, dst);
129
- rewriteChatMetaToSessionMeta(dst, warnings);
130
- moved++;
131
- }
132
-
133
- // 3+4. memory/{group,chat}/<id>/ → memory/session/<id>/
134
- for (const family of ['group', 'chat']) {
135
- const root = join(memoryRoot, family);
136
- if (!existsSync(root)) continue;
137
- for (const id of listDirs(root)) {
138
- const src = join(root, id);
139
- const dst = join(memSessionRoot, id);
140
- if (existsSync(dst)) {
141
- warnings.push(`memory/session/${id} already exists; skipping memory/${family}/${id}`);
142
- continue;
143
- }
144
- renameSync(src, dst);
145
- rewriteSegmentScopes(dst, family, id, warnings);
146
- }
147
- }
148
-
149
- // 5. memory/groups/<g>/ams.json → memory/sessions/<g>/ams.json
150
- const amsGroupRoot = join(memoryRoot, 'groups');
151
- if (existsSync(amsGroupRoot)) {
152
- for (const id of listDirs(amsGroupRoot)) {
153
- const srcDir = join(amsGroupRoot, id);
154
- const dstDir = join(memSessionsAmsRoot, id);
155
- if (existsSync(dstDir)) {
156
- warnings.push(`memory/sessions/${id} already exists; skipping memory/groups/${id}`);
157
- continue;
158
- }
159
- renameSync(srcDir, dstDir);
160
- }
161
- }
162
- const amsChatRoot = join(memoryRoot, 'chats');
163
- if (existsSync(amsChatRoot)) {
164
- for (const id of listDirs(amsChatRoot)) {
165
- const srcDir = join(amsChatRoot, id);
166
- const dstDir = join(memSessionsAmsRoot, id);
167
- if (existsSync(dstDir)) {
168
- warnings.push(`memory/sessions/${id} already exists; skipping memory/chats/${id}`);
169
- continue;
170
- }
171
- renameSync(srcDir, dstDir);
172
- }
173
- }
174
-
175
- // 6. Rewrite SQLite FTS index scope strings via the shared index-db
176
- // module (which already handles ABI loading). Idempotent: WHERE clause
177
- // skips already-rewritten rows.
178
- try {
179
- const dbPath = join(memoryRoot, 'index.db');
180
- if (existsSync(dbPath)) {
181
- const { openSegmentIndex } = await import('../memory/index-db.js');
182
- const idx = openSegmentIndex(dbPath);
183
- try {
184
- const db = idx._db;
185
- db.exec("BEGIN");
186
- db.exec("UPDATE memory_segments SET scope = REPLACE(scope, 'group/', 'session/') WHERE scope LIKE 'group/%'");
187
- db.exec("UPDATE memory_segments SET scope = REPLACE(scope, 'chat/', 'session/') WHERE scope LIKE 'chat/%'");
188
- db.exec("COMMIT");
189
- } catch (err) {
190
- try { idx._db.exec("ROLLBACK"); } catch { /* ignore */ }
191
- warnings.push(`FTS scope rewrite failed: ${err.message}`);
192
- } finally {
193
- try { idx.close(); } catch { /* ignore */ }
194
- }
195
- }
196
- } catch (err) {
197
- warnings.push(`FTS rewrite skipped: ${err.message}`);
198
- }
199
-
200
- // 7. sentinel
201
- writeFileSync(sentinel, JSON.stringify({
202
- version: 1,
203
- migratedAt: new Date().toISOString(),
204
- moved,
205
- warnings,
206
- }, null, 2), 'utf8');
207
-
208
- return { migrated: true, moved, warnings };
209
- }
210
-
211
- function listDirs(root) {
212
- if (!root || !existsSync(root)) return [];
213
- const out = [];
214
- for (const name of readdirSync(root)) {
215
- if (name.startsWith('.')) continue;
216
- try {
217
- if (statSync(join(root, name)).isDirectory()) out.push(name);
218
- } catch { /* ignore */ }
219
- }
220
- return out;
221
- }
222
-
223
- function rewriteGroupMetaToSessionMeta(sessionDir, warnings) {
224
- const oldPath = join(sessionDir, 'group.json');
225
- const newPath = join(sessionDir, 'meta.json');
226
- if (!existsSync(oldPath)) {
227
- if (!existsSync(newPath)) warnings.push(`no group.json in ${sessionDir}`);
228
- return;
229
- }
230
- try {
231
- const raw = JSON.parse(readFileSync(oldPath, 'utf8'));
232
- const meta = {
233
- id: raw.id,
234
- displayName: raw.name || raw.id,
235
- vpIds: Array.isArray(raw.roster) && raw.roster.length > 0 ? raw.roster.slice() : ['omni'],
236
- // Preserve defaultVpId so Phase 2 coordinator can resolve "which VP
237
- // answers when no @-mention". Easy to keep now, hard to backfill later.
238
- ...(raw.defaultVpId ? { defaultVpId: raw.defaultVpId } : {}),
239
- workDir: typeof raw.workDir === 'string' ? raw.workDir : '',
240
- createdAt: raw.createdAt || new Date().toISOString(),
241
- lastTurnAt: null,
242
- };
243
- writeFileSync(newPath, JSON.stringify(meta, null, 2), 'utf8');
244
- try { unlinkSync(oldPath); } catch { /* keep both if cannot delete */ }
245
- } catch (err) {
246
- warnings.push(`failed to rewrite ${oldPath}: ${err.message}`);
247
- }
248
- }
249
-
250
- function rewriteChatMetaToSessionMeta(sessionDir, warnings) {
251
- const oldPath = join(sessionDir, 'chat.json');
252
- const newPath = join(sessionDir, 'meta.json');
253
- if (!existsSync(oldPath)) {
254
- if (!existsSync(newPath)) warnings.push(`no chat.json in ${sessionDir}`);
255
- return;
256
- }
257
- try {
258
- const raw = JSON.parse(readFileSync(oldPath, 'utf8'));
259
- const meta = {
260
- id: raw.id,
261
- displayName: raw.displayName || raw.id,
262
- vpIds: [raw.vpId || 'omni'],
263
- workDir: typeof raw.workDir === 'string' ? raw.workDir : '',
264
- createdAt: raw.createdAt || new Date().toISOString(),
265
- lastTurnAt: raw.lastTurnAt || null,
266
- };
267
- writeFileSync(newPath, JSON.stringify(meta, null, 2), 'utf8');
268
- try { unlinkSync(oldPath); } catch { /* */ }
269
- } catch (err) {
270
- warnings.push(`failed to rewrite ${oldPath}: ${err.message}`);
271
- }
272
- }
273
-
274
- function rewriteSegmentScopes(sessionMemoryDir, oldFamily, id, warnings) {
275
- const segDir = join(sessionMemoryDir, 'segments');
276
- if (!existsSync(segDir)) return;
277
- let entries;
278
- try { entries = readdirSync(segDir); } catch { return; }
279
- const re = new RegExp(`^(\\s*scope:\\s*)${oldFamily}/${escapeRe(id)}\\b`, 'gm');
280
- for (const name of entries) {
281
- if (!name.endsWith('.md')) continue;
282
- const path = join(segDir, name);
283
- try {
284
- const src = readFileSync(path, 'utf8');
285
- const next = src.replace(re, `$1session/${id}`);
286
- if (next !== src) writeFileSync(path, next, 'utf8');
287
- } catch (err) {
288
- warnings.push(`segment rewrite failed ${path}: ${err.message}`);
289
- }
290
- }
291
- }
292
-
293
- function escapeRe(s) {
294
- return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
295
- }
File without changes
File without changes
File without changes