lazyclaw 6.0.1 → 6.2.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.
Files changed (44) hide show
  1. package/README.ko.md +88 -25
  2. package/README.md +121 -190
  3. package/channels/handoff.mjs +18 -5
  4. package/channels/matrix.mjs +23 -5
  5. package/channels/slack.mjs +83 -50
  6. package/channels/slack_env.mjs +45 -0
  7. package/channels/telegram.mjs +49 -6
  8. package/cli.mjs +10 -21
  9. package/commands/channels.mjs +106 -107
  10. package/commands/chat.mjs +81 -45
  11. package/commands/daemon.mjs +15 -0
  12. package/commands/gateway.mjs +194 -0
  13. package/commands/providers.mjs +17 -38
  14. package/commands/service.mjs +113 -0
  15. package/commands/setup.mjs +55 -54
  16. package/commands/setup_channels.mjs +207 -0
  17. package/config_features.mjs +106 -0
  18. package/daemon/lib/inbound_dedup.mjs +108 -0
  19. package/daemon/lib/learn_queue.mjs +46 -0
  20. package/daemon/routes/_deps.mjs +6 -0
  21. package/daemon/routes/conversation.mjs +68 -4
  22. package/daemon/routes/ops.mjs +9 -29
  23. package/dotenv_min.mjs +28 -0
  24. package/first_run.mjs +9 -0
  25. package/lib/gateway_guard.mjs +100 -0
  26. package/lib/inbound_client.mjs +108 -0
  27. package/lib/service_install.mjs +207 -0
  28. package/package.json +2 -3
  29. package/providers/codex_cli.mjs +17 -1
  30. package/providers/compat_vendors.mjs +161 -0
  31. package/providers/gemini_cli.mjs +19 -3
  32. package/providers/model_catalogue.mjs +142 -9
  33. package/providers/probe.mjs +28 -0
  34. package/providers/registry.mjs +31 -162
  35. package/tui/editor.mjs +18 -2
  36. package/tui/pickers.mjs +3 -3
  37. package/tui/provider_families.mjs +8 -6
  38. package/tui/repl.mjs +25 -4
  39. package/tui/slash_commands.mjs +4 -0
  40. package/tui/slash_dispatcher.mjs +118 -0
  41. package/tui/splash_props.mjs +52 -0
  42. package/web/dashboard.css +6 -12
  43. package/web/dashboard.html +2 -34
  44. package/web/dashboard.js +3 -3
@@ -0,0 +1,207 @@
1
+ // commands/setup_channels.mjs — Hermes-style "where will you run it?"
2
+ // onboarding step plus the outbound-webhook step, split out of
3
+ // commands/setup.mjs (CLAUDE.md §7: one file = one responsibility, and to
4
+ // keep setup.mjs under its size-gate ceiling).
5
+ //
6
+ // Credentials are written to <cfgDir>/.env (0600) — the same file the
7
+ // channel modules read via dotenv_min. Metadata (enabled / boundAgent) is
8
+ // written to cfg.channels.<name>, the exact shape daemon/routes/ops.mjs
9
+ // and the dashboard read. Plugin channels are recorded the same way with an
10
+ // honest "needs a plugin package" notice — we never pretend a channel works
11
+ // when it requires an uninstalled package or an external binary.
12
+ import { readConfig, writeConfig } from '../lib/config.mjs';
13
+ import { writeDotenvMerge } from '../dotenv_min.mjs';
14
+ import { messageAdd } from '../config_features.mjs';
15
+
16
+ // fields[].key is the answer key; .env is the env var it maps to; .secret
17
+ // masks it on echo; .optional lets the user skip it.
18
+ export const CHANNEL_CATALOG = [
19
+ { name: 'slack', builtin: true, label: 'Slack',
20
+ fields: [
21
+ { key: 'token', env: 'SLACK_BOT_TOKEN', prompt: 'Bot token (xoxb-…)', secret: true },
22
+ { key: 'appToken', env: 'SLACK_APP_TOKEN', prompt: 'App-level token (xapp-…, for inbound / Socket Mode)', secret: true, optional: true },
23
+ ] },
24
+ { name: 'telegram', builtin: true, label: 'Telegram',
25
+ fields: [{ key: 'token', env: 'TELEGRAM_BOT_TOKEN', prompt: 'Bot token (from @BotFather)', secret: true }] },
26
+ { name: 'matrix', builtin: true, label: 'Matrix',
27
+ fields: [
28
+ { key: 'homeserver', env: 'MATRIX_HOMESERVER', prompt: 'Homeserver URL (https://matrix.org)' },
29
+ { key: 'token', env: 'MATRIX_ACCESS_TOKEN', prompt: 'Access token', secret: true },
30
+ { key: 'userId', env: 'MATRIX_USER_ID', prompt: 'User id (@you:matrix.org)' },
31
+ ] },
32
+ { name: 'http', builtin: true, label: 'HTTP (generic inbound endpoint)', fields: [] },
33
+ { name: 'discord', builtin: false, plugin: '@lazyclaw/channel-discord', label: 'Discord',
34
+ fields: [{ key: 'token', env: 'DISCORD_BOT_TOKEN', prompt: 'Bot token', secret: true }] },
35
+ { name: 'email', builtin: false, plugin: '@lazyclaw/channel-email', label: 'Email (IMAP/SMTP)',
36
+ fields: [
37
+ { key: 'host', env: 'EMAIL_IMAP_HOST', prompt: 'IMAP host' },
38
+ { key: 'user', env: 'EMAIL_IMAP_USER', prompt: 'IMAP user' },
39
+ { key: 'pass', env: 'EMAIL_IMAP_PASS', prompt: 'IMAP password', secret: true },
40
+ ] },
41
+ { name: 'signal', builtin: false, plugin: '@lazyclaw/channel-signal', label: 'Signal (needs signal-cli)',
42
+ fields: [{ key: 'account', env: 'SIGNAL_ACCOUNT', prompt: 'Signal account (+15551234567)' }] },
43
+ { name: 'voice', builtin: false, plugin: '@lazyclaw/channel-voice', label: 'Voice (Whisper transcription)',
44
+ fields: [{ key: 'apiKey', env: 'OPENAI_API_KEY', prompt: 'OpenAI API key (whisper)', secret: true }] },
45
+ { name: 'whatsapp', builtin: false, plugin: '@lazyclaw/channel-whatsapp', label: 'WhatsApp (web session)',
46
+ fields: [] },
47
+ ];
48
+
49
+ export function channelByName(name) {
50
+ return CHANNEL_CATALOG.find((c) => c.name === name) || null;
51
+ }
52
+
53
+ // Pure: turn collected answers into { envVars, channelConfig, needsPlugin }.
54
+ // Empty/whitespace answers are dropped so a skipped optional field doesn't
55
+ // write an empty env var.
56
+ export function buildChannelEntry(name, answers = {}) {
57
+ const spec = channelByName(name);
58
+ if (!spec) throw new Error(`unknown channel: ${name}`);
59
+ const envVars = {};
60
+ for (const f of spec.fields) {
61
+ const v = (answers[f.key] == null ? '' : String(answers[f.key])).trim();
62
+ if (v) envVars[f.env] = v;
63
+ }
64
+ return {
65
+ envVars,
66
+ channelConfig: { enabled: true },
67
+ needsPlugin: spec.builtin ? null : spec.plugin,
68
+ };
69
+ }
70
+
71
+ // Side-effecting persist used by the interactive step (and unit-tested with a
72
+ // temp cfgDir). Writes creds to <cfgDir>/.env and metadata to cfg.channels.
73
+ // readConfig()/writeConfig() resolve configPath() fresh on every call (it
74
+ // reads LAZYCLAW_CONFIG_DIR at use-time, nothing import-cached), so they
75
+ // target <cfgDir>/config.json when the caller has pointed the env var there.
76
+ export function persistChannel(cfgDir, name, answers) {
77
+ const entry = buildChannelEntry(name, answers);
78
+ if (Object.keys(entry.envVars).length) writeDotenvMerge(cfgDir, entry.envVars);
79
+ const cfg = readConfig();
80
+ cfg.channels = cfg.channels && typeof cfg.channels === 'object' ? cfg.channels : {};
81
+ cfg.channels[name] = { ...(cfg.channels[name] || {}), ...entry.channelConfig };
82
+ writeConfig(cfg);
83
+ return entry;
84
+ }
85
+
86
+ const mask = (v) => {
87
+ const s = String(v);
88
+ return s.length <= 4 ? '••••' : `${s.slice(0, 3)}…${s.slice(-2)}`;
89
+ };
90
+
91
+ // Interactive channel step. `prompt(label)` resolves to a trimmed string;
92
+ // `write(text)` sinks UI output (defaults to process.stdout). Returns
93
+ // { skipped, channel?, needsPlugin? }. Never echoes a secret field value.
94
+ export async function runChannelStep({ cfgDir, prompt, colors, write = (s) => process.stdout.write(s) }) {
95
+ const { dim, ok, warn } = colors;
96
+ const list = CHANNEL_CATALOG.map((c) => `${c.name}${c.builtin ? '' : ' *'}`).join(' · ');
97
+ write(` ${dim('Where will you talk to the agent? Built-in: slack/telegram/matrix/http. (* = needs a plugin package.)')}\n`);
98
+ write(` ${dim(list)}\n\n`);
99
+ const pick = (await prompt(' channel (Enter to skip): ')).trim().toLowerCase();
100
+ if (!pick) { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
101
+ const spec = channelByName(pick);
102
+ if (!spec) { write(` ${warn('skipped:')} unknown channel "${pick}"\n\n`); return { skipped: true }; }
103
+
104
+ const answers = {};
105
+ for (const f of spec.fields) {
106
+ const v = (await prompt(` ${spec.label} — ${f.prompt}${f.optional ? ' (optional)' : ''}: `)).trim();
107
+ if (v) answers[f.key] = v;
108
+ }
109
+ try {
110
+ const entry = persistChannel(cfgDir, pick, answers);
111
+ const credKeys = Object.keys(entry.envVars);
112
+ write(` ${ok('✓ channel enabled:')} ${pick} ${credKeys.length ? dim(`creds: ${credKeys.join(', ')}`) : ''}\n`);
113
+ for (const f of spec.fields) {
114
+ if (f.secret && answers[f.key]) write(` ${dim(`${f.env} = ${mask(answers[f.key])} (stored in ${cfgDir}/.env, 0600)`)}\n`);
115
+ }
116
+ if (entry.needsPlugin) {
117
+ write(` ${warn('plugin required:')} ${entry.needsPlugin}\n`);
118
+ write(` ${dim(`install with: lazyclaw channels install ${entry.needsPlugin} (or npm install --prefix ${cfgDir} ${entry.needsPlugin})`)}\n`);
119
+ }
120
+ write('\n');
121
+ return { skipped: false, channel: pick, needsPlugin: entry.needsPlugin };
122
+ } catch (e) {
123
+ write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
124
+ return { skipped: true };
125
+ }
126
+ }
127
+
128
+ // Outbound webhook step (moved verbatim-in-spirit from setup.mjs).
129
+ export async function runWebhookStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
130
+ const { dim, ok, warn } = colors;
131
+ write(` ${dim('Outbound webhook for `lazyclaw message send <name> <text>`. Slack / Discord Incoming Webhook URLs work as-is.')}\n\n`);
132
+ const hookName = (await prompt(' webhook name (Enter to skip): ')).trim();
133
+ if (!hookName) { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
134
+ const hookUrl = (await prompt(' webhook URL: ')).trim();
135
+ if (!hookUrl) { write(` ${warn('skipped:')} URL required\n\n`); return { skipped: true }; }
136
+ try {
137
+ const fresh = readConfig();
138
+ messageAdd(fresh, hookName, hookUrl);
139
+ writeConfig(fresh);
140
+ write(` ${ok('✓ webhook saved:')} ${hookName}\n\n`);
141
+ return { skipped: false, name: hookName };
142
+ } catch (e) {
143
+ write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
144
+ return { skipped: true };
145
+ }
146
+ }
147
+
148
+ // Context-window step (asked right after the model pick): how much past
149
+ // conversation to keep each turn — a sliding history budget, NOT the model's
150
+ // hard limit. Enter on both prompts keeps the defaults.
151
+ export async function runContextStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
152
+ const { dim, ok } = colors;
153
+ const cf = await import('../config_features.mjs');
154
+ const cur = cf.chatWindowGet(readConfig());
155
+ write(` ${dim(`Context window — how much past conversation to send each turn (sliding budget, not the model's hard limit). Now: ${cur.turns} turns · ${cur.tokens} tokens.`)}\n\n`);
156
+ const turnsRaw = (await prompt(` turns to keep (Enter = ${cur.turns}): `)).trim();
157
+ const tokensRaw = (await prompt(` token budget (Enter = ${cur.tokens}): `)).trim();
158
+ if (!turnsRaw && !tokensRaw) { write(` ${dim('— kept defaults —')}\n\n`); return { skipped: true }; }
159
+ const turns = parseInt(turnsRaw, 10);
160
+ const tokens = parseInt(tokensRaw, 10);
161
+ const cfg = readConfig();
162
+ cf.chatWindowSet(cfg, {
163
+ ...(Number.isFinite(turns) && turns > 0 ? { turns } : {}),
164
+ ...(Number.isFinite(tokens) && tokens >= 256 ? { tokens } : {}),
165
+ });
166
+ writeConfig(cfg);
167
+ const w = cf.chatWindowGet(cfg);
168
+ write(` ${ok('✓ context window:')} ${w.turns} turns · ${w.tokens} tokens\n`);
169
+ write(` ${dim('change later: /context turns <N> | tokens <N>')}\n\n`);
170
+ return { skipped: false, ...w };
171
+ }
172
+
173
+ // Optional orchestration step: enable the multi-agent planner+workers pipeline.
174
+ // Defaults the planner to the configured provider and workers to [planner];
175
+ // the user can layer more workers later via /orchestrator or the CLI.
176
+ export async function runOrchestratorStep({ prompt, colors, write = (s) => process.stdout.write(s) }) {
177
+ const { dim, ok } = colors;
178
+ const cf = await import('../config_features.mjs');
179
+ // Reuse the same searchable provider/model picker as the model step, so
180
+ // planner + workers are chosen from a list (arrow keys / filter), not typed.
181
+ const { _pickProviderInteractive } = await import('../tui/pickers.mjs');
182
+ const toSpec = (p) => (p && p.provider) ? (p.model ? `${p.provider}:${p.model}` : p.provider) : null;
183
+ write(` ${dim('Multi-agent: a planner splits your task into subtasks, workers run them in parallel, then a synthesis step merges the results. Skip for a single agent.')}\n\n`);
184
+ const yn = (await prompt(' enable orchestration? [y/N] ')).trim().toLowerCase();
185
+ if (yn !== 'y' && yn !== 'yes') { write(` ${dim('— skipped —')}\n\n`); return { skipped: true }; }
186
+ const cfg = readConfig();
187
+ const base = cfg.provider && cfg.provider !== 'orchestrator' ? cfg.provider : 'claude-cli';
188
+ // Planner — pick from the provider/model list (Esc keeps the default).
189
+ write(` ${dim('Pick the PLANNER (decomposes the task):')}\n`);
190
+ const planner = toSpec(await _pickProviderInteractive()) || base;
191
+ // Workers — pick one or more from the same list.
192
+ const workers = [];
193
+ let more = true;
194
+ while (more) {
195
+ write(` ${dim(`Pick a WORKER (runs the subtasks)${workers.length ? ` — ${workers.length} added` : ''}:`)}\n`);
196
+ const spec = toSpec(await _pickProviderInteractive());
197
+ if (spec && !workers.includes(spec)) workers.push(spec);
198
+ more = ((await prompt(' add another worker? [y/N] ')).trim().toLowerCase().startsWith('y'));
199
+ }
200
+ if (!workers.length) workers.push(planner);
201
+ cf.orchestratorSet(cfg, { planner, workers });
202
+ cf.orchestratorEnable(cfg, true);
203
+ writeConfig(cfg);
204
+ write(` ${ok('✓ orchestration enabled:')} planner ${planner} · workers ${workers.join(', ')}\n`);
205
+ write(` ${dim('change later: /orchestrator or lazyclaw orchestrator status')}\n\n`);
206
+ return { skipped: false, planner, workers };
207
+ }
@@ -239,3 +239,109 @@ export async function messageSend(cfg, name, text, opts = {}) {
239
239
  }
240
240
  return { ok: true, kind: hook.kind, status: res.status };
241
241
  }
242
+
243
+ // ── Channels — built-in channel config (cfg.channels.<name>) ────────────
244
+ // KNOWN_CHANNELS mirrors channels/ (built-in) + channels-* (plugins). Single
245
+ // source of truth: the daemon /channels route, the CLI `channels` command, and
246
+ // the in-chat /channels slash all read it so the three views can't drift.
247
+ export const KNOWN_CHANNELS = ['slack', 'matrix', 'telegram', 'discord', 'email', 'signal', 'whatsapp', 'voice', 'http'];
248
+
249
+ // A channel counts as "configured" when it has a cfg.channels.<name> section
250
+ // or a legacy <name>-bot-token / <name>-token key. Returns one row per
251
+ // configured channel: { name, enabled, boundAgent, lastInboundAt }. Matches
252
+ // the daemon /channels route's enabled semantics exactly.
253
+ export function channelStatusList(cfg) {
254
+ const chCfg = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
255
+ const out = [];
256
+ for (const name of KNOWN_CHANNELS) {
257
+ const sec = chCfg[name];
258
+ if (!sec && !cfg[`${name}-bot-token`] && !cfg[`${name}-token`]) continue;
259
+ out.push({
260
+ name,
261
+ enabled: !!(sec && (sec.enabled !== false)),
262
+ boundAgent: sec?.agent || sec?.boundAgent || null,
263
+ lastInboundAt: sec?.lastInboundAt || null,
264
+ });
265
+ }
266
+ // Surface any additional configured channels not in the known list.
267
+ for (const name of Object.keys(chCfg)) {
268
+ if (KNOWN_CHANNELS.includes(name)) continue;
269
+ const sec = chCfg[name] || {};
270
+ out.push({
271
+ name,
272
+ enabled: sec.enabled !== false,
273
+ boundAgent: sec.agent || sec.boundAgent || null,
274
+ lastInboundAt: sec.lastInboundAt || null,
275
+ });
276
+ }
277
+ return out;
278
+ }
279
+
280
+ // Enable/disable a channel. Mutates cfg.channels.<name>.enabled; the caller
281
+ // persists via writeConfig. Returns cfg for chaining.
282
+ export function channelSetEnabled(cfg, name, enabled) {
283
+ if (!name) throw new Error('channel name is required');
284
+ cfg.channels = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
285
+ cfg.channels[name] = { ...(cfg.channels[name] || {}), enabled: !!enabled };
286
+ return cfg;
287
+ }
288
+
289
+ // ── Chat context window (cfg.chat.window{Turns,Tokens}) ─────────────────
290
+ // The sliding history budget sent to the model each turn (NOT the model's hard
291
+ // context limit). Shared by the /context slash, the setup step, the status bar,
292
+ // and applyChatWindow so all four agree. Defaults mirror chat_window.mjs.
293
+ const _CTX_DEFAULT_TURNS = Number(process.env.LAZYCLAW_CHAT_WINDOW_TURNS) || 20;
294
+ const _CTX_DEFAULT_TOKENS = Number(process.env.LAZYCLAW_CHAT_WINDOW_TOKENS) || 8000;
295
+ export function chatWindowGet(cfg) {
296
+ const c = (cfg && cfg.chat && typeof cfg.chat === 'object') ? cfg.chat : {};
297
+ return { turns: Number(c.windowTurns) || _CTX_DEFAULT_TURNS, tokens: Number(c.windowTokens) || _CTX_DEFAULT_TOKENS };
298
+ }
299
+ export function chatWindowSet(cfg, { turns, tokens } = {}) {
300
+ cfg.chat = (cfg.chat && typeof cfg.chat === 'object') ? cfg.chat : {};
301
+ if (turns !== undefined) cfg.chat.windowTurns = turns;
302
+ if (tokens !== undefined) cfg.chat.windowTokens = tokens;
303
+ return cfg;
304
+ }
305
+
306
+ // ── Orchestrator — multi-agent config (cfg.orchestrator) ────────────────
307
+ // Shared by the setup wizard, the /orchestrator slash, and the CLI so the
308
+ // "planner + workers" config has one shape. Orchestration is ACTIVE only when
309
+ // cfg.provider === 'orchestrator' AND there is at least one worker.
310
+ export function orchestratorGet(cfg) {
311
+ const o = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
312
+ const workers = Array.isArray(o.workers) ? o.workers : [];
313
+ return {
314
+ planner: o.planner || null,
315
+ workers,
316
+ maxSubtasks: Number.isFinite(o.maxSubtasks) ? o.maxSubtasks : 5,
317
+ active: cfg.provider === 'orchestrator' && workers.length > 0,
318
+ };
319
+ }
320
+
321
+ // Merge planner / workers / maxSubtasks into cfg.orchestrator (only the keys
322
+ // provided). Returns cfg.
323
+ export function orchestratorSet(cfg, { planner, workers, maxSubtasks } = {}) {
324
+ cfg.orchestrator = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
325
+ if (planner !== undefined) cfg.orchestrator.planner = planner;
326
+ if (workers !== undefined) cfg.orchestrator.workers = workers;
327
+ if (maxSubtasks !== undefined) cfg.orchestrator.maxSubtasks = maxSubtasks;
328
+ return cfg;
329
+ }
330
+
331
+ // Turn orchestration on/off by routing cfg.provider. Enabling stashes the
332
+ // previous real provider so disabling can restore it (else falls back to the
333
+ // planner's base provider, then claude-cli).
334
+ export function orchestratorEnable(cfg, enabled) {
335
+ if (enabled) {
336
+ if (cfg.provider && cfg.provider !== 'orchestrator') {
337
+ cfg.orchestrator = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
338
+ cfg.orchestrator._prevProvider = cfg.provider;
339
+ }
340
+ cfg.provider = 'orchestrator';
341
+ } else {
342
+ const o = (cfg.orchestrator && typeof cfg.orchestrator === 'object') ? cfg.orchestrator : {};
343
+ const plannerBase = String(o.planner || '').split(':')[0];
344
+ cfg.provider = o._prevProvider || plannerBase || 'claude-cli';
345
+ }
346
+ return cfg;
347
+ }
@@ -0,0 +1,108 @@
1
+ // daemon/lib/inbound_dedup.mjs — idempotency store for POST /inbound.
2
+ //
3
+ // A channel retry (Slack event redelivery, a listener restart replaying its
4
+ // backlog, app_mention+message double-fire arriving from two listener
5
+ // processes) must not run the provider twice or append duplicate turns to the
6
+ // session. Callers claim `${channel}:${messageId}` BEFORE persisting the user
7
+ // turn, record the reply after, and a duplicate replays the recorded reply.
8
+ //
9
+ // Persistence: append-only JSONL at <cfgDir>/inbound_seen.jsonl (0600 — the
10
+ // recorded replies are conversation content), loaded on open, compacted to
11
+ // the newest `cap` entries when the file grows past 4×cap lines. Pending
12
+ // (claimed-but-unrecorded) keys are memory-only with a TTL so a crash between
13
+ // claim and record can never permanently wedge a message id.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ const DEFAULT_CAP = 500;
19
+ const DEFAULT_PENDING_TTL_MS = 120_000;
20
+ // Replays only need enough of the reply to repost it; cap what we persist so
21
+ // a pathological multi-MB reply can't balloon the store.
22
+ const MAX_PERSISTED_REPLY = 16_384;
23
+
24
+ // One instance per config dir — the daemon is long-lived and the store keeps
25
+ // its working set in memory; reopening per request would re-read the file.
26
+ const _instances = new Map();
27
+ export function _resetDedupCache() { _instances.clear(); }
28
+
29
+ export function openDedup(cfgDir, { cap = DEFAULT_CAP, pendingTtlMs = DEFAULT_PENDING_TTL_MS, now = Date.now } = {}) {
30
+ const existing = _instances.get(cfgDir);
31
+ if (existing) return existing;
32
+
33
+ const file = path.join(cfgDir, 'inbound_seen.jsonl');
34
+ /** @type {Map<string, {reply: string, threadId: string|null, sessionId: string|null, at: number}>} */
35
+ const entries = new Map(); // insertion order == age order
36
+ /** @type {Map<string, number>} pending claim -> claimed-at ms */
37
+ const pending = new Map();
38
+
39
+ const load = () => {
40
+ let raw;
41
+ try { raw = fs.readFileSync(file, 'utf8'); }
42
+ catch { return; }
43
+ for (const line of raw.split('\n')) {
44
+ if (!line.trim()) continue;
45
+ appendedLines++;
46
+ let rec;
47
+ try { rec = JSON.parse(line); } catch { continue; } // skip corrupt lines
48
+ if (!rec || typeof rec.key !== 'string') continue;
49
+ entries.delete(rec.key); // re-insert to refresh age order
50
+ entries.set(rec.key, { reply: rec.reply ?? '', threadId: rec.threadId ?? null, sessionId: rec.sessionId ?? null, at: rec.at ?? 0 });
51
+ }
52
+ trim();
53
+ };
54
+
55
+ const trim = () => {
56
+ while (entries.size > cap) {
57
+ const oldest = entries.keys().next().value;
58
+ entries.delete(oldest);
59
+ }
60
+ };
61
+
62
+ // Appended-lines counter (seeded by load) instead of re-reading the file on
63
+ // every record — the store may see one append per inbound message.
64
+ let appendedLines = 0;
65
+ const compactIfNeeded = () => {
66
+ if (appendedLines <= cap * 4) return;
67
+ const out = [...entries.entries()]
68
+ .map(([key, e]) => JSON.stringify({ key, ...e }))
69
+ .join('\n') + '\n';
70
+ try {
71
+ fs.writeFileSync(file, out, { mode: 0o600 });
72
+ appendedLines = entries.size;
73
+ } catch { /* compaction is best-effort */ }
74
+ };
75
+
76
+ const store = {
77
+ claim(key) {
78
+ const hit = entries.get(key);
79
+ if (hit) return { dup: true, pending: false, entry: hit };
80
+ const claimedAt = pending.get(key);
81
+ if (claimedAt != null && now() - claimedAt < pendingTtlMs) {
82
+ return { dup: true, pending: true, entry: null };
83
+ }
84
+ pending.set(key, now());
85
+ return { dup: false, pending: false, entry: null };
86
+ },
87
+ record(key, { reply = '', threadId = null, sessionId = null } = {}) {
88
+ pending.delete(key);
89
+ const bounded = String(reply).length > MAX_PERSISTED_REPLY
90
+ ? String(reply).slice(0, MAX_PERSISTED_REPLY) + '\n…(truncated for replay)'
91
+ : reply;
92
+ const entry = { reply: bounded, threadId, sessionId, at: now() };
93
+ entries.delete(key);
94
+ entries.set(key, entry);
95
+ trim();
96
+ try {
97
+ fs.appendFileSync(file, JSON.stringify({ key, ...entry }) + '\n', { mode: 0o600 });
98
+ appendedLines++;
99
+ } catch { /* persistence is best-effort; memory dedup still holds */ }
100
+ compactIfNeeded();
101
+ },
102
+ release(key) { pending.delete(key); },
103
+ };
104
+
105
+ load();
106
+ _instances.set(cfgDir, store);
107
+ return store;
108
+ }
@@ -0,0 +1,46 @@
1
+ // daemon/lib/learn_queue.mjs — bounded, serialised runner for the /inbound
2
+ // post-task learning hook.
3
+ //
4
+ // runLearning fires up to two trainer LLM completions (skill synthesis + the
5
+ // user model) and, with trainer 'auto', may spawn a claude-cli subprocess —
6
+ // per call, with no throttle of its own. A channel message burst hitting
7
+ // POST /inbound must not fan that out unbounded, so learning jobs run one at
8
+ // a time and the waiting line is capped: when it is full the newest job is
9
+ // DROPPED (learning is best-effort; the trajectory of a dropped turn is
10
+ // simply not recorded — the next turn learns again).
11
+
12
+ const MAX_DEPTH = 8;
13
+
14
+ let _running = false;
15
+ const _waiting = [];
16
+ let _dropped = 0;
17
+
18
+ function _drain() {
19
+ if (_running) return;
20
+ const job = _waiting.shift();
21
+ if (!job) return;
22
+ _running = true;
23
+ Promise.resolve()
24
+ .then(job)
25
+ .catch(() => { /* learning is best-effort */ })
26
+ .finally(() => { _running = false; _drain(); });
27
+ }
28
+
29
+ // Enqueue a learning job (a function returning a promise). Returns true when
30
+ // accepted, false when the queue was full and the job was dropped.
31
+ export function enqueueLearning(job) {
32
+ if (_waiting.length >= MAX_DEPTH) {
33
+ _dropped++;
34
+ if (_dropped === 1 || _dropped % 50 === 0) {
35
+ process.stderr.write(`[learn] queue full — dropped ${_dropped} learning job(s) so far (burst protection)\n`);
36
+ }
37
+ return false;
38
+ }
39
+ _waiting.push(job);
40
+ _drain();
41
+ return true;
42
+ }
43
+
44
+ // Test hooks.
45
+ export function _learnQueueStats() { return { running: _running, waiting: _waiting.length, dropped: _dropped }; }
46
+ export function _resetLearnQueue() { _waiting.length = 0; _running = false; _dropped = 0; }
@@ -34,3 +34,9 @@ export { resolveProvider } from '../lib/provider.mjs';
34
34
  // persistent thread/session and re-point them across channels.
35
35
  export { openThreads } from '../../channels/threads.mjs';
36
36
  export { handoffWithRollback } from '../../channels/handoff.mjs';
37
+ // Phase 4 — /inbound idempotency: dedup retried/redelivered channel messages
38
+ // by their native message id so the provider runs once per message.
39
+ export { openDedup } from '../lib/inbound_dedup.mjs';
40
+ // Phase 4 — serialised, depth-capped runner for the /inbound learning hook
41
+ // (a message burst must not fan out unbounded trainer calls).
42
+ export { enqueueLearning } from '../lib/learn_queue.mjs';
@@ -1,7 +1,7 @@
1
1
  // Daemon route handlers (conversation), extracted verbatim from makeHandler (D5).
2
2
  // Each handler takes the per-request dispatch context `c` and returns the
3
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, openThreads, handoffWithRollback } from './_deps.mjs';
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, openThreads, handoffWithRollback, openDedup, enqueueLearning } from './_deps.mjs';
5
5
  import { randomBytes } from 'node:crypto';
6
6
 
7
7
  // F5 — mint a fresh session id for a newly-seen channel:externalId binding.
@@ -165,6 +165,35 @@ export async function inbound(c) {
165
165
  const cfgDir = ctx.sessionsDirGetter();
166
166
  const channel = (typeof body.channel === 'string' && body.channel) ? body.channel : null;
167
167
  const externalId = (body.externalId != null && String(body.externalId)) ? String(body.externalId) : null;
168
+ // Phase 4 — idempotency. When the relay supplies the native message
169
+ // id, claim a key BEFORE any turn is persisted: a recorded duplicate
170
+ // replays the prior reply (no provider call, no double appendTurn);
171
+ // an in-flight duplicate answers empty so the listener stays silent
172
+ // while the first request finishes. The key is scoped to the
173
+ // CONVERSATION (`channel:externalId:messageId`) so a colliding or
174
+ // forged messageId from another conversation can never replay this
175
+ // one's reply or sessionId.
176
+ const messageId = (body.messageId != null && String(body.messageId)) ? String(body.messageId) : null;
177
+ let dedup = null;
178
+ let dedupKey = null;
179
+ if (channel && messageId) {
180
+ dedup = openDedup(cfgDir);
181
+ dedupKey = `${channel}:${externalId || ''}:${messageId}`;
182
+ const seen = dedup.claim(dedupKey);
183
+ if (seen.dup) {
184
+ if (seen.pending) return writeJson(res, 200, { reply: '', threadId: body.threadId || null, duplicate: true });
185
+ const e = seen.entry;
186
+ const dupOut = { reply: e.reply, threadId: e.threadId, duplicate: true };
187
+ if (e.sessionId) dupOut.sessionId = e.sessionId;
188
+ return writeJson(res, 200, dupOut);
189
+ }
190
+ }
191
+ // Everything from here to record() must free the pending claim on
192
+ // ANY failure (not just provider errors) — otherwise a thrown
193
+ // loadTurns/appendTurn would wedge the message id for the whole
194
+ // pending TTL and retries would be silently dropped.
195
+ let dedupRecorded = false;
196
+ try {
168
197
  let threads = null;
169
198
  let bound = null;
170
199
  let sessionId = null;
@@ -193,6 +222,8 @@ export async function inbound(c) {
193
222
  { apiKey: cfg['api-key'], model: body.model || cfg.model, onUsage: (u) => { inboundUsage = u; } },
194
223
  )) acc += chunk;
195
224
  } catch (err) {
225
+ // A provider failure must stay retryable, not poison the message
226
+ // id — the outer finally releases the claim.
196
227
  const m = statusForProviderError(err);
197
228
  return writeJson(res, m.status, { error: err?.message || String(err), code: err?.code || null }, m.headers || {});
198
229
  }
@@ -211,16 +242,45 @@ export async function inbound(c) {
211
242
  }
212
243
  const out = { reply: acc, threadId: bound ? bound.threadId : (body.threadId || null) };
213
244
  if (sessionId) out.sessionId = sessionId;
245
+ if (dedup) { dedup.record(dedupKey, { reply: acc, threadId: out.threadId, sessionId }); dedupRecorded = true; }
246
+ if (sessionId) {
247
+ // Phase 4 — close the post-task learning loop on channel turns,
248
+ // mirroring the chat REPL's fire-and-forget hook (run_turn.mjs).
249
+ // Only session-bound turns learn (a stateless one-shot relay is
250
+ // not a conversation); trainer resolution inside runLearning
251
+ // handles cfg.trainer 'auto' -> claude-cli ($0) routing. The
252
+ // dedup short-circuit above guarantees at most one learning pass
253
+ // per native message id, and enqueueLearning serialises the runs
254
+ // (concurrency 1, bounded depth) so a message burst can't fan out
255
+ // unbounded trainer LLM calls / claude-cli subprocesses.
256
+ const learnTurns = [
257
+ { agent: 'user', text, ts: new Date().toISOString() },
258
+ { agent: 'chat', text: acc, ts: new Date().toISOString() },
259
+ ];
260
+ enqueueLearning(() =>
261
+ import('../../mas/learning.mjs')
262
+ .then((mod) => mod.runLearning('post-task', {
263
+ agent: { name: 'chat', provider: provName, model: body.model || cfg.model, role: '' },
264
+ task: { id: sessionId, title: '(channel turn)', turns: learnTurns },
265
+ configDir: cfgDir,
266
+ cfg,
267
+ })));
268
+ }
214
269
  return writeJson(res, 200, out);
270
+ } finally {
271
+ if (dedup && !dedupRecorded) dedup.release(dedupKey);
272
+ }
215
273
  }
216
274
 
217
275
  export async function handoff(c) {
218
276
  const { ctx, res, req } = c;
219
277
  // F6 — re-point a thread to a new channel/externalId so a later
220
278
  // inbound on the target resumes the SAME session (context follows).
221
- // The daemon has no live per-channel send map, so it migrates the
222
- // binding directly with no notifier; once a send adapter is injected,
223
- // a failed target notify rolls the binding back (channels/handoff.mjs).
279
+ // When the in-process gateway registered a live sender for the
280
+ // target channel (ctx.channelSenders), the target gets a resume
281
+ // marker and a FAILED notify rolls the binding back (502). Without
282
+ // a sender (bare daemon) the migration persists silently — the
283
+ // session still follows on the next inbound.
224
284
  let body;
225
285
  try { body = await readJson(req); }
226
286
  catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
@@ -232,10 +292,14 @@ export async function handoff(c) {
232
292
  }
233
293
  const cfgDir = ctx.sessionsDirGetter();
234
294
  const threads = openThreads(cfgDir);
295
+ const liveSend = (ctx.channelSenders && typeof ctx.channelSenders.get === 'function')
296
+ ? ctx.channelSenders.get(target)
297
+ : undefined;
235
298
  try {
236
299
  const next = await handoffWithRollback({
237
300
  threads, threadId, target, externalId,
238
301
  note: typeof body.note === 'string' ? body.note : '',
302
+ send: liveSend,
239
303
  });
240
304
  return writeJson(res, 200, {
241
305
  threadId: next.threadId, channel: next.channel,
@@ -3,6 +3,12 @@
3
3
  // HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
4
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
5
 
6
+ // Channel meta + status now live in config_features.mjs (single source for the
7
+ // daemon route, the CLI `channels` command, and the /channels slash). Re-export
8
+ // KNOWN_CHANNELS so existing importers (the drift-guard test) keep working.
9
+ export { KNOWN_CHANNELS } from '../../config_features.mjs';
10
+ import { channelStatusList } from '../../config_features.mjs';
11
+
6
12
  export async function trainerStatus(c) {
7
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;
8
14
  // Reads cfg.trainer.{provider, model, schedule, budget, recipe}
@@ -138,36 +144,10 @@ export async function sandboxUse(c) {
138
144
 
139
145
  export async function channels(c) {
140
146
  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;
141
- // Aggregate cfg.channels.<name> + any channel-specific runtime
142
- // state we expose. Keeps the dashboard from having to know
143
- // each channel module's shape.
147
+ // Aggregate cfg.channels.<name> via the shared status helper so the
148
+ // dashboard, CLI, and /channels slash all report identically.
144
149
  const cfg = ctx.readConfig();
145
- const chCfg = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
146
- // Known built-in channel names (matches channels/ + channels-*).
147
- const KNOWN = ['slack', 'matrix', 'telegram', 'discord', 'email', 'signal', 'whatsapp', 'voice', 'http'];
148
- const out = [];
149
- for (const name of KNOWN) {
150
- const sec = chCfg[name];
151
- if (!sec && !cfg[`${name}-bot-token`] && !cfg[`${name}-token`]) continue;
152
- out.push({
153
- name,
154
- enabled: !!(sec && (sec.enabled !== false)),
155
- lastInboundAt: sec?.lastInboundAt || null,
156
- boundAgent: sec?.agent || sec?.boundAgent || null,
157
- });
158
- }
159
- // Surface any additional configured channels we didn't enumerate.
160
- for (const name of Object.keys(chCfg)) {
161
- if (KNOWN.includes(name)) continue;
162
- const sec = chCfg[name] || {};
163
- out.push({
164
- name,
165
- enabled: sec.enabled !== false,
166
- lastInboundAt: sec.lastInboundAt || null,
167
- boundAgent: sec.agent || sec.boundAgent || null,
168
- });
169
- }
170
- return writeJson(res, 200, { channels: out });
150
+ return writeJson(res, 200, { channels: channelStatusList(cfg) });
171
151
  }
172
152
 
173
153
  export async function indexRebuild(c) {