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,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
+