lazyclaw 4.3.0 → 5.0.1

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 (54) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -508
  3. package/channels/handoff.mjs +41 -0
  4. package/channels/loader.mjs +124 -0
  5. package/channels/threads.mjs +116 -0
  6. package/cli.mjs +430 -7
  7. package/daemon.mjs +13 -0
  8. package/mas/agent_turn.mjs +28 -0
  9. package/mas/confidence.mjs +108 -0
  10. package/mas/index_db.mjs +242 -0
  11. package/mas/nudge.mjs +97 -0
  12. package/mas/prompt_stack.mjs +80 -0
  13. package/mas/skill_synth.mjs +124 -25
  14. package/mas/tool_runner.mjs +10 -61
  15. package/mas/tools/browser.mjs +77 -0
  16. package/mas/tools/clarify.mjs +36 -0
  17. package/mas/tools/coding.mjs +109 -0
  18. package/mas/tools/delegation.mjs +53 -0
  19. package/mas/tools/edit.mjs +36 -0
  20. package/mas/tools/git.mjs +110 -0
  21. package/mas/tools/ha.mjs +34 -0
  22. package/mas/tools/learning.mjs +168 -0
  23. package/mas/tools/media.mjs +105 -0
  24. package/mas/tools/os.mjs +152 -0
  25. package/mas/tools/patch.mjs +91 -0
  26. package/mas/tools/recall.mjs +103 -0
  27. package/mas/tools/registry.mjs +93 -0
  28. package/mas/tools/scheduling.mjs +62 -0
  29. package/mas/tools/web.mjs +137 -0
  30. package/mas/toolsets.mjs +64 -0
  31. package/mas/trajectory_export.mjs +169 -0
  32. package/mas/trajectory_store.mjs +179 -0
  33. package/mas/user_modeler.mjs +108 -0
  34. package/package.json +20 -3
  35. package/providers/codex_cli.mjs +200 -0
  36. package/providers/gemini_cli.mjs +179 -0
  37. package/providers/registry.mjs +61 -1
  38. package/sandbox/base.mjs +82 -0
  39. package/sandbox/confiners/bubblewrap.mjs +21 -0
  40. package/sandbox/confiners/firejail.mjs +16 -0
  41. package/sandbox/confiners/landlock.mjs +14 -0
  42. package/sandbox/confiners/seatbelt.mjs +28 -0
  43. package/sandbox/daytona.mjs +37 -0
  44. package/sandbox/docker.mjs +91 -0
  45. package/sandbox/index.mjs +67 -0
  46. package/sandbox/local.mjs +59 -0
  47. package/sandbox/modal.mjs +53 -0
  48. package/sandbox/singularity.mjs +39 -0
  49. package/sandbox/ssh.mjs +56 -0
  50. package/sandbox.mjs +11 -127
  51. package/scripts/hermes-import.mjs +111 -0
  52. package/scripts/migrate-v5.mjs +342 -0
  53. package/scripts/openclaw-import.mjs +71 -0
  54. package/sessions.mjs +20 -1
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env node
2
+ // scripts/migrate-v5.mjs — Phase A baseline migration (spec §10).
3
+ //
4
+ // Steps (each is idempotent — a second run is a no-op):
5
+ // 1. Backup <configDir> to <configDir>/backup-v4-<ts>/ (only when no
6
+ // prior backup exists for the current schema version).
7
+ // 2. Rewrite config.json: ensure trainer.provider defaults to "auto"
8
+ // when omitted (canonical C9). Existing trainer blocks are left
9
+ // alone.
10
+ // 3. Walk skills/*.md and add missing frontmatter fields per
11
+ // canonical decisions:
12
+ // - group: filename-hyphen-prefix or 'legacy' (C5)
13
+ // - trained_by: 'legacy' for pre-v5 skills (C4)
14
+ // Existing fields are never overwritten.
15
+ // 4. Rebuild index.db from on-disk sessions, skills, memory.
16
+ //
17
+ // Phases B+ extend this script with user-modeler import, persona
18
+ // promotion, and trajectory backfill from the v4 recent.jsonl.
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import os from 'node:os';
23
+ import { openIndex, rebuild, indexSessionTurn, indexSkill, indexMemory } from '../mas/index_db.mjs';
24
+ import { parseFrontmatter } from '../skills.mjs';
25
+
26
+ function defaultConfigDir() {
27
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
28
+ }
29
+
30
+ function tsStamp() {
31
+ return new Date().toISOString().replace(/[:.]/g, '-');
32
+ }
33
+
34
+ function backupOnce(configDir) {
35
+ const entries = fs.readdirSync(configDir, { withFileTypes: true });
36
+ const hasBackup = entries.some(e => e.isDirectory() && e.name.startsWith('backup-v4-'));
37
+ if (hasBackup) return { skipped: true };
38
+ const backupDir = path.join(configDir, `backup-v4-${tsStamp()}`);
39
+ fs.mkdirSync(backupDir, { recursive: true });
40
+ for (const e of entries) {
41
+ if (e.name === 'index.db' || e.name.startsWith('backup-v4-')) continue;
42
+ const src = path.join(configDir, e.name);
43
+ const dst = path.join(backupDir, e.name);
44
+ fs.cpSync(src, dst, { recursive: true });
45
+ }
46
+ return { backupDir };
47
+ }
48
+
49
+ function rewriteConfig(configDir) {
50
+ const cfgPath = path.join(configDir, 'config.json');
51
+ let cfg = {};
52
+ if (fs.existsSync(cfgPath)) {
53
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { cfg = {}; }
54
+ }
55
+ if (!cfg.trainer || !cfg.trainer.provider) {
56
+ cfg.trainer = { provider: 'auto', ...(cfg.trainer || {}) };
57
+ }
58
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
59
+ return cfg;
60
+ }
61
+
62
+ function escapeYaml(s) {
63
+ const str = String(s ?? '');
64
+ if (!/[":\n]/.test(str)) return str;
65
+ return '"' + str.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
66
+ }
67
+
68
+ function upgradeSkillFrontmatter(filePath) {
69
+ const raw = fs.readFileSync(filePath, 'utf8');
70
+ const { meta, body } = parseFrontmatter(raw);
71
+ const baseName = path.basename(filePath, '.md');
72
+ const want = {
73
+ group: meta.group || (baseName.includes('-') ? baseName.split('-')[0] : 'legacy'),
74
+ trained_by: meta.trained_by || 'legacy',
75
+ };
76
+ const before = JSON.stringify(meta);
77
+ const next = { ...meta, ...want };
78
+ if (JSON.stringify(next) === before) return false; // no change
79
+ const lines = ['---'];
80
+ for (const [k, v] of Object.entries(next)) {
81
+ lines.push(`${k}: ${escapeYaml(v)}`);
82
+ }
83
+ lines.push('---', '', body.replace(/^\n+/, ''));
84
+ fs.writeFileSync(filePath, lines.join('\n'));
85
+ return true;
86
+ }
87
+
88
+ function upgradeAllSkills(configDir) {
89
+ const dir = path.join(configDir, 'skills');
90
+ if (!fs.existsSync(dir)) return { upgraded: 0 };
91
+ let n = 0;
92
+ for (const name of fs.readdirSync(dir)) {
93
+ if (!name.endsWith('.md')) continue;
94
+ if (upgradeSkillFrontmatter(path.join(dir, name))) n++;
95
+ }
96
+ return { upgraded: n };
97
+ }
98
+
99
+ function rebuildIndex(configDir) {
100
+ rebuild(configDir);
101
+ openIndex(configDir);
102
+
103
+ // Sessions.
104
+ const sessDir = path.join(configDir, 'sessions');
105
+ if (fs.existsSync(sessDir)) {
106
+ for (const f of fs.readdirSync(sessDir)) {
107
+ if (!f.endsWith('.jsonl')) continue;
108
+ const id = f.slice(0, -'.jsonl'.length);
109
+ const raw = fs.readFileSync(path.join(sessDir, f), 'utf8');
110
+ let idx = 0;
111
+ for (const line of raw.split('\n')) {
112
+ if (!line) continue;
113
+ try {
114
+ const obj = JSON.parse(line);
115
+ indexSessionTurn({
116
+ session_id: id, turn_idx: idx++, role: obj.role || 'user',
117
+ ts: obj.ts || 0, content: obj.content || '',
118
+ }, configDir);
119
+ } catch { /* skip malformed */ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Skills.
125
+ const skillsDir = path.join(configDir, 'skills');
126
+ if (fs.existsSync(skillsDir)) {
127
+ for (const f of fs.readdirSync(skillsDir)) {
128
+ if (!f.endsWith('.md')) continue;
129
+ const name = f.slice(0, -'.md'.length);
130
+ const raw = fs.readFileSync(path.join(skillsDir, f), 'utf8');
131
+ const { meta, body } = parseFrontmatter(raw);
132
+ indexSkill({
133
+ skill_name: name,
134
+ trained_by: meta.trained_by || 'legacy',
135
+ group_name: meta.group || (name.includes('-') ? name.split('-')[0] : 'legacy'),
136
+ content: body,
137
+ }, configDir);
138
+ }
139
+ }
140
+
141
+ // Memory (core + episodic).
142
+ const memDir = path.join(configDir, 'memory');
143
+ if (fs.existsSync(memDir)) {
144
+ const corePath = path.join(memDir, 'core.md');
145
+ if (fs.existsSync(corePath)) {
146
+ indexMemory({ topic: 'core', kind: 'core',
147
+ content: fs.readFileSync(corePath, 'utf8') }, configDir);
148
+ }
149
+ const epi = path.join(memDir, 'episodic');
150
+ if (fs.existsSync(epi)) {
151
+ for (const f of fs.readdirSync(epi)) {
152
+ if (!f.endsWith('.md')) continue;
153
+ indexMemory({
154
+ topic: f.slice(0, -'.md'.length), kind: 'episodic',
155
+ content: fs.readFileSync(path.join(epi, f), 'utf8'),
156
+ }, configDir);
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ export async function migrateV5(opts = {}) {
163
+ const configDir = opts.configDir || defaultConfigDir();
164
+ fs.mkdirSync(configDir, { recursive: true });
165
+ const backup = backupOnce(configDir);
166
+ const config = rewriteConfig(configDir);
167
+ const skills = upgradeAllSkills(configDir);
168
+ rebuildIndex(configDir);
169
+ return {
170
+ ok: true,
171
+ configDir,
172
+ backupDir: backup.backupDir || null,
173
+ backupSkipped: !!backup.skipped,
174
+ trainerProvider: config.trainer?.provider,
175
+ skillsUpgraded: skills.upgraded,
176
+ };
177
+ }
178
+
179
+ // --- Phase G: full v4→v5 migration (spec §1.7, §10) ---------------------
180
+ // Phase A's migrateV5() keeps its original behaviour (backup inside
181
+ // cfgDir, rebuild index). Phase G adds a parallel migrate()/rollback()
182
+ // pair the user-facing CLI calls. Differences from Phase A:
183
+ // * Backup goes to `<cfgDir>.v4.backup/<ISO-ts>/` (peer dir, not under
184
+ // cfgDir) so rollback can wipe + restore cleanly.
185
+ // * Config rewrites add `orchestrator → orchestra` (C3, §3.9) and
186
+ // `sandbox: "docker"` string → `sandbox: {backend: "docker"}` object
187
+ // (C8) on top of the trainer auto default (C9).
188
+ // * Skill frontmatter gains `confidence: 0.5` alongside the C4/C5
189
+ // fields Phase A already injects.
190
+ // * `personalities/`, `memory/`, `workspaces/` directories are
191
+ // created so the Phase-G CLI surfaces work right after migration.
192
+
193
+ function isoTs() {
194
+ return new Date().toISOString().replace(/[:.]/g, '-');
195
+ }
196
+
197
+ function copyTree(src, dst) {
198
+ fs.mkdirSync(dst, { recursive: true });
199
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
200
+ const s = path.join(src, entry.name);
201
+ const d = path.join(dst, entry.name);
202
+ if (entry.isDirectory()) copyTree(s, d);
203
+ else fs.copyFileSync(s, d);
204
+ }
205
+ }
206
+
207
+ function removeTree(p) {
208
+ if (!fs.existsSync(p)) return;
209
+ const st = fs.lstatSync(p);
210
+ if (!st.isDirectory()) { fs.unlinkSync(p); return; }
211
+ for (const entry of fs.readdirSync(p, { withFileTypes: true })) {
212
+ const c = path.join(p, entry.name);
213
+ if (entry.isDirectory()) removeTree(c);
214
+ else fs.unlinkSync(c);
215
+ }
216
+ fs.rmdirSync(p);
217
+ }
218
+
219
+ function backupSnapshot(cfgDir) {
220
+ const root = `${cfgDir}.v4.backup`;
221
+ fs.mkdirSync(root, { recursive: true });
222
+ const dst = path.join(root, isoTs());
223
+ copyTree(cfgDir, dst);
224
+ return dst;
225
+ }
226
+
227
+ function rewriteConfigPhaseG(cfgDir) {
228
+ const p = path.join(cfgDir, 'config.json');
229
+ if (!fs.existsSync(p)) return;
230
+ let cfg;
231
+ try { cfg = JSON.parse(fs.readFileSync(p, 'utf8')); }
232
+ catch (e) { throw new Error(`migrate: config.json is not valid JSON: ${e.message}`); }
233
+
234
+ // orchestrator → orchestra (C3 + spec §3.9)
235
+ if (cfg.orchestrator && !cfg.orchestra) {
236
+ cfg.orchestra = cfg.orchestrator;
237
+ delete cfg.orchestrator;
238
+ }
239
+
240
+ // sandbox: "docker" → sandbox: { backend: "docker" } (C8)
241
+ if (typeof cfg.sandbox === 'string') {
242
+ cfg.sandbox = { backend: cfg.sandbox };
243
+ }
244
+
245
+ // Default trainer (auto) when absent (C9)
246
+ if (!cfg.trainer) {
247
+ cfg.trainer = { provider: 'auto', schedule: 'nightly' };
248
+ }
249
+
250
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
251
+ }
252
+
253
+ function upgradeSkillPhaseG(filePath) {
254
+ const raw = fs.readFileSync(filePath, 'utf8');
255
+ // Frontmatter detection mirrors skills.mjs::parseFrontmatter
256
+ if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) return;
257
+ const after = raw.slice(4);
258
+ const closeRe = /\r?\n---[ \t]*(?:\r?\n|$)/;
259
+ const m = closeRe.exec(after);
260
+ if (!m) return;
261
+ const block = after.slice(0, m.index);
262
+ const body = after.slice(m.index + m[0].length);
263
+ const keys = {};
264
+ for (const line of block.split(/\r?\n/)) {
265
+ const kv = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trim());
266
+ if (kv) keys[kv[1]] = kv[2];
267
+ }
268
+
269
+ const fname = path.basename(filePath, '.md');
270
+ const hyphenPrefix = fname.includes('-') ? fname.split('-')[0] : null;
271
+
272
+ let mutated = false;
273
+ if (!keys.group) {
274
+ keys.group = hyphenPrefix || 'legacy'; // C5
275
+ mutated = true;
276
+ }
277
+ if (!keys.confidence) { keys.confidence = '0.5'; mutated = true; }
278
+ if (!keys.trained_by) { keys.trained_by = 'legacy'; mutated = true; } // C4
279
+
280
+ if (!mutated) return;
281
+ // Emit in a stable order: keep originals first, append new ones.
282
+ const orderedKeys = [];
283
+ for (const line of block.split(/\r?\n/)) {
284
+ const kv = /^([A-Za-z0-9_-]+)\s*:/.exec(line.trim());
285
+ if (kv && !orderedKeys.includes(kv[1])) orderedKeys.push(kv[1]);
286
+ }
287
+ for (const k of ['group', 'confidence', 'trained_by']) {
288
+ if (!orderedKeys.includes(k)) orderedKeys.push(k);
289
+ }
290
+ const newBlock = orderedKeys.map(k => `${k}: ${keys[k]}`).join('\n');
291
+ fs.writeFileSync(filePath, `---\n${newBlock}\n---\n${body}`);
292
+ }
293
+
294
+ function upgradeAllSkillsPhaseG(cfgDir) {
295
+ const dir = path.join(cfgDir, 'skills');
296
+ if (!fs.existsSync(dir)) return;
297
+ for (const f of fs.readdirSync(dir)) {
298
+ if (f.endsWith('.md')) upgradeSkillPhaseG(path.join(dir, f));
299
+ }
300
+ }
301
+
302
+ function ensureDirs(cfgDir) {
303
+ fs.mkdirSync(path.join(cfgDir, 'personalities'), { recursive: true }); // C7
304
+ fs.mkdirSync(path.join(cfgDir, 'memory'), { recursive: true });
305
+ fs.mkdirSync(path.join(cfgDir, 'workspaces'), { recursive: true });
306
+ }
307
+
308
+ export function migrate({ cfgDir } = {}) {
309
+ const dir = cfgDir || defaultConfigDir();
310
+ if (!fs.existsSync(dir)) throw new Error(`config dir not found: ${dir}`);
311
+ const backupDir = backupSnapshot(dir);
312
+ rewriteConfigPhaseG(dir);
313
+ upgradeAllSkillsPhaseG(dir);
314
+ ensureDirs(dir);
315
+ return { backupDir };
316
+ }
317
+
318
+ export function rollback({ cfgDir } = {}) {
319
+ const dir = cfgDir || defaultConfigDir();
320
+ const root = `${dir}.v4.backup`;
321
+ if (!fs.existsSync(root)) throw new Error(`no backup found at ${root}`);
322
+ const stamps = fs.readdirSync(root).sort();
323
+ if (!stamps.length) throw new Error(`no backup snapshots in ${root}`);
324
+ const latest = path.join(root, stamps[stamps.length - 1]);
325
+ // Wipe the current cfgDir contents, restore the latest snapshot.
326
+ for (const entry of fs.readdirSync(dir)) removeTree(path.join(dir, entry));
327
+ copyTree(latest, dir);
328
+ return { restoredFrom: latest };
329
+ }
330
+
331
+ // CLI entry — `npm run migrate:v5` or `node scripts/migrate-v5.mjs`.
332
+ if (import.meta.url === `file://${process.argv[1]}`) {
333
+ migrateV5().then(r => {
334
+ // eslint-disable-next-line no-console
335
+ console.log(JSON.stringify(r, null, 2));
336
+ process.exit(r.ok ? 0 : 1);
337
+ }).catch(err => {
338
+ // eslint-disable-next-line no-console
339
+ console.error('[migrate-v5] failed:', err.stack || err.message);
340
+ process.exit(1);
341
+ });
342
+ }
@@ -0,0 +1,71 @@
1
+ // scripts/openclaw-import.mjs
2
+ // Detect ~/.openclaw (or --from <dir>) and import into lazyclaw,
3
+ // matching Hermes `claw migrate` coverage. Tags every skill
4
+ // trained_by: openclaw-import (canonical C4).
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+
9
+ export function defaultOpenclawDir() { return path.join(os.homedir(), '.openclaw'); }
10
+ export function defaultCfgDir() {
11
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
12
+ }
13
+
14
+ function injectTrainedBy(content, value) {
15
+ if (!content.startsWith('---')) return `---\ntrained_by: ${value}\n---\n${content}`;
16
+ const closeRe = /\r?\n---[ \t]*(?:\r?\n|$)/;
17
+ const m = closeRe.exec(content.slice(3));
18
+ if (!m) return content;
19
+ const block = content.slice(4, 3 + m.index);
20
+ const rest = content.slice(3 + m.index + m[0].length);
21
+ if (/^trained_by:/m.test(block)) {
22
+ return `---\n${block.replace(/^trained_by:.*$/m, `trained_by: ${value}`)}\n---\n${rest}`;
23
+ }
24
+ return `---\n${block}\ntrained_by: ${value}\n---\n${rest}`;
25
+ }
26
+
27
+ function copyIfPresent(srcFile, dstFile, transform = (s) => s) {
28
+ if (!fs.existsSync(srcFile)) return false;
29
+ fs.mkdirSync(path.dirname(dstFile), { recursive: true });
30
+ fs.writeFileSync(dstFile, transform(fs.readFileSync(srcFile, 'utf8')));
31
+ return true;
32
+ }
33
+
34
+ function mergeJson(srcFile, cfgKey, cfgDir) {
35
+ if (!fs.existsSync(srcFile)) return;
36
+ let incoming; try { incoming = JSON.parse(fs.readFileSync(srcFile, 'utf8')); } catch { return; }
37
+ const cfgPath = path.join(cfgDir, 'config.json');
38
+ let cfg = {}; try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch {}
39
+ cfg[cfgKey] = { ...(cfg[cfgKey] || {}), ...incoming };
40
+ fs.mkdirSync(cfgDir, { recursive: true });
41
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
42
+ }
43
+
44
+ export function importOpenclaw({ from, cfgDir } = {}) {
45
+ const src = from || defaultOpenclawDir();
46
+ const dst = cfgDir || defaultCfgDir();
47
+ if (!fs.existsSync(src)) throw new Error(`openclaw source not found: ${src}`);
48
+ fs.mkdirSync(dst, { recursive: true });
49
+
50
+ copyIfPresent(path.join(src, 'SOUL.md'), path.join(dst, 'SOUL.md'));
51
+ copyIfPresent(path.join(src, 'USER.md'), path.join(dst, 'memory', 'USER.md'));
52
+ copyIfPresent(path.join(src, 'MEMORY.md'), path.join(dst, 'memory', 'core.md'));
53
+
54
+ const skillsSrc = path.join(src, 'skills');
55
+ let nSkills = 0;
56
+ if (fs.existsSync(skillsSrc)) {
57
+ const skillsDst = path.join(dst, 'skills');
58
+ fs.mkdirSync(skillsDst, { recursive: true });
59
+ for (const f of fs.readdirSync(skillsSrc)) {
60
+ if (!f.endsWith('.md')) continue;
61
+ const content = fs.readFileSync(path.join(skillsSrc, f), 'utf8');
62
+ fs.writeFileSync(path.join(skillsDst, f), injectTrainedBy(content, 'openclaw-import'));
63
+ nSkills++;
64
+ }
65
+ }
66
+
67
+ mergeJson(path.join(src, 'allowlist.json'), 'allowlist', dst);
68
+ mergeJson(path.join(src, 'messaging.json'), 'channels', dst);
69
+
70
+ return { src, dst, counts: { skills: nSkills } };
71
+ }
package/sessions.mjs CHANGED
@@ -18,6 +18,7 @@ import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import os from 'node:os';
20
20
  import { appendRecent as _memoryAppendRecent } from './memory.mjs';
21
+ import { indexSessionTurn as _indexSessionTurn } from './mas/index_db.mjs';
21
22
 
22
23
  const SESSIONS_DIRNAME = 'sessions';
23
24
 
@@ -73,12 +74,30 @@ export function appendTurn(id, role, content, configDir = defaultConfigDir()) {
73
74
  }
74
75
  const p = sessionPath(id, configDir);
75
76
  fs.mkdirSync(path.dirname(p), { recursive: true });
76
- const line = JSON.stringify({ role, content: String(content ?? ''), ts: Date.now() }) + '\n';
77
+ const ts = Date.now();
78
+ const line = JSON.stringify({ role, content: String(content ?? ''), ts }) + '\n';
79
+ // Compute turn_idx BEFORE the append so the index row aligns with the
80
+ // JSONL row about to land on disk.
81
+ let turnIdx = 0;
82
+ try {
83
+ if (fs.existsSync(p)) {
84
+ const existing = fs.readFileSync(p, 'utf8');
85
+ turnIdx = existing ? existing.split('\n').filter(Boolean).length : 0;
86
+ }
87
+ } catch { /* ignore — turn_idx defaults to 0 */ }
77
88
  fs.appendFileSync(p, line);
78
89
  // Write-through to the memory recency log. Best-effort; failures
79
90
  // never propagate up — a missing or broken memory store must not
80
91
  // break the session-write path.
81
92
  _memoryAppendRecent(id, role, content, configDir);
93
+ // Phase A: FTS5 mirror (spec §4.4). Errors are swallowed inside
94
+ // indexSessionTurn but we wrap again here so a missing module (e.g.
95
+ // index_db not present in a tree shaped before Phase A) can't break
96
+ // this hot path either.
97
+ try {
98
+ _indexSessionTurn({ session_id: id, turn_idx: turnIdx, role, ts,
99
+ content: String(content ?? '') }, configDir);
100
+ } catch { /* swallow */ }
82
101
  }
83
102
 
84
103
  export function clearSession(id, configDir = defaultConfigDir()) {