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,223 @@
|
|
|
1
|
+
// Daemon route handlers (registry), 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 agentsList(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
|
+
const mod = await import('../../agents.mjs');
|
|
9
|
+
return writeJson(res, 200, mod.listAgents());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function agentsCreate(c) {
|
|
13
|
+
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;
|
|
14
|
+
const mod = await import('../../agents.mjs');
|
|
15
|
+
let body;
|
|
16
|
+
try { body = await readJson(req); }
|
|
17
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
18
|
+
try { return writeJson(res, 200, mod.registerAgent(body)); }
|
|
19
|
+
catch (err) {
|
|
20
|
+
const code = err?.code === 'AGENT_EXISTS' ? 409 : 400;
|
|
21
|
+
return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function agentGet(c) {
|
|
26
|
+
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;
|
|
27
|
+
const name = url.pathname.split('/').pop();
|
|
28
|
+
const mod = await import('../../agents.mjs');
|
|
29
|
+
const a = mod.getAgent(name);
|
|
30
|
+
if (!a) return writeJson(res, 404, { error: `no agent "${name}"` });
|
|
31
|
+
return writeJson(res, 200, a);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function agentPatch(c) {
|
|
35
|
+
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;
|
|
36
|
+
const name = url.pathname.split('/').pop();
|
|
37
|
+
const mod = await import('../../agents.mjs');
|
|
38
|
+
let body;
|
|
39
|
+
try { body = await readJson(req); }
|
|
40
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
41
|
+
try { return writeJson(res, 200, mod.patchAgent(name, body)); }
|
|
42
|
+
catch (err) {
|
|
43
|
+
const code = err?.code === 'AGENT_NO_AGENT' ? 404 : 400;
|
|
44
|
+
return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function agentDelete(c) {
|
|
49
|
+
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;
|
|
50
|
+
const name = url.pathname.split('/').pop();
|
|
51
|
+
const mod = await import('../../agents.mjs');
|
|
52
|
+
try { return writeJson(res, 200, mod.removeAgent(name)); }
|
|
53
|
+
catch (err) {
|
|
54
|
+
return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function agentMemoryGet(c) {
|
|
59
|
+
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;
|
|
60
|
+
// M13 — 404 when the agent is not registered. The historical
|
|
61
|
+
// behaviour silently returned an empty body, which made
|
|
62
|
+
// typos indistinguishable from "no memory yet" and let the
|
|
63
|
+
// dashboard render a stub for a non-existent agent.
|
|
64
|
+
const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
|
|
65
|
+
const agentsMod = await import('../../agents.mjs');
|
|
66
|
+
if (!agentsMod.getAgent(name)) {
|
|
67
|
+
return writeJson(res, 404, { error: `no agent "${name}"`, name });
|
|
68
|
+
}
|
|
69
|
+
const memMod = await import('../../mas/agent_memory.mjs');
|
|
70
|
+
const text = memMod.readMemory(name);
|
|
71
|
+
res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-cache' });
|
|
72
|
+
return res.end(text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function agentMemoryPut(c) {
|
|
76
|
+
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;
|
|
77
|
+
// M13 — 404 when the agent is not registered. Without this
|
|
78
|
+
// check, writeRaw happily created memory.md for a misspelled
|
|
79
|
+
// agent name and the orphan file lived forever.
|
|
80
|
+
const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
|
|
81
|
+
const agentsMod = await import('../../agents.mjs');
|
|
82
|
+
if (!agentsMod.getAgent(name)) {
|
|
83
|
+
return writeJson(res, 404, { error: `no agent "${name}"`, name });
|
|
84
|
+
}
|
|
85
|
+
const memMod = await import('../../mas/agent_memory.mjs');
|
|
86
|
+
// Read raw text body — content-type defaults to text/markdown
|
|
87
|
+
// but JSON {"text": "..."} is also accepted for tooling that
|
|
88
|
+
// prefers structured bodies.
|
|
89
|
+
let body = '';
|
|
90
|
+
await new Promise((resolve) => {
|
|
91
|
+
req.on('data', (c) => { body += c.toString(); });
|
|
92
|
+
req.on('end', resolve);
|
|
93
|
+
});
|
|
94
|
+
let text = body;
|
|
95
|
+
if (req.headers['content-type']?.includes('application/json')) {
|
|
96
|
+
try { text = (JSON.parse(body || '{}').text) || ''; } catch { /* leave raw */ }
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const p = memMod.writeRaw(name, text);
|
|
100
|
+
return writeJson(res, 200, { path: p, bytes: Buffer.byteLength(text, 'utf8') });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return writeJson(res, 400, { error: err?.message || String(err), code: err?.code });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function agentMemoryDelete(c) {
|
|
107
|
+
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;
|
|
108
|
+
const name = url.pathname.match(/^\/agents\/([^/]+)\/memory$/)[1];
|
|
109
|
+
const agentsMod = await import('../../agents.mjs');
|
|
110
|
+
if (!agentsMod.getAgent(name)) {
|
|
111
|
+
return writeJson(res, 404, { error: `no agent "${name}"`, name });
|
|
112
|
+
}
|
|
113
|
+
const memMod = await import('../../mas/agent_memory.mjs');
|
|
114
|
+
const removed = memMod.clear(name);
|
|
115
|
+
return writeJson(res, 200, { name, cleared: removed });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function teamsList(c) {
|
|
119
|
+
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;
|
|
120
|
+
const mod = await import('../../teams.mjs');
|
|
121
|
+
return writeJson(res, 200, mod.listTeams());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function teamsCreate(c) {
|
|
125
|
+
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;
|
|
126
|
+
const mod = await import('../../teams.mjs');
|
|
127
|
+
let body;
|
|
128
|
+
try { body = await readJson(req); }
|
|
129
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
130
|
+
try { return writeJson(res, 200, mod.registerTeam(body)); }
|
|
131
|
+
catch (err) {
|
|
132
|
+
const code = err?.code === 'TEAM_EXISTS' ? 409 : 400;
|
|
133
|
+
return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function teamGet(c) {
|
|
138
|
+
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;
|
|
139
|
+
const name = url.pathname.split('/').pop();
|
|
140
|
+
const mod = await import('../../teams.mjs');
|
|
141
|
+
const t = mod.getTeam(name);
|
|
142
|
+
if (!t) return writeJson(res, 404, { error: `no team "${name}"` });
|
|
143
|
+
return writeJson(res, 200, t);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function teamPatch(c) {
|
|
147
|
+
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;
|
|
148
|
+
const name = url.pathname.split('/').pop();
|
|
149
|
+
const mod = await import('../../teams.mjs');
|
|
150
|
+
let body;
|
|
151
|
+
try { body = await readJson(req); }
|
|
152
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
153
|
+
try { return writeJson(res, 200, mod.patchTeam(name, body)); }
|
|
154
|
+
catch (err) {
|
|
155
|
+
const code = err?.code === 'TEAM_NO_TEAM' ? 404 : 400;
|
|
156
|
+
return writeJson(res, code, { error: err?.message || String(err), code: err?.code });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function teamDelete(c) {
|
|
161
|
+
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;
|
|
162
|
+
const name = url.pathname.split('/').pop();
|
|
163
|
+
const mod = await import('../../teams.mjs');
|
|
164
|
+
try { return writeJson(res, 200, mod.removeTeam(name)); }
|
|
165
|
+
catch (err) {
|
|
166
|
+
return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function tasksList(c) {
|
|
171
|
+
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;
|
|
172
|
+
const mod = await import('../../tasks.mjs');
|
|
173
|
+
return writeJson(res, 200, mod.listTasks());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function taskTranscript(c) {
|
|
177
|
+
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;
|
|
178
|
+
const m = url.pathname.match(/^\/tasks\/([^/]+)\/transcript$/);
|
|
179
|
+
const id = m[1];
|
|
180
|
+
const mod = await import('../../tasks.mjs');
|
|
181
|
+
const t = mod.getTask(id);
|
|
182
|
+
if (!t) return writeJson(res, 404, { error: `no task "${id}"` });
|
|
183
|
+
const fmt = String(url.searchParams.get('format') || 'text');
|
|
184
|
+
if (fmt === 'json') return writeJson(res, 200, t);
|
|
185
|
+
const body = mod.formatTranscript(t, fmt);
|
|
186
|
+
const mime = fmt === 'md' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8';
|
|
187
|
+
res.writeHead(200, { 'content-type': mime, 'cache-control': 'no-cache' });
|
|
188
|
+
return res.end(body);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function taskGet(c) {
|
|
192
|
+
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;
|
|
193
|
+
const id = url.pathname.split('/').pop();
|
|
194
|
+
const mod = await import('../../tasks.mjs');
|
|
195
|
+
const t = mod.getTask(id);
|
|
196
|
+
if (!t) return writeJson(res, 404, { error: `no task "${id}"` });
|
|
197
|
+
return writeJson(res, 200, t);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function taskDelete(c) {
|
|
201
|
+
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;
|
|
202
|
+
const id = url.pathname.split('/').pop();
|
|
203
|
+
const mod = await import('../../tasks.mjs');
|
|
204
|
+
try { return writeJson(res, 200, mod.removeTask(id)); }
|
|
205
|
+
catch (err) {
|
|
206
|
+
return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function taskAction(c) {
|
|
211
|
+
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;
|
|
212
|
+
const m = url.pathname.match(/^\/tasks\/([^/]+)\/(done|abandon)$/);
|
|
213
|
+
const id = m[1];
|
|
214
|
+
const action = m[2];
|
|
215
|
+
const mod = await import('../../tasks.mjs');
|
|
216
|
+
try {
|
|
217
|
+
const next = mod.patchTask(id, { status: action === 'done' ? 'done' : 'abandoned' });
|
|
218
|
+
return writeJson(res, 200, next);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
return writeJson(res, 404, { error: err?.message || String(err), code: err?.code });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Daemon route handlers (sessions), 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 sessionsList(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
|
+
// ?filter=<substr> case-insensitive id substring;
|
|
9
|
+
// ?limit=<N> caps post-filter count.
|
|
10
|
+
// Same composition (filter then limit) as v3.33's CLI flag.
|
|
11
|
+
// ?withTurnCount=true mirrors CLI v3.59's --with-turn-count;
|
|
12
|
+
// opt-in because it loads each session file.
|
|
13
|
+
// ?sortBy=mtime|turn-count|bytes|id mirrors CLI v3.60's --sort-by;
|
|
14
|
+
// turn-count implicitly enables turn-count loading.
|
|
15
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
16
|
+
let list = ctx.sessionsMod.listSessions(cfgDir);
|
|
17
|
+
const filter = url.searchParams.get('filter');
|
|
18
|
+
if (filter) {
|
|
19
|
+
const f = filter.toLowerCase();
|
|
20
|
+
list = list.filter(s => s.id.toLowerCase().includes(f));
|
|
21
|
+
}
|
|
22
|
+
const limitStr = url.searchParams.get('limit');
|
|
23
|
+
if (limitStr) {
|
|
24
|
+
const n = parseInt(limitStr, 10);
|
|
25
|
+
if (Number.isFinite(n) && n > 0) list = list.slice(0, n);
|
|
26
|
+
}
|
|
27
|
+
const sortBy = url.searchParams.get('sortBy');
|
|
28
|
+
const validSortBy = new Set(['mtime', 'turn-count', 'bytes', 'id']);
|
|
29
|
+
if (sortBy && !validSortBy.has(sortBy)) {
|
|
30
|
+
return writeJson(res, 400, { error: `invalid sortBy: ${sortBy} (expected: mtime, turn-count, bytes, id)` });
|
|
31
|
+
}
|
|
32
|
+
const withCount = url.searchParams.get('withTurnCount') === 'true' || sortBy === 'turn-count';
|
|
33
|
+
// v5: surface trainerHandled / agentName / trajectoryId per row.
|
|
34
|
+
// Source: the last turn's metadata if persisted; otherwise null.
|
|
35
|
+
// We're surgical here — we read the metadata only when withTurnCount
|
|
36
|
+
// is already paying the load cost, OR when the dashboard explicitly
|
|
37
|
+
// asks via ?withV5=true. Keeps the default GET /sessions cheap.
|
|
38
|
+
const withV5 = url.searchParams.get('withV5') === 'true' || withCount;
|
|
39
|
+
let out = list.map(s => {
|
|
40
|
+
const base = { id: s.id, bytes: s.bytes, mtime: new Date(s.mtimeMs).toISOString(), _mtimeMs: s.mtimeMs };
|
|
41
|
+
if (withCount) {
|
|
42
|
+
try { base.turnCount = ctx.sessionsMod.loadTurns(s.id, cfgDir).length; }
|
|
43
|
+
catch { base.turnCount = null; }
|
|
44
|
+
}
|
|
45
|
+
if (withV5) {
|
|
46
|
+
try {
|
|
47
|
+
const turns = ctx.sessionsMod.loadTurns(s.id, cfgDir);
|
|
48
|
+
// Newest turn carries the freshest annotations. Fall back to
|
|
49
|
+
// any earlier turn that has the field set so a long session
|
|
50
|
+
// doesn't drop its trainer/agent attribution.
|
|
51
|
+
let trainerHandled = false;
|
|
52
|
+
let trainedBy = null;
|
|
53
|
+
let agentName = null;
|
|
54
|
+
let trajectoryId = null;
|
|
55
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
56
|
+
const t = turns[i] || {};
|
|
57
|
+
if (!trajectoryId && t.trajectoryId) trajectoryId = String(t.trajectoryId);
|
|
58
|
+
if (!agentName && t.agent) agentName = String(t.agent);
|
|
59
|
+
if (!trainedBy && t.trainedBy) { trainedBy = String(t.trainedBy); trainerHandled = true; }
|
|
60
|
+
if (t.trainerHandled) trainerHandled = true;
|
|
61
|
+
if (trajectoryId && agentName && trainedBy) break;
|
|
62
|
+
}
|
|
63
|
+
base.trainerHandled = !!trainerHandled;
|
|
64
|
+
base.trainedBy = trainedBy;
|
|
65
|
+
base.agentName = agentName;
|
|
66
|
+
base.trajectoryId = trajectoryId;
|
|
67
|
+
} catch { /* missing metadata is non-fatal */ }
|
|
68
|
+
}
|
|
69
|
+
return base;
|
|
70
|
+
});
|
|
71
|
+
if (sortBy) {
|
|
72
|
+
const cmp = {
|
|
73
|
+
mtime: (a, b) => b._mtimeMs - a._mtimeMs,
|
|
74
|
+
'turn-count': (a, b) => (b.turnCount ?? 0) - (a.turnCount ?? 0),
|
|
75
|
+
bytes: (a, b) => b.bytes - a.bytes,
|
|
76
|
+
id: (a, b) => a.id.localeCompare(b.id),
|
|
77
|
+
};
|
|
78
|
+
out.sort(cmp[sortBy]);
|
|
79
|
+
}
|
|
80
|
+
return writeJson(res, 200, out.map(({ _mtimeMs, ...rest }) => rest));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function sessionsSearch(c) {
|
|
84
|
+
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;
|
|
85
|
+
// Mirror of `lazyclaw sessions search <query> [--regex]`.
|
|
86
|
+
// ?q=<query> required; ?regex=true switches to regex mode.
|
|
87
|
+
// Returns { query, regex, matches: [{ id, mtime, matchCount, excerpt }] }
|
|
88
|
+
// — same shape the CLI prints. A dashboard rendering the
|
|
89
|
+
// search box can use the same parser for both surfaces.
|
|
90
|
+
const q = url.searchParams.get('q');
|
|
91
|
+
if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
|
|
92
|
+
const useRegex = url.searchParams.get('regex') === 'true';
|
|
93
|
+
let matcher;
|
|
94
|
+
if (useRegex) {
|
|
95
|
+
try { matcher = new RegExp(q, 'i'); }
|
|
96
|
+
catch (e) { return writeJson(res, 400, { error: `invalid regex: ${e.message}` }); }
|
|
97
|
+
} else {
|
|
98
|
+
const ql = q.toLowerCase();
|
|
99
|
+
matcher = { test: (s) => String(s).toLowerCase().includes(ql) };
|
|
100
|
+
}
|
|
101
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
102
|
+
const list = ctx.sessionsMod.listSessions(cfgDir);
|
|
103
|
+
const matches = [];
|
|
104
|
+
for (const s of list) {
|
|
105
|
+
const turns = ctx.sessionsMod.loadTurns(s.id, cfgDir);
|
|
106
|
+
let matchCount = 0;
|
|
107
|
+
let firstExcerpt = null;
|
|
108
|
+
for (const t of turns) {
|
|
109
|
+
if (typeof t?.content !== 'string') continue;
|
|
110
|
+
if (matcher.test(t.content)) {
|
|
111
|
+
matchCount++;
|
|
112
|
+
if (firstExcerpt === null) {
|
|
113
|
+
const c = t.content;
|
|
114
|
+
let pos = useRegex ? c.search(matcher) : c.toLowerCase().indexOf(q.toLowerCase());
|
|
115
|
+
if (pos < 0) pos = 0;
|
|
116
|
+
const start = Math.max(0, pos - 40);
|
|
117
|
+
const end = Math.min(c.length, pos + q.length + 40);
|
|
118
|
+
firstExcerpt = (start > 0 ? '…' : '') + c.slice(start, end) + (end < c.length ? '…' : '');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (matchCount > 0) {
|
|
123
|
+
matches.push({
|
|
124
|
+
id: s.id,
|
|
125
|
+
mtime: new Date(s.mtimeMs).toISOString(),
|
|
126
|
+
matchCount,
|
|
127
|
+
excerpt: firstExcerpt,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return writeJson(res, 200, { query: q, regex: useRegex, matches });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function sessionExport(c) {
|
|
135
|
+
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;
|
|
136
|
+
// GET /sessions/<id>/export?format=md|json|text — same body
|
|
137
|
+
// the CLI's `lazyclaw sessions export <id> --format ...`
|
|
138
|
+
// produces, with the appropriate content-type. The dashboard
|
|
139
|
+
// can offer a "download as ..." button without spawning the
|
|
140
|
+
// CLI.
|
|
141
|
+
const id = sessionExportMatch[1];
|
|
142
|
+
try {
|
|
143
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
144
|
+
const file = ctx.sessionsMod.sessionPath(id, cfgDir);
|
|
145
|
+
if (!(await fileExists(file))) return writeJson(res, 404, { error: 'session not found', id });
|
|
146
|
+
const fmt = (url.searchParams.get('format') || 'md').toLowerCase();
|
|
147
|
+
const FORMATS = {
|
|
148
|
+
md: { fn: ctx.sessionsMod.exportMarkdown, mime: 'text/markdown; charset=utf-8' },
|
|
149
|
+
markdown: { fn: ctx.sessionsMod.exportMarkdown, mime: 'text/markdown; charset=utf-8' },
|
|
150
|
+
json: { fn: ctx.sessionsMod.exportJson, mime: 'application/json; charset=utf-8' },
|
|
151
|
+
text: { fn: ctx.sessionsMod.exportText, mime: 'text/plain; charset=utf-8' },
|
|
152
|
+
txt: { fn: ctx.sessionsMod.exportText, mime: 'text/plain; charset=utf-8' },
|
|
153
|
+
};
|
|
154
|
+
const f = FORMATS[fmt];
|
|
155
|
+
if (!f) {
|
|
156
|
+
return writeJson(res, 400, {
|
|
157
|
+
error: `unknown format: ${fmt}`,
|
|
158
|
+
expected: ['md', 'json', 'text'],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const body = f.fn(id, cfgDir);
|
|
162
|
+
res.writeHead(200, {
|
|
163
|
+
'content-type': f.mime,
|
|
164
|
+
'content-length': Buffer.byteLength(body),
|
|
165
|
+
});
|
|
166
|
+
return res.end(body);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function sessionGet(c) {
|
|
173
|
+
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;
|
|
174
|
+
// GET /sessions/<id> — full turn log. Returns 404 when missing
|
|
175
|
+
// rather than an empty array so the caller can distinguish
|
|
176
|
+
// "session does not exist" from "session is empty".
|
|
177
|
+
const id = sessionMatch[1];
|
|
178
|
+
try {
|
|
179
|
+
const cfgDir = ctx.sessionsDirGetter();
|
|
180
|
+
const file = ctx.sessionsMod.sessionPath(id, cfgDir);
|
|
181
|
+
if (!(await fileExists(file))) return writeJson(res, 404, { error: 'session not found', id });
|
|
182
|
+
const turns = ctx.sessionsMod.loadTurns(id, cfgDir);
|
|
183
|
+
return writeJson(res, 200, { id, turns });
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function sessionDelete(c) {
|
|
190
|
+
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;
|
|
191
|
+
// DELETE /sessions/<id> — idempotent. 200 on both "deleted" and
|
|
192
|
+
// "didn't exist" so callers can use it as a reset without checking
|
|
193
|
+
// first. m16: include `removed: <bool>` for shape parity with
|
|
194
|
+
// sibling DELETEs (/skills, /workflows).
|
|
195
|
+
const id = sessionMatch[1];
|
|
196
|
+
try {
|
|
197
|
+
// Use the sessions module's path resolver to check existence
|
|
198
|
+
// BEFORE clearSession (which is unconditional unlink-if-exists).
|
|
199
|
+
const sessDir = ctx.sessionsDirGetter();
|
|
200
|
+
let existedBefore = false;
|
|
201
|
+
try {
|
|
202
|
+
const sessPath = ctx.sessionsMod.sessionPath
|
|
203
|
+
? ctx.sessionsMod.sessionPath(id, sessDir)
|
|
204
|
+
: null;
|
|
205
|
+
if (sessPath) existedBefore = fs.existsSync(sessPath);
|
|
206
|
+
} catch { /* sessionPath unavailable → leave as unknown */ }
|
|
207
|
+
ctx.sessionsMod.clearSession(id, sessDir);
|
|
208
|
+
return writeJson(res, 200, { ok: true, id, removed: existedBefore });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return writeJson(res, 400, { error: err?.message || String(err) });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|