@yeaft/webchat-agent 0.1.519 → 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 +1 -1
- package/unify/migration/detect.js +176 -0
- package/unify/migration/map-fields.js +293 -0
- package/unify/migration/v0-to-v1.js +588 -0
- package/unify/session.js +25 -0
- package/unify/storage/atomic.js +107 -0
- package/unify/storage/compact.js +80 -0
- package/unify/storage/index.js +44 -0
- package/unify/storage/jsonl-index.js +122 -0
- package/unify/storage/jsonl-log.js +222 -0
- package/unify/storage/shard-index.js +158 -0
- package/unify/storage/shard-store.js +317 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|