lazyclaw 6.0.0 → 6.1.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.
- package/README.ko.md +88 -25
- package/README.md +121 -190
- package/channels/handoff.mjs +18 -5
- package/channels/matrix.mjs +23 -5
- package/channels/slack.mjs +83 -50
- package/channels/slack_env.mjs +45 -0
- package/channels/telegram.mjs +49 -6
- 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 +10 -21
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +254 -0
- package/commands/chat.mjs +1253 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +275 -0
- package/commands/gateway.mjs +194 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +490 -0
- package/commands/service.mjs +113 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +742 -0
- package/commands/setup_channels.mjs +207 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/config_features.mjs +106 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/inbound_dedup.mjs +108 -0
- package/daemon/lib/learn_queue.mjs +46 -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 +42 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +435 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +165 -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/dotenv_min.mjs +51 -0
- package/first_run.mjs +24 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/gateway_guard.mjs +100 -0
- package/lib/inbound_client.mjs +108 -0
- package/lib/registry_boot.mjs +55 -0
- package/lib/service_install.mjs +207 -0
- package/package.json +15 -3
- package/providers/probe.mjs +28 -0
- package/secure_write.mjs +46 -0
- package/tui/editor.mjs +18 -2
- package/tui/repl.mjs +25 -4
- package/tui/slash_commands.mjs +4 -0
- package/tui/slash_dispatcher.mjs +118 -0
- package/tui/splash_props.mjs +52 -0
- package/web/dashboard.css +6 -12
- package/web/dashboard.html +2 -34
- package/web/dashboard.js +3 -3
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Daemon route handlers (skills), extracted verbatim from makeHandler (D5).
|
|
2
|
+
// Each handler takes the per-request dispatch context `c` and returns the
|
|
3
|
+
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
|
+
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
|
|
5
|
+
|
|
6
|
+
export async function skillsList(c) {
|
|
7
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
8
|
+
// List installed skills with their first-line summary so a UI
|
|
9
|
+
// can render them without a follow-up read for each one.
|
|
10
|
+
// ?filter=<substr>&limit=<N> mirror the v3.33 CLI flags.
|
|
11
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
12
|
+
let items = listSkills(cfgDir);
|
|
13
|
+
const filter = url.searchParams.get('filter');
|
|
14
|
+
if (filter) {
|
|
15
|
+
const f = filter.toLowerCase();
|
|
16
|
+
items = items.filter(s => s.name.toLowerCase().includes(f));
|
|
17
|
+
}
|
|
18
|
+
const limitStr = url.searchParams.get('limit');
|
|
19
|
+
if (limitStr) {
|
|
20
|
+
const n = parseInt(limitStr, 10);
|
|
21
|
+
if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
|
|
22
|
+
}
|
|
23
|
+
// v5: include frontmatter fields the dashboard renders as badges.
|
|
24
|
+
// listSkills() currently only surfaces name/summary/etc., so we
|
|
25
|
+
// re-parse the body once per skill to extract trained_by /
|
|
26
|
+
// confidence / cross_cli_tested / group. Cheap (markdown files,
|
|
27
|
+
// already cached by the OS).
|
|
28
|
+
const out = items.map((s) => {
|
|
29
|
+
let meta = {};
|
|
30
|
+
try {
|
|
31
|
+
const body = loadSkill(s.name, cfgDir);
|
|
32
|
+
meta = parseFrontmatter(body).meta || {};
|
|
33
|
+
} catch { /* unreadable → empty meta */ }
|
|
34
|
+
const xcli = meta.cross_cli_tested;
|
|
35
|
+
return {
|
|
36
|
+
name: s.name,
|
|
37
|
+
bytes: s.bytes,
|
|
38
|
+
summary: s.summary,
|
|
39
|
+
description: meta.description || s.description || '',
|
|
40
|
+
group: meta.group || '',
|
|
41
|
+
trained_by: meta.trained_by || (meta.created_by === 'agent' ? 'agent' : ''),
|
|
42
|
+
confidence: meta.confidence !== undefined && meta.confidence !== ''
|
|
43
|
+
? Number(meta.confidence)
|
|
44
|
+
: null,
|
|
45
|
+
cross_cli_tested: xcli === 'true' || xcli === true ? true
|
|
46
|
+
: (Array.isArray(xcli) ? xcli : null),
|
|
47
|
+
version: meta.version || s.version || '',
|
|
48
|
+
created_by: meta.created_by || '',
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
return writeJson(res, 200, out);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function skillsSuggestions(c) {
|
|
55
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
56
|
+
// Ring buffer of nudge.suggest_skill events. Dashboard polls this
|
|
57
|
+
// since the SSE bus is deferred to v5.1.
|
|
58
|
+
return writeJson(res, 200, { suggestions: nudgeSuggestionsRing.slice(0, 20) });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function skillsSynth(c) {
|
|
62
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
63
|
+
// Body: { sessionId: '<id>' [, outcome] [, trainedBy] [, model] }
|
|
64
|
+
// Runs mas/skill_synth.synthesizeSkill against the named session.
|
|
65
|
+
// We assemble a minimal agent stub from cfg.provider/model and an
|
|
66
|
+
// empty role — the synth pipeline expects an agent object, but
|
|
67
|
+
// most callers will want "use my default provider".
|
|
68
|
+
let body;
|
|
69
|
+
try { body = await readJson(req); }
|
|
70
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
71
|
+
const sessionId = body && String(body.sessionId || '').trim();
|
|
72
|
+
if (!sessionId) return writeJson(res, 400, { error: 'sessionId is required' });
|
|
73
|
+
const cfg = ctx.readConfig();
|
|
74
|
+
const provider = body.provider || cfg.provider;
|
|
75
|
+
const model = body.model || cfg.model;
|
|
76
|
+
if (!provider) return writeJson(res, 400, { error: 'no provider configured — set cfg.provider or pass body.provider' });
|
|
77
|
+
// Pull the session turns and build a minimal task shape.
|
|
78
|
+
let turns;
|
|
79
|
+
try { turns = ctx.sessionsMod.loadTurns(sessionId, gwConfigDir); }
|
|
80
|
+
catch (e) { return writeJson(res, 404, { error: `session not found: ${sessionId}` }); }
|
|
81
|
+
if (!Array.isArray(turns) || turns.length === 0) {
|
|
82
|
+
return writeJson(res, 400, { error: `session "${sessionId}" has no turns` });
|
|
83
|
+
}
|
|
84
|
+
const task = {
|
|
85
|
+
id: sessionId,
|
|
86
|
+
title: turns[0]?.content?.slice(0, 80) || sessionId,
|
|
87
|
+
turns: turns.map((t) => ({
|
|
88
|
+
agent: t.role === 'user' ? 'user' : (t.role === 'system' ? 'system' : 'assistant'),
|
|
89
|
+
text: String(t.content || ''),
|
|
90
|
+
})),
|
|
91
|
+
};
|
|
92
|
+
const agent = { provider, model, role: '' };
|
|
93
|
+
try {
|
|
94
|
+
const apiKey = cfg['api-key'] || null;
|
|
95
|
+
const result = await skillSynth.synthesizeSkill({
|
|
96
|
+
agent, task, apiKey,
|
|
97
|
+
outcome: body.outcome || 'done',
|
|
98
|
+
trainedBy: body.trainedBy || null,
|
|
99
|
+
trainedOnModel: model || null,
|
|
100
|
+
});
|
|
101
|
+
if (!result) return writeJson(res, 200, { ok: false, message: 'synth produced no skill (model returned NONE)' });
|
|
102
|
+
// Mirror the CLI synth flow: installSynthesized() handles slug
|
|
103
|
+
// reservation, agent-overwrite protection, and FTS5 mirror.
|
|
104
|
+
const install = skillSynth.installSynthesized({
|
|
105
|
+
name: result.name,
|
|
106
|
+
description: result.description,
|
|
107
|
+
body: result.body,
|
|
108
|
+
sourceTask: sessionId,
|
|
109
|
+
createdBy: 'agent',
|
|
110
|
+
}, gwConfigDir);
|
|
111
|
+
return writeJson(res, 200, {
|
|
112
|
+
ok: true,
|
|
113
|
+
name: install?.skill || result.name,
|
|
114
|
+
description: result.description,
|
|
115
|
+
path: install?.path || null,
|
|
116
|
+
});
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return writeJson(res, 500, { error: e?.message || String(e), code: e?.code });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function skillsSearch(c) {
|
|
123
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
124
|
+
// Mirror of `lazyclaw skills search`. ?q=<query> required;
|
|
125
|
+
// ?regex=true switches to regex mode. Returns
|
|
126
|
+
// { query, regex, matches: [{ name, bytes, matchCount, excerpt }] }
|
|
127
|
+
// — same shape the CLI prints. A dashboard skill picker can
|
|
128
|
+
// hit this endpoint instead of pulling every skill body and
|
|
129
|
+
// searching client-side.
|
|
130
|
+
const q = url.searchParams.get('q');
|
|
131
|
+
if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
|
|
132
|
+
const useRegex = url.searchParams.get('regex') === 'true';
|
|
133
|
+
let matcher;
|
|
134
|
+
if (useRegex) {
|
|
135
|
+
try { matcher = new RegExp(q, 'gi'); }
|
|
136
|
+
catch (e) { return writeJson(res, 400, { error: `invalid regex: ${e.message}` }); }
|
|
137
|
+
}
|
|
138
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
139
|
+
const items = listSkills(cfgDir);
|
|
140
|
+
const matches = [];
|
|
141
|
+
for (const s of items) {
|
|
142
|
+
let body;
|
|
143
|
+
try { body = loadSkill(s.name, cfgDir); } catch { continue; }
|
|
144
|
+
let matchCount = 0;
|
|
145
|
+
let firstExcerpt = null;
|
|
146
|
+
if (useRegex) {
|
|
147
|
+
for (const m of body.matchAll(matcher)) {
|
|
148
|
+
matchCount++;
|
|
149
|
+
if (firstExcerpt === null) {
|
|
150
|
+
const pos = m.index ?? 0;
|
|
151
|
+
const start = Math.max(0, pos - 40);
|
|
152
|
+
const end = Math.min(body.length, pos + m[0].length + 40);
|
|
153
|
+
firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
const lower = body.toLowerCase();
|
|
158
|
+
const ql = q.toLowerCase();
|
|
159
|
+
let pos = 0;
|
|
160
|
+
while (true) {
|
|
161
|
+
const i = lower.indexOf(ql, pos);
|
|
162
|
+
if (i < 0) break;
|
|
163
|
+
matchCount++;
|
|
164
|
+
if (firstExcerpt === null) {
|
|
165
|
+
const start = Math.max(0, i - 40);
|
|
166
|
+
const end = Math.min(body.length, i + ql.length + 40);
|
|
167
|
+
firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
|
|
168
|
+
}
|
|
169
|
+
pos = i + ql.length;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (matchCount > 0) {
|
|
173
|
+
matches.push({ name: s.name, bytes: s.bytes, matchCount, excerpt: firstExcerpt });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return writeJson(res, 200, { query: q, regex: useRegex, matches });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function skillGet(c) {
|
|
180
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
181
|
+
// GET /skills/<name> — full markdown body as text/markdown.
|
|
182
|
+
// 404 when the file is missing so the caller can branch.
|
|
183
|
+
// 400 when the name fails skillPath validation (path traversal,
|
|
184
|
+
// dotfile, etc.) — same protections as the CLI.
|
|
185
|
+
// m13 — decodeURIComponent before validation (see PUT below).
|
|
186
|
+
let name;
|
|
187
|
+
try { name = decodeURIComponent(skillMatch[1]); }
|
|
188
|
+
catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
|
|
189
|
+
try {
|
|
190
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
191
|
+
const file = skillPath(name, cfgDir);
|
|
192
|
+
if (!(await fileExists(file))) return writeJson(res, 404, { error: 'skill not found', name });
|
|
193
|
+
const body = loadSkill(name, cfgDir);
|
|
194
|
+
res.writeHead(200, {
|
|
195
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
196
|
+
'content-length': Buffer.byteLength(body),
|
|
197
|
+
});
|
|
198
|
+
return res.end(body);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function skillPut(c) {
|
|
205
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
206
|
+
// PUT /skills/<name> body = markdown text
|
|
207
|
+
// 201 on first write, 200 on overwrite (caller can branch on
|
|
208
|
+
// the status if they care about idempotency vs newness).
|
|
209
|
+
// 400 on invalid name (skillPath validation) or oversize body.
|
|
210
|
+
// m13 — decodeURIComponent the segment before validation so
|
|
211
|
+
// a request like `PUT /skills/foo%2Fbar` is rejected as a path-
|
|
212
|
+
// separator (slash) rather than letting the literal-percent
|
|
213
|
+
// filename slip through.
|
|
214
|
+
let name;
|
|
215
|
+
try { name = decodeURIComponent(skillMatch[1]); }
|
|
216
|
+
catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
|
|
217
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
218
|
+
let priorExists = false;
|
|
219
|
+
try {
|
|
220
|
+
// Validate name before reading the body so a bogus name fails
|
|
221
|
+
// fast and we don't waste bandwidth.
|
|
222
|
+
const file = skillPath(name, cfgDir);
|
|
223
|
+
priorExists = await fileExists(file);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
226
|
+
}
|
|
227
|
+
let body;
|
|
228
|
+
try { body = await readTextBody(req); }
|
|
229
|
+
catch (err) { return writeJson(res, 400, { error: err?.message || String(err) }); }
|
|
230
|
+
try {
|
|
231
|
+
const written = installSkill(name, body, cfgDir);
|
|
232
|
+
return writeJson(res, priorExists ? 200 : 201, {
|
|
233
|
+
ok: true, name, path: written, bytes: body.length, replaced: priorExists,
|
|
234
|
+
});
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function skillDelete(c) {
|
|
241
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
242
|
+
// DELETE /skills/<name> idempotent: 200 whether the file
|
|
243
|
+
// existed or not, mirroring DELETE /sessions/<id>. The body
|
|
244
|
+
// reports `removed: true|false` so callers can branch when
|
|
245
|
+
// they care.
|
|
246
|
+
// m13 — decodeURIComponent before validation (see PUT below).
|
|
247
|
+
let name;
|
|
248
|
+
try { name = decodeURIComponent(skillMatch[1]); }
|
|
249
|
+
catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
|
|
250
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
251
|
+
try {
|
|
252
|
+
const file = skillPath(name, cfgDir);
|
|
253
|
+
const existed = await fileExists(file);
|
|
254
|
+
removeSkill(name, cfgDir);
|
|
255
|
+
return writeJson(res, 200, { ok: true, name, removed: existed });
|
|
256
|
+
} catch (err) {
|
|
257
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Daemon route handlers (workflows), extracted verbatim from makeHandler (D5).
|
|
2
|
+
// Each handler takes the per-request dispatch context `c` and returns the
|
|
3
|
+
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
|
+
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
|
|
5
|
+
|
|
6
|
+
export async function workflowsAggregate(c) {
|
|
7
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
8
|
+
// Mirror of CLI v3.48 `lazyclaw inspect --aggregate`. Per-node
|
|
9
|
+
// statistics across every persisted session in the state
|
|
10
|
+
// directory. Answers "which node tends to be slow / fail
|
|
11
|
+
// across all my runs?" — needs no workflow file, just state.
|
|
12
|
+
// ?filter=<substr> applies before aggregation (v3.50).
|
|
13
|
+
const stateDir = ctx.workflowStateDir();
|
|
14
|
+
const qFilter = url.searchParams.get('filter');
|
|
15
|
+
const qNode = url.searchParams.get('node');
|
|
16
|
+
try {
|
|
17
|
+
const stats = aggregateNodeStats(stateDir, { filter: qFilter });
|
|
18
|
+
// ?node=<id>: drill into one node's cross-session stats
|
|
19
|
+
// (mirrors CLI v3.52 --aggregate --node). 404 when the
|
|
20
|
+
// node never appeared in any session.
|
|
21
|
+
if (qNode) {
|
|
22
|
+
const nodeStat = stats.nodeStats[qNode];
|
|
23
|
+
if (!nodeStat) {
|
|
24
|
+
return writeJson(res, 404, {
|
|
25
|
+
error: 'node not found across sessions',
|
|
26
|
+
nodeId: qNode,
|
|
27
|
+
knownNodes: Object.keys(stats.nodeStats),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return writeJson(res, 200, {
|
|
31
|
+
dir: stateDir,
|
|
32
|
+
filter: qFilter || null,
|
|
33
|
+
sessionCount: stats.sessionCount,
|
|
34
|
+
nodeId: qNode,
|
|
35
|
+
...nodeStat,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, ...stats });
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err?.code === 'ENOENT') {
|
|
41
|
+
return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, sessionCount: 0, nodeStats: {} });
|
|
42
|
+
}
|
|
43
|
+
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function workflowsList(c) {
|
|
48
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
49
|
+
// List every persisted workflow session in the configured
|
|
50
|
+
// state dir, newest activity first. Mirrors `lazyclaw inspect`
|
|
51
|
+
// (no-arg) exactly so a dashboard can use the same renderer
|
|
52
|
+
// for CLI and HTTP outputs. Per-node `nodes` map is omitted —
|
|
53
|
+
// call /workflows/<sessionId> for full detail.
|
|
54
|
+
//
|
|
55
|
+
// ?status=done|resumable|failed|running mirrors the CLI's
|
|
56
|
+
// --status flag — one shared predicate so a UI can paginate
|
|
57
|
+
// by bucket without pulling the full list.
|
|
58
|
+
const stateDir = ctx.workflowStateDir();
|
|
59
|
+
const qStatus = url.searchParams.get('status');
|
|
60
|
+
if (qStatus) {
|
|
61
|
+
const valid = new Set(['done', 'resumable', 'failed', 'running']);
|
|
62
|
+
if (!valid.has(qStatus)) {
|
|
63
|
+
return writeJson(res, 400, {
|
|
64
|
+
error: `invalid status: ${qStatus}`,
|
|
65
|
+
expected: [...valid],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
let sessions = listWorkflowSessions(stateDir);
|
|
71
|
+
if (qStatus) {
|
|
72
|
+
sessions = sessions.filter(s => {
|
|
73
|
+
if (qStatus === 'done') return s.summary.done;
|
|
74
|
+
if (qStatus === 'resumable') return s.summary.resumable;
|
|
75
|
+
if (qStatus === 'failed') return s.summary.failed > 0;
|
|
76
|
+
if (qStatus === 'running') return s.summary.running > 0;
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
// ?filter=<substr>&limit=<N> mirror v3.33 sessions/skills list flags.
|
|
81
|
+
const qFilter = url.searchParams.get('filter');
|
|
82
|
+
if (qFilter) {
|
|
83
|
+
const f = qFilter.toLowerCase();
|
|
84
|
+
sessions = sessions.filter(s => s.sessionId.toLowerCase().includes(f));
|
|
85
|
+
}
|
|
86
|
+
const qLimit = url.searchParams.get('limit');
|
|
87
|
+
if (qLimit) {
|
|
88
|
+
const n = parseInt(qLimit, 10);
|
|
89
|
+
if (Number.isFinite(n) && n > 0) sessions = sessions.slice(0, n);
|
|
90
|
+
}
|
|
91
|
+
return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err?.code === 'ENOENT') {
|
|
94
|
+
// Empty dir is a valid state (no workflows ever ran). The
|
|
95
|
+
// CLI distinguishes "missing dir" from "empty dir" — the
|
|
96
|
+
// daemon collapses both to an empty array so a fresh
|
|
97
|
+
// process doesn't 404 a UI poll loop.
|
|
98
|
+
return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions: [] });
|
|
99
|
+
}
|
|
100
|
+
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function workflowGet(c) {
|
|
105
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
106
|
+
// GET /workflows/<sessionId> — full state of a single
|
|
107
|
+
// workflow run. Same shape as `lazyclaw inspect <id>` (the
|
|
108
|
+
// engine's persisted object plus a derived summary block).
|
|
109
|
+
// 404 when the state file is missing.
|
|
110
|
+
const sid = workflowMatch[1];
|
|
111
|
+
const stateDir = ctx.workflowStateDir();
|
|
112
|
+
let state;
|
|
113
|
+
try {
|
|
114
|
+
state = loadWorkflowState(sid, stateDir);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
117
|
+
}
|
|
118
|
+
if (!state) return writeJson(res, 404, { error: 'workflow not found', sessionId: sid });
|
|
119
|
+
// ?node=<id> drills into one node's state — same shape as
|
|
120
|
+
// `lazyclaw inspect <session> --node <id>` (v3.41). The
|
|
121
|
+
// HTTP status reflects the node's lifecycle (mirrors the
|
|
122
|
+
// CLI exit codes): 200 success/pending/running, 410 Gone
|
|
123
|
+
// for failed (request was valid, but the resource is in a
|
|
124
|
+
// failed state), 404 for unknown node id.
|
|
125
|
+
// ?slowest=<N>: top N nodes by durationMs. Same shape as
|
|
126
|
+
// CLI v3.44 — pure state-file analysis, no deps needed.
|
|
127
|
+
const qSlowest = url.searchParams.get('slowest');
|
|
128
|
+
if (qSlowest) {
|
|
129
|
+
const n = parseInt(qSlowest, 10);
|
|
130
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
131
|
+
return writeJson(res, 400, {
|
|
132
|
+
error: `slowest must be a positive integer (got ${JSON.stringify(qSlowest)})`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const entries = Object.entries(state.nodes || {}).map(([id, ns]) => ({
|
|
136
|
+
id,
|
|
137
|
+
status: ns?.status || 'pending',
|
|
138
|
+
durationMs: Number.isFinite(ns?.durationMs) ? ns.durationMs : 0,
|
|
139
|
+
attempts: ns?.attempts ?? 0,
|
|
140
|
+
}));
|
|
141
|
+
entries.sort((a, b) => (b.durationMs - a.durationMs) || a.id.localeCompare(b.id));
|
|
142
|
+
return writeJson(res, 200, {
|
|
143
|
+
sessionId: state.sessionId,
|
|
144
|
+
top: entries.slice(0, n),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const qNode = url.searchParams.get('node');
|
|
148
|
+
if (qNode) {
|
|
149
|
+
const ns = state.nodes?.[qNode];
|
|
150
|
+
if (!ns) {
|
|
151
|
+
return writeJson(res, 404, {
|
|
152
|
+
error: 'node not found',
|
|
153
|
+
sessionId: sid,
|
|
154
|
+
nodeId: qNode,
|
|
155
|
+
knownNodes: Object.keys(state.nodes || {}),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return writeJson(res, ns.status === 'failed' ? 410 : 200, {
|
|
159
|
+
sessionId: state.sessionId,
|
|
160
|
+
nodeId: qNode,
|
|
161
|
+
...ns,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const { summary, failedNodes } = summarizeState(state);
|
|
165
|
+
// ?summary=true trims the per-node `nodes` map and `order`
|
|
166
|
+
// array, matching v3.17's CLI `inspect --summary` shape and
|
|
167
|
+
// the per-session shape that list-mode produces. A UI fetching
|
|
168
|
+
// this endpoint to render a status badge doesn't want the
|
|
169
|
+
// full per-node payload — `?summary=true` keeps the wire
|
|
170
|
+
// small for high-frequency polls.
|
|
171
|
+
const compact = url.searchParams.get('summary') === 'true';
|
|
172
|
+
const body = compact
|
|
173
|
+
? {
|
|
174
|
+
sessionId: state.sessionId,
|
|
175
|
+
dir: stateDir,
|
|
176
|
+
summary,
|
|
177
|
+
failedNodes,
|
|
178
|
+
startedAt: state.startedAt,
|
|
179
|
+
updatedAt: state.updatedAt,
|
|
180
|
+
}
|
|
181
|
+
: {
|
|
182
|
+
sessionId: state.sessionId,
|
|
183
|
+
dir: stateDir,
|
|
184
|
+
summary,
|
|
185
|
+
failedNodes,
|
|
186
|
+
order: state.order,
|
|
187
|
+
nodes: state.nodes,
|
|
188
|
+
startedAt: state.startedAt,
|
|
189
|
+
updatedAt: state.updatedAt,
|
|
190
|
+
};
|
|
191
|
+
return writeJson(res, 200, body);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function workflowDelete(c) {
|
|
195
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
196
|
+
// DELETE /workflows/<sessionId> — idempotent: 200 with
|
|
197
|
+
// `removed: true|false`. Same protection as the rest of the
|
|
198
|
+
// delete routes — only files inside the configured state dir
|
|
199
|
+
// are touched. The path matcher already rejects `..` and `/`,
|
|
200
|
+
// and we re-resolve via path.join so a sessionId that resolves
|
|
201
|
+
// outside the dir is rejected with 400.
|
|
202
|
+
const sid = workflowMatch[1];
|
|
203
|
+
const stateDir = ctx.workflowStateDir();
|
|
204
|
+
// Note: `path` is shadowed inside this handler by the URL path
|
|
205
|
+
// variable above — use `nodePath` (aliased import) for fs ops.
|
|
206
|
+
const file = nodePath.join(stateDir, `${sid}.json`);
|
|
207
|
+
// Confined-path check: file must resolve under stateDir. fs.realpathSync
|
|
208
|
+
// would resolve symlinks too, but the dir may not exist yet — use
|
|
209
|
+
// the resolved string-prefix check, which is enough since stateDir
|
|
210
|
+
// is operator-controlled.
|
|
211
|
+
const resolvedDir = nodePath.resolve(stateDir);
|
|
212
|
+
const resolvedFile = nodePath.resolve(file);
|
|
213
|
+
if (!resolvedFile.startsWith(resolvedDir + nodePath.sep) && resolvedFile !== resolvedDir) {
|
|
214
|
+
return writeJson(res, 400, { error: 'invalid sessionId' });
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const existed = fs.existsSync(resolvedFile);
|
|
218
|
+
if (existed) fs.unlinkSync(resolvedFile);
|
|
219
|
+
return writeJson(res, 200, { ok: true, sessionId: sid, removed: existed });
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return writeJson(res, 500, { error: err?.message || String(err) });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
package/dotenv_min.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// dotenv_min.mjs — minimal .env loader shared by the CLI and the Ink slash
|
|
2
|
+
// dispatcher. Loads <cfgDir>/.env into process.env (without overwriting
|
|
3
|
+
// already-set vars) so Slack / provider tokens are available. Extracted from
|
|
4
|
+
// cli.mjs `_loadDotenvIfAny` so the /task Slack-close flow can reuse it.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { writeTextSecure } from './secure_write.mjs';
|
|
9
|
+
|
|
10
|
+
export function loadDotenvIfAny(cfgDir) {
|
|
11
|
+
const p = path.join(cfgDir, '.env');
|
|
12
|
+
if (!fs.existsSync(p)) return { path: p, loaded: 0 };
|
|
13
|
+
let loaded = 0;
|
|
14
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
15
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
16
|
+
if (!line || line.trimStart().startsWith('#')) continue;
|
|
17
|
+
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
|
|
18
|
+
if (!m) continue;
|
|
19
|
+
let val = m[2].trim();
|
|
20
|
+
if (val.length >= 2 && val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
|
21
|
+
if (process.env[m[1]] === undefined) { process.env[m[1]] = val; loaded++; }
|
|
22
|
+
}
|
|
23
|
+
return { path: p, loaded };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Read-merge-write <cfgDir>/.env at 0600. Existing keys are preserved;
|
|
27
|
+
// keys present in `vars` are overwritten. Values are written verbatim
|
|
28
|
+
// (no quoting) — callers pass already-trimmed strings. Returns the path.
|
|
29
|
+
// Mirror of loadDotenvIfAny so .env read + write live together.
|
|
30
|
+
export function writeDotenvMerge(cfgDir, vars) {
|
|
31
|
+
const p = path.join(cfgDir, '.env');
|
|
32
|
+
const lines = [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
const existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
|
|
35
|
+
for (const line of existing.split(/\r?\n/)) {
|
|
36
|
+
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=/);
|
|
37
|
+
if (m && Object.prototype.hasOwnProperty.call(vars, m[1])) {
|
|
38
|
+
seen.add(m[1]);
|
|
39
|
+
lines.push(`${m[1]}=${vars[m[1]]}`);
|
|
40
|
+
} else {
|
|
41
|
+
lines.push(line);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
45
|
+
if (!seen.has(k)) lines.push(`${k}=${v}`);
|
|
46
|
+
}
|
|
47
|
+
let text = lines.join('\n').replace(/\n+$/, '');
|
|
48
|
+
if (text) text += '\n';
|
|
49
|
+
writeTextSecure(p, text);
|
|
50
|
+
return p;
|
|
51
|
+
}
|
package/first_run.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// first_run.mjs — decide what onboarding the chat entrypoint runs.
|
|
2
|
+
//
|
|
3
|
+
// 'setup' — genuine fresh install (no provider, interactive): run the full
|
|
4
|
+
// 5-step guided setup (provider+model, workspace, skills) instead
|
|
5
|
+
// of only the provider picker the Ink path used to run.
|
|
6
|
+
// 'pick' — explicit `chat --pick`: just re-pick provider/model.
|
|
7
|
+
// 'none' — provider already configured, or non-interactive (automation).
|
|
8
|
+
//
|
|
9
|
+
// Pure so it unit-tests without a TTY.
|
|
10
|
+
export function firstRunMode({ hasProvider, flagPick, isTTY }) {
|
|
11
|
+
if (!isTTY) return 'none';
|
|
12
|
+
if (flagPick) return 'pick';
|
|
13
|
+
if (!hasProvider) return 'setup';
|
|
14
|
+
return 'none';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A blank or 'mock' provider is a placeholder, not a real configured choice:
|
|
18
|
+
// the mock provider returns canned replies and v5.3.2 stopped treating it as a
|
|
19
|
+
// usable default. Treat both as "not configured" so the very first run always
|
|
20
|
+
// lands in setup, and only a genuine saved provider skips it afterwards.
|
|
21
|
+
const PLACEHOLDER_PROVIDERS = new Set(['', 'mock']);
|
|
22
|
+
export function hasConfiguredProvider(provider) {
|
|
23
|
+
return !PLACEHOLDER_PROVIDERS.has(String(provider ?? '').trim().toLowerCase());
|
|
24
|
+
}
|
package/goals_cron.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// goals_cron.mjs — attach/detach a cron schedule to a goal.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from cli.mjs (_attachGoalCron / _detachGoalCron) so the Ink
|
|
4
|
+
// /goal slash can actually schedule (and unschedule) a goal instead of just
|
|
5
|
+
// recording the cron string and telling the user to re-run from the CLI.
|
|
6
|
+
// Dependency-injected on readConfig / writeConfig and the cron module so the
|
|
7
|
+
// core unit-tests with a fake cron and no real launchd/crontab writes.
|
|
8
|
+
// `LAZYCLAW_SKIP_CRON_INSTALL` skips the backend install (config still written).
|
|
9
|
+
|
|
10
|
+
export async function attachGoalCron({ readConfig, writeConfig, cron, name, schedule }) {
|
|
11
|
+
cron.parseCronSpec(schedule); // validate before touching state
|
|
12
|
+
const cfg = readConfig();
|
|
13
|
+
const jobName = `goal-${name}`;
|
|
14
|
+
const cmd = ['lazyclaw', 'goal', 'tick', name];
|
|
15
|
+
cron.upsertJob(cfg, jobName, schedule, cmd);
|
|
16
|
+
writeConfig(cfg);
|
|
17
|
+
if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return { jobName, skipped: true };
|
|
18
|
+
const backend = cron.pickBackend();
|
|
19
|
+
if (backend === 'launchd') cron.installLaunchdJob(jobName, schedule, cmd);
|
|
20
|
+
else cron.installCrontabJob(jobName, schedule, cmd);
|
|
21
|
+
return { jobName, skipped: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function detachGoalCron({ readConfig, writeConfig, cron, name }) {
|
|
25
|
+
const cfg = readConfig();
|
|
26
|
+
const jobName = `goal-${name}`;
|
|
27
|
+
if (!cfg.cron || !cfg.cron[jobName]) return false;
|
|
28
|
+
cron.removeJob(cfg, jobName);
|
|
29
|
+
writeConfig(cfg);
|
|
30
|
+
if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return true;
|
|
31
|
+
const backend = cron.pickBackend();
|
|
32
|
+
try {
|
|
33
|
+
if (backend === 'launchd') cron.uninstallLaunchdJob(jobName);
|
|
34
|
+
else cron.uninstallCrontabJob(jobName);
|
|
35
|
+
} catch { /* best-effort — cron sync recovers */ }
|
|
36
|
+
return true;
|
|
37
|
+
}
|