lazyclaw 4.2.2 → 5.0.0

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 (67) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -353
  3. package/agents.mjs +19 -3
  4. package/channels/handoff.mjs +41 -0
  5. package/channels/loader.mjs +124 -0
  6. package/channels/matrix.mjs +417 -0
  7. package/channels/telegram.mjs +362 -0
  8. package/channels/threads.mjs +116 -0
  9. package/cli.mjs +730 -27
  10. package/daemon.mjs +111 -0
  11. package/gateway/device_auth.mjs +664 -0
  12. package/gateway/http_gateway.mjs +304 -0
  13. package/mas/agent_memory.mjs +35 -34
  14. package/mas/agent_turn.mjs +30 -1
  15. package/mas/confidence.mjs +108 -0
  16. package/mas/index_db.mjs +242 -0
  17. package/mas/mention_router.mjs +75 -4
  18. package/mas/nudge.mjs +97 -0
  19. package/mas/prompt_stack.mjs +80 -0
  20. package/mas/provider_adapters.mjs +83 -0
  21. package/mas/redact.mjs +46 -0
  22. package/mas/skill_synth.mjs +331 -0
  23. package/mas/tool_runner.mjs +19 -48
  24. package/mas/tools/browser.mjs +77 -0
  25. package/mas/tools/clarify.mjs +36 -0
  26. package/mas/tools/coding.mjs +109 -0
  27. package/mas/tools/delegation.mjs +53 -0
  28. package/mas/tools/edit.mjs +36 -0
  29. package/mas/tools/git.mjs +110 -0
  30. package/mas/tools/ha.mjs +34 -0
  31. package/mas/tools/learning.mjs +168 -0
  32. package/mas/tools/media.mjs +105 -0
  33. package/mas/tools/os.mjs +152 -0
  34. package/mas/tools/patch.mjs +91 -0
  35. package/mas/tools/recall.mjs +103 -0
  36. package/mas/tools/registry.mjs +93 -0
  37. package/mas/tools/scheduling.mjs +62 -0
  38. package/mas/tools/skill_view.mjs +43 -0
  39. package/mas/tools/web.mjs +137 -0
  40. package/mas/toolsets.mjs +64 -0
  41. package/mas/trajectory_export.mjs +169 -0
  42. package/mas/trajectory_store.mjs +179 -0
  43. package/mas/user_modeler.mjs +108 -0
  44. package/package.json +22 -3
  45. package/providers/codex_cli.mjs +200 -0
  46. package/providers/gemini_cli.mjs +179 -0
  47. package/providers/registry.mjs +61 -1
  48. package/sandbox/base.mjs +82 -0
  49. package/sandbox/confiners/bubblewrap.mjs +21 -0
  50. package/sandbox/confiners/firejail.mjs +16 -0
  51. package/sandbox/confiners/landlock.mjs +14 -0
  52. package/sandbox/confiners/seatbelt.mjs +28 -0
  53. package/sandbox/daytona.mjs +37 -0
  54. package/sandbox/docker.mjs +91 -0
  55. package/sandbox/index.mjs +67 -0
  56. package/sandbox/local.mjs +59 -0
  57. package/sandbox/modal.mjs +53 -0
  58. package/sandbox/singularity.mjs +39 -0
  59. package/sandbox/ssh.mjs +56 -0
  60. package/sandbox.mjs +11 -127
  61. package/scripts/hermes-import.mjs +111 -0
  62. package/scripts/migrate-v5.mjs +342 -0
  63. package/scripts/openclaw-import.mjs +71 -0
  64. package/sessions.mjs +20 -1
  65. package/skills.mjs +101 -8
  66. package/skills_curator.mjs +323 -0
  67. package/workspace.mjs +18 -3
@@ -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()) {
package/skills.mjs CHANGED
@@ -38,24 +38,79 @@ export function listSkills(configDir = defaultConfigDir()) {
38
38
  .map(name => {
39
39
  const full = path.join(dir, name);
40
40
  const stat = fs.statSync(full);
41
- const head = readFirstLine(full);
41
+ let content = '';
42
+ try { content = fs.readFileSync(full, 'utf8'); } catch { /* unreadable → empty */ }
43
+ const { meta, body } = parseFrontmatter(content);
44
+ // Prefer the agent-/author-supplied frontmatter description; fall
45
+ // back to the first markdown heading for legacy frontmatter-less
46
+ // skills so the index still reads sensibly.
47
+ const summary = (meta.description || firstHeading(body) || '').slice(0, 120);
42
48
  return {
43
49
  name: name.slice(0, -SKILL_EXT.length),
44
50
  path: full,
45
51
  bytes: stat.size,
46
52
  mtimeMs: stat.mtimeMs,
47
- summary: head.replace(/^#+\s*/, '').slice(0, 120),
53
+ summary,
54
+ description: meta.description || '',
55
+ createdBy: meta.created_by || '',
56
+ version: meta.version || '',
48
57
  };
49
58
  })
50
59
  .sort((a, b) => a.name.localeCompare(b.name));
51
60
  }
52
61
 
53
- function readFirstLine(p) {
54
- try {
55
- const buf = fs.readFileSync(p, 'utf8');
56
- const nl = buf.indexOf('\n');
57
- return nl < 0 ? buf : buf.slice(0, nl);
58
- } catch { return ''; }
62
+ // Parse a leading YAML frontmatter block (--- … ---). Only the flat
63
+ // `key: value` shape skills use is supported — no nested YAML — which
64
+ // keeps us dependency-free. Returns { meta, body }; when no frontmatter
65
+ // is present meta is {} and body is the untouched content.
66
+ export function parseFrontmatter(content) {
67
+ const text = String(content ?? '');
68
+ if (!text.startsWith('---')) return { meta: {}, body: text };
69
+ // The opening fence must be its own line.
70
+ const afterOpen = text.slice(3);
71
+ if (!/^\r?\n/.test(afterOpen)) return { meta: {}, body: text };
72
+ const closeRe = /\r?\n---[ \t]*(?:\r?\n|$)/;
73
+ const m = closeRe.exec(afterOpen);
74
+ if (!m) return { meta: {}, body: text };
75
+ const block = afterOpen.slice(0, m.index);
76
+ // Drop blank lines between the closing fence and the first body line
77
+ // so callers can rely on body starting at real content.
78
+ const body = afterOpen.slice(m.index + m[0].length).replace(/^(?:\r?\n)+/, '');
79
+ const meta = {};
80
+ for (const line of block.split(/\r?\n/)) {
81
+ const mm = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trim());
82
+ if (!mm) continue;
83
+ let val = mm[2].trim();
84
+ if (val.length >= 2 && val.startsWith('"') && val.endsWith('"')) {
85
+ // Symmetric with skill_synth's escapeYaml double-quote escaping.
86
+ val = val.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
87
+ } else if (val.length >= 2 && val.startsWith("'") && val.endsWith("'")) {
88
+ val = val.slice(1, -1);
89
+ }
90
+ meta[mm[1]] = val;
91
+ }
92
+ return { meta, body };
93
+ }
94
+
95
+ function firstHeading(body) {
96
+ for (const line of String(body || '').split(/\r?\n/)) {
97
+ const t = line.trim();
98
+ if (!t) continue;
99
+ return t.replace(/^#+\s*/, '');
100
+ }
101
+ return '';
102
+ }
103
+
104
+ // Compact "Level 0" recall index: one `- <name>: <summary>` line per
105
+ // installed skill, sorted by name. Returns '' when no skills exist so
106
+ // callers can inject it conditionally. This is what gets dropped into
107
+ // the system prompt so the model knows which skills exist without
108
+ // paying for their full bodies (progressive disclosure — the model
109
+ // pulls a full skill on demand via the skill_view tool).
110
+ export function skillsIndex(configDir = defaultConfigDir()) {
111
+ const skills = listSkills(configDir);
112
+ if (!skills.length) return '';
113
+ return skills.map((s) => `- ${s.name}: ${s.summary}`.trimEnd()).join('\n');
59
114
  }
60
115
 
61
116
  export function loadSkill(name, configDir = defaultConfigDir()) {
@@ -76,6 +131,44 @@ export function removeSkill(name, configDir = defaultConfigDir()) {
76
131
  if (fs.existsSync(p)) fs.unlinkSync(p);
77
132
  }
78
133
 
134
+ export function skillExists(name, configDir = defaultConfigDir()) {
135
+ try { return fs.existsSync(skillPath(name, configDir)); }
136
+ catch { return false; }
137
+ }
138
+
139
+ // Read the integer `version` from a skill's frontmatter (0 when the
140
+ // skill is missing or carries no version).
141
+ export function skillVersion(name, configDir = defaultConfigDir()) {
142
+ try {
143
+ const { meta } = parseFrontmatter(fs.readFileSync(skillPath(name, configDir), 'utf8'));
144
+ return parseInt(meta.version, 10) || 0;
145
+ } catch { return 0; }
146
+ }
147
+
148
+ // Reserve a target name for an agent-synthesised skill that NEVER
149
+ // clobbers a human-authored skill. If the slug is free, use it. If a
150
+ // skill with that slug already exists AND it was itself agent-authored
151
+ // (created_by: agent), reuse it — that's the self-improvement update
152
+ // path. Otherwise the slug belongs to a human/curated skill, so we
153
+ // append a numeric suffix and try again. This is the security boundary
154
+ // that stops LLM-chosen slugs from overwriting trusted skills.
155
+ export function reserveSynthName(name, configDir = defaultConfigDir()) {
156
+ const base = (name && String(name).trim()) || 'skill';
157
+ let candidate = base;
158
+ for (let i = 1; i < 1000; i++) {
159
+ let p;
160
+ try { p = skillPath(candidate, configDir); }
161
+ catch { return base; }
162
+ if (!fs.existsSync(p)) return candidate;
163
+ let createdBy = '';
164
+ try { createdBy = parseFrontmatter(fs.readFileSync(p, 'utf8')).meta.created_by || ''; }
165
+ catch { /* unreadable → treat as occupied */ }
166
+ if (createdBy === 'agent') return candidate; // overwrite our own = improve
167
+ candidate = `${base}-${i}`;
168
+ }
169
+ return candidate;
170
+ }
171
+
79
172
  /**
80
173
  * Compose the system prompt for a chat/agent invocation. Concatenates each
81
174
  * named skill's contents with a separator, in the order given. Returns null