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.
- package/channels/handoff.mjs +36 -0
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +73 -7399
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +255 -0
- package/commands/chat.mjs +1217 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +260 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +511 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +741 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +36 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +371 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +185 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/daemon.mjs +23 -2085
- package/dotenv_min.mjs +23 -0
- package/first_run.mjs +15 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/registry_boot.mjs +55 -0
- package/mas/agent_turn.mjs +2 -1
- package/mas/index_db.mjs +82 -0
- package/mas/learning.mjs +17 -1
- package/mas/mention_router.mjs +38 -10
- package/mas/provider_adapters.mjs +28 -4
- package/mas/scrub_env.mjs +34 -0
- package/mas/tool_runner.mjs +23 -7
- package/mas/tools/bash.mjs +10 -5
- package/mas/tools/browser.mjs +18 -0
- package/mas/tools/learning.mjs +24 -14
- package/mas/tools/recall.mjs +5 -1
- package/mas/tools/web.mjs +47 -11
- package/mas/trajectory_store.mjs +7 -4
- package/package.json +16 -2
- package/providers/auth_store.mjs +22 -0
- package/providers/claude_cli.mjs +28 -2
- package/providers/claude_cli_detect.mjs +46 -0
- package/providers/custom_provider.mjs +70 -0
- package/providers/model_catalogue.mjs +86 -0
- package/providers/orchestrator.mjs +30 -9
- package/providers/rates.mjs +12 -2
- package/providers/registry.mjs +10 -7
- package/sandbox/confiners/landlock.mjs +14 -8
- package/sandbox/confiners/seatbelt.mjs +18 -2
- package/scripts/loop-worker.mjs +18 -7
- package/scripts/migrate-v5.mjs +5 -61
- package/secure_write.mjs +46 -0
- package/sessions.mjs +0 -0
- package/tui/modal_filter.mjs +59 -0
- package/tui/modal_picker.mjs +12 -37
- package/tui/pickers.mjs +917 -0
- package/tui/provider_families.mjs +41 -0
- package/tui/repl.mjs +67 -36
- package/tui/slash_commands.mjs +2 -1
- package/tui/slash_dispatcher.mjs +717 -58
- package/tui/splash.mjs +5 -12
- package/tui/subcommands.mjs +17 -0
- package/tui/terminal_approve.mjs +37 -0
- package/web/dashboard.css +275 -0
- package/web/dashboard.html +2 -1685
- package/web/dashboard.js +1406 -0
- package/workflow/persistent.mjs +13 -6
- 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
|
+
}
|