lazyclaw 5.4.4 → 6.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 (91) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/channels-discord/index.mjs +76 -0
  3. package/channels-discord/package.json +14 -0
  4. package/channels-email/index.mjs +109 -0
  5. package/channels-email/package.json +14 -0
  6. package/channels-signal/index.mjs +69 -0
  7. package/channels-signal/package.json +9 -0
  8. package/channels-voice/index.mjs +81 -0
  9. package/channels-voice/package.json +9 -0
  10. package/channels-whatsapp/index.mjs +70 -0
  11. package/channels-whatsapp/package.json +13 -0
  12. package/cli.mjs +73 -7399
  13. package/commands/agents.mjs +669 -0
  14. package/commands/auth_nodes.mjs +323 -0
  15. package/commands/automation.mjs +582 -0
  16. package/commands/channels.mjs +255 -0
  17. package/commands/chat.mjs +1217 -0
  18. package/commands/config.mjs +315 -0
  19. package/commands/daemon.mjs +260 -0
  20. package/commands/misc.mjs +128 -0
  21. package/commands/providers.mjs +511 -0
  22. package/commands/sessions.mjs +343 -0
  23. package/commands/setup.mjs +741 -0
  24. package/commands/skills.mjs +218 -0
  25. package/commands/workflow.mjs +661 -0
  26. package/daemon/lib/auth.mjs +58 -0
  27. package/daemon/lib/cost.mjs +30 -0
  28. package/daemon/lib/provider.mjs +69 -0
  29. package/daemon/lib/respond.mjs +83 -0
  30. package/daemon/route_table.mjs +96 -0
  31. package/daemon/routes/_deps.mjs +36 -0
  32. package/daemon/routes/config.mjs +99 -0
  33. package/daemon/routes/conversation.mjs +371 -0
  34. package/daemon/routes/meta.mjs +239 -0
  35. package/daemon/routes/ops.mjs +185 -0
  36. package/daemon/routes/providers.mjs +211 -0
  37. package/daemon/routes/rates.mjs +90 -0
  38. package/daemon/routes/registry.mjs +223 -0
  39. package/daemon/routes/sessions.mjs +213 -0
  40. package/daemon/routes/skills.mjs +260 -0
  41. package/daemon/routes/workflows.mjs +224 -0
  42. package/daemon.mjs +23 -2085
  43. package/dotenv_min.mjs +23 -0
  44. package/first_run.mjs +15 -0
  45. package/goals_cron.mjs +37 -0
  46. package/lib/args.mjs +216 -0
  47. package/lib/config.mjs +113 -0
  48. package/lib/registry_boot.mjs +55 -0
  49. package/mas/agent_turn.mjs +2 -1
  50. package/mas/index_db.mjs +82 -0
  51. package/mas/learning.mjs +17 -1
  52. package/mas/mention_router.mjs +38 -10
  53. package/mas/provider_adapters.mjs +28 -4
  54. package/mas/scrub_env.mjs +34 -0
  55. package/mas/tool_runner.mjs +23 -7
  56. package/mas/tools/bash.mjs +10 -5
  57. package/mas/tools/browser.mjs +18 -0
  58. package/mas/tools/learning.mjs +24 -14
  59. package/mas/tools/recall.mjs +5 -1
  60. package/mas/tools/web.mjs +47 -11
  61. package/mas/trajectory_store.mjs +7 -4
  62. package/package.json +16 -2
  63. package/providers/auth_store.mjs +22 -0
  64. package/providers/claude_cli.mjs +28 -2
  65. package/providers/claude_cli_detect.mjs +46 -0
  66. package/providers/custom_provider.mjs +70 -0
  67. package/providers/model_catalogue.mjs +86 -0
  68. package/providers/orchestrator.mjs +30 -9
  69. package/providers/rates.mjs +12 -2
  70. package/providers/registry.mjs +10 -7
  71. package/sandbox/confiners/landlock.mjs +14 -8
  72. package/sandbox/confiners/seatbelt.mjs +18 -2
  73. package/scripts/loop-worker.mjs +18 -7
  74. package/scripts/migrate-v5.mjs +5 -61
  75. package/secure_write.mjs +46 -0
  76. package/sessions.mjs +0 -0
  77. package/tui/modal_filter.mjs +59 -0
  78. package/tui/modal_picker.mjs +12 -37
  79. package/tui/pickers.mjs +917 -0
  80. package/tui/provider_families.mjs +41 -0
  81. package/tui/repl.mjs +67 -36
  82. package/tui/slash_commands.mjs +2 -1
  83. package/tui/slash_dispatcher.mjs +717 -58
  84. package/tui/splash.mjs +5 -12
  85. package/tui/subcommands.mjs +17 -0
  86. package/tui/terminal_approve.mjs +37 -0
  87. package/web/dashboard.css +275 -0
  88. package/web/dashboard.html +2 -1685
  89. package/web/dashboard.js +1406 -0
  90. package/workflow/persistent.mjs +13 -6
  91. package/mas/tools/skill_view.mjs +0 -43
@@ -0,0 +1,343 @@
1
+ // Session CRUD + portable bundle (export/import) + layered memory commands,
2
+ // extracted from cli.mjs (Phase D3). Owns the BUNDLE_VERSION constant.
3
+ import path from 'node:path';
4
+ import fs from 'node:fs';
5
+ import { configPath, readConfig, writeConfig, _resolveAuthKey } from '../lib/config.mjs';
6
+ import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
7
+
8
+ const BUNDLE_VERSION = 1;
9
+
10
+ export async function cmdExport(flags) {
11
+ // Portable bundle: config + every installed skill + (optionally) every
12
+ // persisted session. Writes JSON to stdout so the caller pipes it
13
+ // wherever they want — disk, scp, gist, encrypted vault.
14
+ //
15
+ // Secrets default to redacted because a bundle on a teammate's laptop
16
+ // shouldn't carry your API keys. --include-secrets flips that behavior
17
+ // for the use case of "back up MY laptop to MY external drive".
18
+ const skillsMod = await import('../skills.mjs');
19
+ const sessionsMod = await import('../sessions.mjs');
20
+ const cfgDir = path.dirname(configPath());
21
+ const cfg = readConfig();
22
+ const safeCfg = { ...cfg };
23
+ if (!flags['include-secrets']) {
24
+ if (safeCfg['api-key']) safeCfg['api-key'] = '***REDACTED***';
25
+ }
26
+ const skills = skillsMod.listSkills(cfgDir).map(s => ({
27
+ name: s.name,
28
+ content: skillsMod.loadSkill(s.name, cfgDir),
29
+ }));
30
+ const includeSessions = !!flags['include-sessions'];
31
+ const sessions = sessionsMod.listSessions(cfgDir).map(s => {
32
+ const base = { id: s.id, mtime: new Date(s.mtimeMs).toISOString(), bytes: s.bytes };
33
+ if (includeSessions) base.turns = sessionsMod.loadTurns(s.id, cfgDir);
34
+ return base;
35
+ });
36
+ const bundle = {
37
+ bundleVersion: BUNDLE_VERSION,
38
+ exportedAt: new Date().toISOString(),
39
+ config: safeCfg,
40
+ skills,
41
+ sessions,
42
+ secretsIncluded: !!flags['include-secrets'],
43
+ sessionContentIncluded: includeSessions,
44
+ };
45
+ process.stdout.write(JSON.stringify(bundle, null, 2) + '\n');
46
+ }
47
+
48
+ export async function cmdImport(flags) {
49
+ // Read JSON bundle from stdin (or --from <path>). Apply with these rules:
50
+ // - config keys land via writeConfig; existing keys are overwritten
51
+ // UNLESS --no-overwrite-config is set.
52
+ // - skills land via installSkill; existing names are skipped UNLESS
53
+ // --overwrite-skills is set.
54
+ // - sessions land only when the bundle carried turn content AND
55
+ // --import-sessions is set; existing session files are NEVER
56
+ // overwritten (we don't want to clobber active conversations).
57
+ // - REDACTED api-key in the bundle is dropped (never written).
58
+ const skillsMod = await import('../skills.mjs');
59
+ const sessionsMod = await import('../sessions.mjs');
60
+ const cfgDir = path.dirname(configPath());
61
+ let raw;
62
+ if (flags.from) raw = fs.readFileSync(flags.from, 'utf8');
63
+ else {
64
+ raw = await new Promise(resolve => {
65
+ let buf = '';
66
+ process.stdin.setEncoding('utf8');
67
+ process.stdin.on('data', d => { buf += d; });
68
+ process.stdin.on('end', () => resolve(buf));
69
+ });
70
+ }
71
+ let bundle;
72
+ try { bundle = JSON.parse(raw); }
73
+ catch (e) { console.error(`import: invalid JSON: ${e.message}`); process.exit(2); }
74
+ if (!bundle || typeof bundle !== 'object' || bundle.bundleVersion !== BUNDLE_VERSION) {
75
+ console.error(`import: unsupported bundleVersion (got ${bundle?.bundleVersion}, expected ${BUNDLE_VERSION})`);
76
+ process.exit(2);
77
+ }
78
+ const stats = { configKeys: 0, skillsAdded: 0, skillsSkipped: 0, sessionsAdded: 0, sessionsSkipped: 0 };
79
+ // Config
80
+ if (bundle.config && typeof bundle.config === 'object') {
81
+ const existing = readConfig();
82
+ const next = flags['no-overwrite-config']
83
+ ? { ...bundle.config, ...existing } // existing wins
84
+ : { ...existing, ...bundle.config }; // bundle wins (default)
85
+ // Drop redacted secrets so we never write the placeholder string.
86
+ if (next['api-key'] === '***REDACTED***') delete next['api-key'];
87
+ writeConfig(next);
88
+ stats.configKeys = Object.keys(bundle.config).length;
89
+ }
90
+ // Skills
91
+ for (const s of bundle.skills || []) {
92
+ if (!s?.name || typeof s.content !== 'string') continue;
93
+ const file = skillsMod.skillPath(s.name, cfgDir);
94
+ if (fs.existsSync(file) && !flags['overwrite-skills']) {
95
+ stats.skillsSkipped += 1;
96
+ continue;
97
+ }
98
+ skillsMod.installSkill(s.name, s.content, cfgDir);
99
+ stats.skillsAdded += 1;
100
+ }
101
+ // Sessions — never overwrite, only add new
102
+ if (flags['import-sessions']) {
103
+ for (const sess of bundle.sessions || []) {
104
+ if (!sess?.id || !Array.isArray(sess.turns)) continue;
105
+ try {
106
+ const file = sessionsMod.sessionPath(sess.id, cfgDir);
107
+ if (fs.existsSync(file)) { stats.sessionsSkipped += 1; continue; }
108
+ for (const t of sess.turns) {
109
+ if (t?.role && typeof t.content === 'string') {
110
+ sessionsMod.appendTurn(sess.id, t.role, t.content, cfgDir);
111
+ }
112
+ }
113
+ stats.sessionsAdded += 1;
114
+ } catch { stats.sessionsSkipped += 1; }
115
+ }
116
+ }
117
+ console.log(JSON.stringify({ ok: true, ...stats }));
118
+ }
119
+ export async function cmdMemory(sub, positional, flags = {}) {
120
+ const memMod = await import('../memory.mjs');
121
+ const cfgDir = path.dirname(configPath());
122
+ switch (sub) {
123
+ case undefined:
124
+ case 'show': {
125
+ const which = positional[0] || 'core';
126
+ if (which === 'core') {
127
+ process.stdout.write(memMod.loadCore(cfgDir));
128
+ return;
129
+ }
130
+ if (which === 'recent') {
131
+ const n = flags.n !== undefined ? Number(flags.n) : 20;
132
+ console.log(JSON.stringify(memMod.loadRecent(n, cfgDir), null, 2));
133
+ return;
134
+ }
135
+ if (which === 'episodic') {
136
+ const topic = positional[1];
137
+ if (topic) { process.stdout.write(memMod.loadEpisodic(topic, cfgDir)); return; }
138
+ console.log(JSON.stringify(memMod.listEpisodic(cfgDir), null, 2));
139
+ return;
140
+ }
141
+ console.error(`unknown memory.show target: ${which} (expected: core, recent, episodic)`);
142
+ process.exit(2);
143
+ return;
144
+ }
145
+ case 'dream': {
146
+ await ensureRegistry();
147
+ const cfg = readConfig();
148
+ const provName = flags.provider || cfg.provider || 'mock';
149
+ const prov = getRegistry().PROVIDERS[provName];
150
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
151
+ const sid = positional[0] || flags.session || null;
152
+ try {
153
+ const result = await memMod.dream(sid, {
154
+ provider: prov,
155
+ model: flags.model || cfg.model,
156
+ apiKey: _resolveAuthKey(cfg, provName),
157
+ }, cfgDir);
158
+ console.log(JSON.stringify({ ok: true, ...result }, null, 2));
159
+ } catch (e) { console.error(`dream error: ${e?.message || e}`); process.exit(1); }
160
+ return;
161
+ }
162
+ case 'edit': {
163
+ const which = positional[0] || 'core';
164
+ if (which !== 'core') {
165
+ console.error('Only core.md is editable right now (episodic is LLM-curated, recent is append-only)');
166
+ process.exit(2);
167
+ }
168
+ const p = memMod.corePath(cfgDir);
169
+ const fs_ = await import('node:fs');
170
+ fs_.mkdirSync(memMod.memoryDir(cfgDir), { recursive: true });
171
+ if (!fs_.existsSync(p)) fs_.writeFileSync(p, '');
172
+ const editor = process.env.EDITOR || 'vi';
173
+ const { spawnSync } = await import('node:child_process');
174
+ // EDITOR=cat is the test-only escape hatch: spawnSync with stdio
175
+ // inherit makes the file's contents land on stdout and the
176
+ // command exits 0 without blocking.
177
+ const r = spawnSync(editor, [p], { stdio: 'inherit' });
178
+ if (r.status !== 0 && r.status !== null) {
179
+ console.error(`editor exited ${r.status}`);
180
+ process.exit(r.status);
181
+ }
182
+ return;
183
+ }
184
+ default:
185
+ console.error('Usage: lazyclaw memory <show|dream|edit> ...');
186
+ process.exit(2);
187
+ }
188
+ }
189
+ export async function cmdSessions(sub, positional, flags = {}) {
190
+ const sessionsMod = await import('../sessions.mjs');
191
+ const cfgDir = path.dirname(configPath());
192
+ switch (sub) {
193
+ case 'list': {
194
+ // --filter <substring> applies a case-insensitive id substring
195
+ // filter (no regex, deliberately — filtering on session ids is
196
+ // typically about prefixes or fragments).
197
+ // --limit <N> caps the result count after filter+sort. Negative
198
+ // or zero values are ignored so a script can pass `--limit 0`
199
+ // explicitly to opt out without special-casing.
200
+ // --with-turn-count: opt-in flag that adds `turnCount` per
201
+ // session. Loads each session file (one fs.read each) — opt-in
202
+ // because the default `list` should be fast even with thousands
203
+ // of sessions.
204
+ let items = sessionsMod.listSessions(cfgDir);
205
+ if (flags.filter) {
206
+ const f = String(flags.filter).toLowerCase();
207
+ items = items.filter(s => s.id.toLowerCase().includes(f));
208
+ }
209
+ if (flags.limit !== undefined) {
210
+ const n = parseInt(flags.limit, 10);
211
+ if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
212
+ }
213
+ let out = items.map(s => {
214
+ const base = { id: s.id, bytes: s.bytes, mtime: new Date(s.mtimeMs).toISOString(), _mtimeMs: s.mtimeMs };
215
+ if (flags['with-turn-count'] || flags['sort-by'] === 'turn-count') {
216
+ try { base.turnCount = sessionsMod.loadTurns(s.id, cfgDir).length; }
217
+ catch { base.turnCount = null; }
218
+ }
219
+ return base;
220
+ });
221
+ // --sort-by mtime|turn-count|bytes|id. Default is mtime descending
222
+ // (matches the underlying listSessions behavior). turn-count
223
+ // implicitly enables turnCount loading above.
224
+ if (flags['sort-by']) {
225
+ const valid = new Set(['mtime', 'turn-count', 'bytes', 'id']);
226
+ if (!valid.has(flags['sort-by'])) {
227
+ console.error(`invalid --sort-by: ${flags['sort-by']} (expected: mtime, turn-count, bytes, id)`);
228
+ process.exit(2);
229
+ }
230
+ const cmp = {
231
+ mtime: (a, b) => b._mtimeMs - a._mtimeMs,
232
+ 'turn-count': (a, b) => (b.turnCount ?? 0) - (a.turnCount ?? 0),
233
+ bytes: (a, b) => b.bytes - a.bytes,
234
+ id: (a, b) => a.id.localeCompare(b.id),
235
+ };
236
+ out.sort(cmp[flags['sort-by']]);
237
+ }
238
+ // Strip the internal helper field before serializing.
239
+ out = out.map(({ _mtimeMs, ...rest }) => rest);
240
+ console.log(JSON.stringify(out, null, 2));
241
+ return;
242
+ }
243
+ case 'show': {
244
+ const id = positional[0];
245
+ if (!id) { console.error('Usage: lazyclaw sessions show <id>'); process.exit(2); }
246
+ const turns = sessionsMod.loadTurns(id, cfgDir);
247
+ console.log(JSON.stringify(turns, null, 2));
248
+ return;
249
+ }
250
+ case 'clear': {
251
+ const id = positional[0];
252
+ if (!id) { console.error('Usage: lazyclaw sessions clear <id>'); process.exit(2); }
253
+ sessionsMod.clearSession(id, cfgDir);
254
+ console.log(JSON.stringify({ ok: true, cleared: id }));
255
+ return;
256
+ }
257
+ case 'export': {
258
+ const id = positional[0];
259
+ if (!id) { console.error('Usage: lazyclaw sessions export <id> [--format md|json|text]'); process.exit(2); }
260
+ const format = (flags.format || 'md').toLowerCase();
261
+ const formatters = {
262
+ md: sessionsMod.exportMarkdown,
263
+ markdown: sessionsMod.exportMarkdown,
264
+ json: sessionsMod.exportJson,
265
+ text: sessionsMod.exportText,
266
+ txt: sessionsMod.exportText,
267
+ };
268
+ const fn = formatters[format];
269
+ if (!fn) {
270
+ console.error(`unknown export format: ${format} (expected: md, json, text)`);
271
+ process.exit(2);
272
+ }
273
+ try { process.stdout.write(fn(id, cfgDir)); }
274
+ catch (e) { console.error(e.message); process.exit(1); }
275
+ return;
276
+ }
277
+ case 'search': {
278
+ const query = positional[0];
279
+ if (!query) { console.error('Usage: lazyclaw sessions search <query> [--regex]'); process.exit(2); }
280
+ // --regex came in via the parsed flags map (parseArgs lifted it
281
+ // out of positional). 'regex' is also in BOOLEAN_FLAGS so it
282
+ // never consumes the next argument.
283
+ const useRegex = !!flags.regex;
284
+ let matcher;
285
+ if (useRegex) {
286
+ try { matcher = new RegExp(query, 'i'); }
287
+ catch (e) { console.error(`invalid regex: ${e.message}`); process.exit(2); }
288
+ } else {
289
+ // Case-insensitive substring search. The naive `s.includes(q)`
290
+ // pattern is exactly what the user wants — same shape they'd
291
+ // get from `grep -i`.
292
+ const q = query.toLowerCase();
293
+ matcher = { test: (s) => String(s).toLowerCase().includes(q) };
294
+ }
295
+ const items = sessionsMod.listSessions(cfgDir);
296
+ const matches = [];
297
+ for (const s of items) {
298
+ const turns = sessionsMod.loadTurns(s.id, cfgDir);
299
+ let matchCount = 0;
300
+ let firstExcerpt = null;
301
+ for (const t of turns) {
302
+ if (typeof t?.content !== 'string') continue;
303
+ if (matcher.test(t.content)) {
304
+ matchCount++;
305
+ if (firstExcerpt === null) {
306
+ // Excerpt: 40 chars before/after first match, clamped at
307
+ // string boundaries. For regex matches we need to find
308
+ // the actual position; for substring use indexOf.
309
+ const c = t.content;
310
+ let pos;
311
+ if (useRegex) {
312
+ pos = c.search(matcher);
313
+ } else {
314
+ pos = c.toLowerCase().indexOf(query.toLowerCase());
315
+ }
316
+ if (pos < 0) pos = 0;
317
+ const start = Math.max(0, pos - 40);
318
+ const end = Math.min(c.length, pos + query.length + 40);
319
+ firstExcerpt = (start > 0 ? '…' : '') + c.slice(start, end) + (end < c.length ? '…' : '');
320
+ }
321
+ }
322
+ }
323
+ if (matchCount > 0) {
324
+ matches.push({
325
+ id: s.id,
326
+ mtime: new Date(s.mtimeMs).toISOString(),
327
+ matchCount,
328
+ excerpt: firstExcerpt,
329
+ });
330
+ }
331
+ }
332
+ console.log(JSON.stringify({ query, regex: useRegex, matches }, null, 2));
333
+ // Exit 0 even on no matches — `grep` convention is exit 1, but
334
+ // a CLI tool that returns JSON should always exit 0 on a
335
+ // successful search; the caller checks `matches.length` for
336
+ // emptiness.
337
+ return;
338
+ }
339
+ default:
340
+ console.error('Usage: lazyclaw sessions <list|show <id>|clear <id>|export <id>|search <query> [--regex]>');
341
+ process.exit(2);
342
+ }
343
+ }