@yeaft/webchat-agent 0.1.520 → 0.1.521

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.520",
3
+ "version": "0.1.521",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,176 @@
1
+ /**
2
+ * detect.js — Scan a legacy Yeaft directory to determine migration scope.
3
+ *
4
+ * Spec: .crew/context/task-334i-migration-spec.md §M1
5
+ *
6
+ * Returns a report describing which generations of the old layout are
7
+ * present, whether the new tree already exists, and which files the
8
+ * migration will touch.
9
+ *
10
+ * const report = detect(yeaftDir);
11
+ * // {
12
+ * // yeaftDir, empty,
13
+ * // hasGen1, hasGen2, hasNewTree,
14
+ * // paths: {
15
+ * // messages: [...],
16
+ * // cold: [...],
17
+ * // memoryEntries: [...],
18
+ * // threads: [...],
19
+ * // taskDirs: [{ id, dir, meta, coordinator }],
20
+ * // userPreferences: string | null,
21
+ * // memoryAggregate: string | null,
22
+ * // scopes: string | null,
23
+ * // },
24
+ * // counts: { messages, cold, memoryEntries, threads, tasks },
25
+ * // }
26
+ */
27
+
28
+ import { existsSync, readdirSync, statSync } from 'fs';
29
+ import { join } from 'path';
30
+
31
+ const MESSAGE_RE = /^m\d+\.md$/;
32
+ const CONV_META_RE = /^conv-\d+\.md$/;
33
+ const TASK_DIR_RE = /^task-[A-Za-z0-9_\-]+$/;
34
+
35
+ function safeListDir(dir) {
36
+ try {
37
+ if (!existsSync(dir)) return [];
38
+ return readdirSync(dir);
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ function isFile(path) {
45
+ try {
46
+ return statSync(path).isFile();
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ function isDir(path) {
53
+ try {
54
+ return statSync(path).isDirectory();
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Scan yeaftDir and return the migration report.
62
+ */
63
+ export function detect(yeaftDir) {
64
+ const paths = {
65
+ messages: [],
66
+ cold: [],
67
+ memoryEntries: [],
68
+ threads: [],
69
+ taskDirs: [],
70
+ userPreferences: null,
71
+ memoryAggregate: null,
72
+ scopes: null,
73
+ conversationIndex: null,
74
+ conversationCompact: null,
75
+ threadsIndex: null,
76
+ tasksIndex: null,
77
+ tasksPlan: null,
78
+ };
79
+
80
+ // ─── Gen-1: conversation/ ────────────────────────────
81
+ const convMessagesDir = join(yeaftDir, 'conversation', 'messages');
82
+ for (const name of safeListDir(convMessagesDir)) {
83
+ if (MESSAGE_RE.test(name)) {
84
+ paths.messages.push(join(convMessagesDir, name));
85
+ }
86
+ // conv-NNNN.md are meta files we ignore (spec §M1.1 comment: "首行 md,不是消息")
87
+ }
88
+ paths.messages.sort();
89
+
90
+ const convColdDir = join(yeaftDir, 'conversation', 'cold');
91
+ for (const name of safeListDir(convColdDir)) {
92
+ if (MESSAGE_RE.test(name)) paths.cold.push(join(convColdDir, name));
93
+ }
94
+ paths.cold.sort();
95
+
96
+ const convIndex = join(yeaftDir, 'conversation', 'index.md');
97
+ if (isFile(convIndex)) paths.conversationIndex = convIndex;
98
+ const convCompact = join(yeaftDir, 'conversation', 'compact.md');
99
+ if (isFile(convCompact)) paths.conversationCompact = convCompact;
100
+
101
+ // ─── Gen-1: memory/ ────────────────────────────
102
+ const memEntriesDir = join(yeaftDir, 'memory', 'entries');
103
+ for (const name of safeListDir(memEntriesDir)) {
104
+ if (name.endsWith('.md')) paths.memoryEntries.push(join(memEntriesDir, name));
105
+ }
106
+ paths.memoryEntries.sort();
107
+
108
+ const memAggregate = join(yeaftDir, 'memory', 'MEMORY.md');
109
+ if (isFile(memAggregate)) paths.memoryAggregate = memAggregate;
110
+ const userPrefs = join(yeaftDir, 'memory', 'user-preferences.md');
111
+ if (isFile(userPrefs)) paths.userPreferences = userPrefs;
112
+ const scopes = join(yeaftDir, 'memory', 'scopes.md');
113
+ if (isFile(scopes)) paths.scopes = scopes;
114
+
115
+ // ─── Gen-2: threads/ ────────────────────────────
116
+ const threadsDir = join(yeaftDir, 'threads');
117
+ for (const name of safeListDir(threadsDir)) {
118
+ if (name === 'index.md') {
119
+ paths.threadsIndex = join(threadsDir, name);
120
+ continue;
121
+ }
122
+ if (name.endsWith('.md')) paths.threads.push(join(threadsDir, name));
123
+ }
124
+ paths.threads.sort();
125
+
126
+ // ─── tasks/ (shared Gen-1/2) ────────────────────────────
127
+ const tasksDir = join(yeaftDir, 'tasks');
128
+ for (const name of safeListDir(tasksDir)) {
129
+ const full = join(tasksDir, name);
130
+ if (name === 'index.md' && isFile(full)) { paths.tasksIndex = full; continue; }
131
+ if (name === 'plan.md' && isFile(full)) { paths.tasksPlan = full; continue; }
132
+ if (!TASK_DIR_RE.test(name) || !isDir(full)) continue;
133
+ const meta = join(full, 'meta.md');
134
+ const coordinator = join(full, 'coordinator.md');
135
+ paths.taskDirs.push({
136
+ id: name.replace(/^task-/, ''),
137
+ dir: full,
138
+ meta: isFile(meta) ? meta : null,
139
+ coordinator: isFile(coordinator) ? coordinator : null,
140
+ });
141
+ }
142
+ paths.taskDirs.sort((a, b) => a.id.localeCompare(b.id));
143
+
144
+ const hasGen1 = paths.messages.length > 0 || paths.memoryEntries.length > 0;
145
+ const hasGen2 = paths.threads.length > 0 || paths.threadsIndex !== null;
146
+
147
+ // ─── New tree already present? ────────────────────────────
148
+ const hasNewTree =
149
+ isDir(join(yeaftDir, 'groups')) ||
150
+ isDir(join(yeaftDir, 'virtual-persons')) ||
151
+ isDir(join(yeaftDir, 'user', 'memory'));
152
+
153
+ const counts = {
154
+ messages: paths.messages.length,
155
+ cold: paths.cold.length,
156
+ memoryEntries: paths.memoryEntries.length,
157
+ threads: paths.threads.length,
158
+ tasks: paths.taskDirs.length,
159
+ };
160
+
161
+ const empty =
162
+ !hasGen1 && !hasGen2 && !hasNewTree &&
163
+ paths.taskDirs.length === 0 &&
164
+ !paths.conversationIndex && !paths.conversationCompact &&
165
+ !paths.memoryAggregate && !paths.userPreferences;
166
+
167
+ return {
168
+ yeaftDir,
169
+ empty,
170
+ hasGen1,
171
+ hasGen2,
172
+ hasNewTree,
173
+ paths,
174
+ counts,
175
+ };
176
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * map-fields.js — Pure mapping functions for task-334i migration.
3
+ *
4
+ * Spec: .crew/context/task-334i-migration-spec.md §M2
5
+ *
6
+ * Converts old `~/.yeaft/` markdown frontmatter shapes into the shapes
7
+ * consumed by the 334o storage primitives (JSONL log rows for messages,
8
+ * shard-store entries for memory, JSON for task metadata).
9
+ *
10
+ * All functions here are pure: no fs, no Date.now(), no randomness
11
+ * (the caller supplies IDs + timestamps).
12
+ *
13
+ * Exported:
14
+ * - parseFrontmatter(raw) → { meta, body }
15
+ * - mapMessageMdToJsonl({ meta, body, originalId, fallbackTaskId }) → row
16
+ * - mapMemoryEntry({ meta, body, now }) → { shard, entry }
17
+ * - mapTaskMeta({ meta, taskId }) → task.json
18
+ * - splitCoordinatorTurns(raw) → [ { id, role, ts, body } ]
19
+ * - shardForMemoryKind(kind) → shard name
20
+ * - LEGACY_GROUP_ID, LEGACY_VP_ID — constants
21
+ */
22
+
23
+ export const LEGACY_GROUP_ID = 'legacy-main';
24
+ export const LEGACY_VP_ID = 'unify-legacy';
25
+ export const MIGRATION_AUTHOR = 'system:migration-v0-to-v1';
26
+
27
+ const MEMORY_SHARD_BY_KIND = {
28
+ skill: 'skill',
29
+ preference: 'preferences',
30
+ relation: 'relations',
31
+ lesson: 'lessons',
32
+ };
33
+ const MEMORY_DEFAULT_SHARD = 'project-legacy';
34
+
35
+ /**
36
+ * Parse YAML-ish frontmatter from a markdown string.
37
+ *
38
+ * Input:
39
+ * ---
40
+ * key: value
41
+ * list: [a, b]
42
+ * ---
43
+ * body text...
44
+ *
45
+ * Returns { meta: {}, body: string }. On malformed input, returns
46
+ * { meta: null, body: raw }.
47
+ */
48
+ export function parseFrontmatter(raw) {
49
+ if (typeof raw !== 'string') return { meta: null, body: '' };
50
+ const trimmed = raw.replace(/^\uFEFF/, '');
51
+ if (!trimmed.startsWith('---')) return { meta: null, body: trimmed };
52
+
53
+ const endIdx = trimmed.indexOf('\n---', 3);
54
+ if (endIdx < 0) return { meta: null, body: trimmed };
55
+
56
+ const header = trimmed.slice(3, endIdx).replace(/^\r?\n/, '');
57
+ const rest = trimmed.slice(endIdx + 4).replace(/^\r?\n/, '');
58
+
59
+ const meta = {};
60
+ for (const lineRaw of header.split('\n')) {
61
+ const line = lineRaw.trim();
62
+ if (!line || line.startsWith('#')) continue;
63
+ const m = line.match(/^([A-Za-z0-9_\-]+)\s*:\s*(.*)$/);
64
+ if (!m) continue;
65
+ const key = m[1];
66
+ const value = m[2].trim();
67
+ meta[key] = parseScalar(value);
68
+ }
69
+ return { meta, body: rest };
70
+ }
71
+
72
+ function parseScalar(v) {
73
+ if (v === '' || v === 'null' || v === '~') return null;
74
+ if (v === 'true') return true;
75
+ if (v === 'false') return false;
76
+ // Array: [a, b, c] or [a,b,c]
77
+ if (v.startsWith('[') && v.endsWith(']')) {
78
+ const inner = v.slice(1, -1).trim();
79
+ if (!inner) return [];
80
+ return inner.split(',').map((s) => parseScalar(s.trim()));
81
+ }
82
+ // Quoted string
83
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
84
+ return v.slice(1, -1);
85
+ }
86
+ // Numeric (plain integer / float)
87
+ if (/^-?\d+$/.test(v)) return Number(v);
88
+ if (/^-?\d+\.\d+$/.test(v)) return Number(v);
89
+ return v;
90
+ }
91
+
92
+ /**
93
+ * Translate a legacy conversation message md into the JSONL row shape
94
+ * emitted by 334o jsonl-log.
95
+ *
96
+ * Input: { meta, body, originalId (string used to form id prefix),
97
+ * fallbackTaskId (nullable) }
98
+ *
99
+ * Output: a plain object ready for jsonl-log.append().
100
+ */
101
+ export function mapMessageMdToJsonl({ meta, body, originalId, fallbackTaskId = null }) {
102
+ if (!meta) {
103
+ // Corrupted input — caller decides whether to skip or keep. We still
104
+ // return a minimally-valid row so the migration can continue if it wants.
105
+ return {
106
+ id: `msg_legacy_${originalId}`,
107
+ ts: null,
108
+ type: 'chat',
109
+ authorKind: 'unknown',
110
+ authorId: 'unknown',
111
+ groupId: LEGACY_GROUP_ID,
112
+ taskId: fallbackTaskId,
113
+ body: typeof body === 'string' ? body : '',
114
+ mentions: [],
115
+ replyTo: null,
116
+ viaTool: null,
117
+ _corrupted: true,
118
+ };
119
+ }
120
+
121
+ const role = meta.role;
122
+ let type = 'chat';
123
+ let authorKind = 'user';
124
+ let authorId = 'user:self';
125
+ if (role === 'assistant') {
126
+ authorKind = 'vp';
127
+ authorId = LEGACY_VP_ID;
128
+ } else if (role === 'tool') {
129
+ type = 'tool';
130
+ authorKind = 'tool';
131
+ authorId = meta.tool || 'tool:legacy';
132
+ } else if (role && role !== 'user') {
133
+ authorKind = 'unknown';
134
+ authorId = `unknown:${role}`;
135
+ }
136
+
137
+ return {
138
+ id: `msg_legacy_${originalId}`,
139
+ ts: meta.time || meta.ts || null,
140
+ type,
141
+ authorKind,
142
+ authorId,
143
+ groupId: LEGACY_GROUP_ID,
144
+ taskId: fallbackTaskId,
145
+ body: typeof body === 'string' ? body : '',
146
+ mentions: [],
147
+ replyTo: null,
148
+ viaTool: null,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Route a legacy memory entry to the correct shard filename.
154
+ */
155
+ export function shardForMemoryKind(kind) {
156
+ if (!kind) return MEMORY_DEFAULT_SHARD;
157
+ return MEMORY_SHARD_BY_KIND[kind] || MEMORY_DEFAULT_SHARD;
158
+ }
159
+
160
+ /**
161
+ * Translate a legacy memory entry md into a shard-store entry.
162
+ *
163
+ * Input: { meta, body, now (iso string used for authoredAt/migration ts),
164
+ * id (pre-generated) }
165
+ *
166
+ * Output: { shard, entry } ready for shardStore.put().
167
+ * entry.body is a serialised frontmatter + body string (the
168
+ * shard-store keeps opaque text between START/END markers, so we
169
+ * pre-serialise the new YAML here).
170
+ */
171
+ export function mapMemoryEntry({ meta, body, id, now }) {
172
+ if (!meta) return null;
173
+ const kind = meta.kind || 'fact';
174
+ const shard = shardForMemoryKind(kind);
175
+ const tags = Array.isArray(meta.tags) ? meta.tags.slice() : [];
176
+ const pinned = meta.importance === 'high';
177
+ const createdAt = meta.created_at || now;
178
+ const updatedAt = meta.updated_at || createdAt;
179
+
180
+ const entryMeta = {
181
+ id,
182
+ kind,
183
+ tags,
184
+ pinned,
185
+ sourceRef: {
186
+ groupId: LEGACY_GROUP_ID,
187
+ taskId: null,
188
+ msgIds: [],
189
+ timeWindow: [createdAt, updatedAt],
190
+ hint: `migrated from v0 memory/entries/${meta.name || 'unknown'}.md`,
191
+ },
192
+ authoredBy: MIGRATION_AUTHOR,
193
+ createdAt,
194
+ updatedAt,
195
+ supersedes: null,
196
+ supersededBy: null,
197
+ };
198
+
199
+ const serialisedBody = serialiseEntryBody(entryMeta, body);
200
+
201
+ return {
202
+ shard,
203
+ entry: {
204
+ id,
205
+ shard,
206
+ body: serialisedBody,
207
+ meta: {
208
+ kind,
209
+ tags,
210
+ pinned,
211
+ },
212
+ },
213
+ };
214
+ }
215
+
216
+ function serialiseEntryBody(meta, body) {
217
+ const lines = ['---'];
218
+ lines.push(`id: ${meta.id}`);
219
+ lines.push(`kind: ${meta.kind}`);
220
+ lines.push(`tags: [${meta.tags.map((t) => JSON.stringify(t)).join(', ')}]`);
221
+ lines.push(`pinned: ${meta.pinned}`);
222
+ lines.push(`authoredBy: ${JSON.stringify(meta.authoredBy)}`);
223
+ lines.push(`createdAt: ${JSON.stringify(meta.createdAt)}`);
224
+ lines.push(`updatedAt: ${JSON.stringify(meta.updatedAt)}`);
225
+ lines.push('sourceRef:');
226
+ lines.push(` groupId: ${meta.sourceRef.groupId}`);
227
+ lines.push(` taskId: ${meta.sourceRef.taskId ?? 'null'}`);
228
+ lines.push(` msgIds: []`);
229
+ lines.push(` timeWindow: [${JSON.stringify(meta.sourceRef.timeWindow[0])}, ${JSON.stringify(meta.sourceRef.timeWindow[1])}]`);
230
+ lines.push(` hint: ${JSON.stringify(meta.sourceRef.hint)}`);
231
+ lines.push(`supersedes: null`);
232
+ lines.push(`supersededBy: null`);
233
+ lines.push('---');
234
+ lines.push('');
235
+ lines.push(typeof body === 'string' ? body.trim() : '');
236
+ return lines.join('\n');
237
+ }
238
+
239
+ /**
240
+ * Map a legacy task meta.md (YAML frontmatter) into a task.json payload.
241
+ */
242
+ export function mapTaskMeta({ meta, taskId }) {
243
+ const m = meta || {};
244
+ return {
245
+ id: taskId,
246
+ groupId: LEGACY_GROUP_ID,
247
+ initiatorVpId: LEGACY_VP_ID,
248
+ members: [LEGACY_VP_ID],
249
+ relatedTaskIds: [],
250
+ status: m.status || 'archived',
251
+ description: m.description || '',
252
+ createdAt: m.created_at || null,
253
+ updatedAt: m.updated_at || m.created_at || null,
254
+ _legacy: true,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Split a coordinator.md file into turns.
260
+ *
261
+ * Rule (spec §M2.3): break on markdown H2 `## ` headings. Each turn becomes
262
+ * one row. Turns with a heading in the form `## <role> @ <ts>` have role/ts
263
+ * extracted; otherwise role='system', ts=null.
264
+ *
265
+ * Returns an array of { index, role, ts, body } — caller turns them into
266
+ * full JSONL rows (adding id etc).
267
+ */
268
+ export function splitCoordinatorTurns(raw) {
269
+ if (typeof raw !== 'string' || !raw.trim()) return [];
270
+ const lines = raw.split('\n');
271
+ const turns = [];
272
+ let current = null;
273
+ for (const line of lines) {
274
+ if (line.startsWith('## ')) {
275
+ if (current) turns.push(current);
276
+ const header = line.slice(3).trim();
277
+ const atIdx = header.lastIndexOf(' @ ');
278
+ let role = header;
279
+ let ts = null;
280
+ if (atIdx > 0) {
281
+ role = header.slice(0, atIdx).trim();
282
+ ts = header.slice(atIdx + 3).trim() || null;
283
+ }
284
+ current = { index: turns.length, role: role || 'system', ts, body: '' };
285
+ } else if (current) {
286
+ current.body += (current.body ? '\n' : '') + line;
287
+ }
288
+ }
289
+ if (current) turns.push(current);
290
+ // Trim trailing blank lines in bodies.
291
+ for (const t of turns) t.body = t.body.replace(/\n+$/, '');
292
+ return turns;
293
+ }
@@ -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.