discoclaw 0.4.0 → 0.5.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.
@@ -86,12 +86,15 @@ Each action category has its own flag (only active when the master switch is `1`
86
86
  | `DISCOCLAW_DISCORD_ACTIONS_MEMORY` | `1` | memoryRemember, memoryForget, memoryShow |
87
87
  | `DISCOCLAW_DISCORD_ACTIONS_DEFER` | `1` | defer |
88
88
  | `DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN` | `0` | generateImage |
89
+ | `DISCOCLAW_DISCORD_ACTIONS_VOICE` | `0` | voiceStatus, voiceJoin, voiceLeave, voiceSetVoice |
90
+ | `DISCOCLAW_DISCORD_ACTIONS_SPAWN` | `1` | spawnAgent |
89
91
  | _(config — always on)_ | — | modelSet, modelShow |
90
92
 
91
93
  Notes:
92
94
  - `reactionPrompt` is gated by the MESSAGING flag — it is registered via `REACTION_PROMPT_ACTION_TYPES` only when `flags.messaging` is true (`src/discord/actions.ts:113`).
93
95
  - Config actions (`modelSet`, `modelShow`) have no separate env flag. They are always enabled when the master switch is on, hardcoded in `src/index.ts`.
94
96
  - `generateImage` supports two providers: **OpenAI** (models: `dall-e-3`, `gpt-image-1`) and **Gemini** (models: `imagen-4.0-generate-001`, `imagen-4.0-fast-generate-001`, `imagen-4.0-ultra-generate-001`). Provider is auto-detected from the model prefix (`dall-e-*`/`gpt-image-*` → openai, `imagen-*` → gemini) or set explicitly via the `provider` field. OpenAI provider uses `OPENAI_API_KEY` (required) and optional `OPENAI_BASE_URL`. Gemini provider uses `IMAGEGEN_GEMINI_API_KEY`. At least one key must be set when `DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1`. Default model is auto-detected: if only `IMAGEGEN_GEMINI_API_KEY` is set, defaults to `imagen-4.0-generate-001`; otherwise defaults to `dall-e-3`. Override with `IMAGEGEN_DEFAULT_MODEL`.
97
+ - `spawnAgent` is enabled by default (`DISCOCLAW_DISCORD_ACTIONS_SPAWN=1`; set to 0 to disable). Spawned agents run fire-and-forget: each agent runs its prompt via the configured runtime and posts its output directly to the target channel. Multiple `spawnAgent` actions in a single response run in parallel (bounded by `DISCOCLAW_DISCORD_ACTIONS_SPAWN_MAX_CONCURRENT`, default 8). Spawn is disabled for bot-originated messages and excluded from cron flows to prevent recursive agent chains. Spawned agents run at recursion depth 1 and cannot themselves spawn further agents.
95
98
 
96
99
  Auto-follow-up: When query actions (channelList, channelInfo, threadListArchived, forumTagList, readMessages, fetchMessage, listPins, memberInfo, roleInfo, searchMessages, eventList, taskList, taskShow, cronList, cronShow, planList, planShow, memoryShow, modelShow, forgeStatus) succeed, DiscoClaw automatically re-invokes Claude with the results. This allows Claude to reason about query results without requiring the user to send a follow-up message. Controlled by `DISCOCLAW_ACTION_FOLLOWUP_DEPTH` (default `3`, `0` disables). Mutation-only responses do not trigger follow-ups. Trivially short follow-up responses (<50 chars with no actions) are suppressed.
97
100
 
package/.context/voice.md CHANGED
@@ -83,5 +83,6 @@ When `voiceEnabled=true`, the post-connect block in `src/index.ts` initializes t
83
83
  | `DEEPGRAM_API_KEY` | — | Required for deepgram STT and TTS |
84
84
  | `DEEPGRAM_STT_MODEL` | `nova-3-conversationalai` | Deepgram STT model name |
85
85
  | `DEEPGRAM_TTS_VOICE` | `aura-2-asteria-en` | Deepgram TTS voice name |
86
+ | `DEEPGRAM_TTS_SPEED` | `1.0` | Deepgram TTS playback speed (range 0.5–1.5) |
86
87
  | `CARTESIA_API_KEY` | — | Required for cartesia TTS |
87
88
  | *(built-in)* | — | Telegraphic style instruction hardcoded into every voice AI invocation — front-loads the answer, strips preambles/markdown/filler, keeps responses short for TTS latency. Not an env var; not overridable by `DISCOCLAW_VOICE_SYSTEM_PROMPT`. |
package/.env.example.full CHANGED
@@ -197,6 +197,14 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
197
197
  # Maximum number of pending deferred invocations allowed at once (default: 5).
198
198
  #DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT=5
199
199
 
200
+ # Allow the AI to spawn parallel sub-agents in target channels.
201
+ # Each spawned agent is a fire-and-forget invocation that posts output to the specified channel.
202
+ # Spawned agents run at recursion depth 1 and cannot themselves spawn further agents.
203
+ # Default: on. Set to 0 to disable.
204
+ #DISCOCLAW_DISCORD_ACTIONS_SPAWN=1
205
+ # Maximum number of spawn actions that may run concurrently (default: 8).
206
+ #DISCOCLAW_DISCORD_ACTIONS_SPAWN_MAX_CONCURRENT=8
207
+
200
208
  # ============================================================
201
209
  # OPTIONAL — uncomment sections you need
202
210
  # ============================================================
@@ -537,5 +545,8 @@ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
537
545
  # Deepgram TTS voice for speech synthesis (default: aura-2-asteria-en).
538
546
  # See https://developers.deepgram.com/docs/tts-models for available voices.
539
547
  #DEEPGRAM_TTS_VOICE=aura-2-asteria-en
548
+ # Deepgram TTS playback speed (range: 0.5–1.5, default: 1.0).
549
+ # Values below 1.0 slow down speech; values above 1.0 speed it up.
550
+ #DEEPGRAM_TTS_SPEED=1.0
540
551
  # API key for Cartesia Sonic-3 TTS. Required when DISCOCLAW_TTS_PROVIDER=cartesia.
541
552
  #CARTESIA_API_KEY=
package/README.md CHANGED
@@ -221,6 +221,18 @@ Full step-by-step guide: [docs/discord-bot-setup.md](docs/discord-bot-setup.md)
221
221
  npm install -g discoclaw
222
222
  ```
223
223
 
224
+ > **Fedora 43+ / GCC 14+ — `@discordjs/opus` build failure**
225
+ >
226
+ > GCC 14 promotes `-Wincompatible-pointer-types` to a hard error by default. The upstream opus C source triggers this, causing `npm install` to fail with an error like:
227
+ > ```
228
+ > error: incompatible pointer types passing ...
229
+ > ```
230
+ > **Workaround** — set the flag before installing:
231
+ > ```bash
232
+ > CFLAGS="-Wno-error=incompatible-pointer-types" npm install -g discoclaw
233
+ > ```
234
+ > This is a known upstream issue in the `@discordjs/opus` native addon. It only requires the flag override at install time; runtime behavior is unaffected.
235
+
224
236
  2. **Run the interactive setup wizard** (creates `.env` and scaffolds your workspace):
225
237
  ```bash
226
238
  discoclaw init
package/dist/config.js CHANGED
@@ -158,6 +158,8 @@ export function parseConfig(env) {
158
158
  const discordActionsDefer = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER', true);
159
159
  const discordActionsImagegen = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN', false);
160
160
  const discordActionsVoice = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_VOICE', false);
161
+ const discordActionsSpawn = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_SPAWN', true);
162
+ const spawnMaxConcurrent = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_SPAWN_MAX_CONCURRENT', 4);
161
163
  const deferMaxDelaySeconds = parsePositiveNumber(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS);
162
164
  const deferMaxConcurrent = parsePositiveInt(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT', DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT);
163
165
  if (!discordActionsEnabled) {
@@ -176,6 +178,7 @@ export function parseConfig(env) {
176
178
  { name: 'DISCOCLAW_DISCORD_ACTIONS_DEFER', enabled: discordActionsDefer },
177
179
  { name: 'DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN', enabled: discordActionsImagegen },
178
180
  { name: 'DISCOCLAW_DISCORD_ACTIONS_VOICE', enabled: discordActionsVoice },
181
+ { name: 'DISCOCLAW_DISCORD_ACTIONS_SPAWN', enabled: discordActionsSpawn },
179
182
  ]
180
183
  .filter((entry) => (env[entry.name] ?? '').trim().length > 0 && entry.enabled)
181
184
  .map((entry) => entry.name);
@@ -243,6 +246,16 @@ export function parseConfig(env) {
243
246
  const deepgramApiKey = parseTrimmedString(env, 'DEEPGRAM_API_KEY');
244
247
  const deepgramSttModel = parseTrimmedString(env, 'DEEPGRAM_STT_MODEL') ?? 'nova-3-general';
245
248
  const deepgramTtsVoice = parseTrimmedString(env, 'DEEPGRAM_TTS_VOICE') ?? 'aura-2-asteria-en';
249
+ const deepgramTtsSpeed = (() => {
250
+ const raw = parseTrimmedString(env, 'DEEPGRAM_TTS_SPEED');
251
+ if (raw == null)
252
+ return undefined;
253
+ const n = parseFloat(raw);
254
+ if (!Number.isFinite(n) || n < 0.5 || n > 1.5) {
255
+ throw new Error(`DEEPGRAM_TTS_SPEED must be a number between 0.5 and 1.5, got "${raw}"`);
256
+ }
257
+ return n;
258
+ })();
246
259
  const cartesiaApiKey = parseTrimmedString(env, 'CARTESIA_API_KEY');
247
260
  const voiceModelRaw = parseTrimmedString(env, 'DISCOCLAW_VOICE_MODEL');
248
261
  const voiceSystemPrompt = (() => {
@@ -351,8 +364,10 @@ export function parseConfig(env) {
351
364
  discordActionsDefer,
352
365
  discordActionsImagegen,
353
366
  discordActionsVoice,
367
+ discordActionsSpawn,
354
368
  deferMaxDelaySeconds,
355
369
  deferMaxConcurrent,
370
+ spawnMaxConcurrent,
356
371
  messageHistoryBudget: parseNonNegativeInt(env, 'DISCOCLAW_MESSAGE_HISTORY_BUDGET', 3000),
357
372
  summaryEnabled: parseBoolean(env, 'DISCOCLAW_SUMMARY_ENABLED', true),
358
373
  summaryModel: parseTrimmedString(env, 'DISCOCLAW_SUMMARY_MODEL') ?? fastModel,
@@ -398,6 +413,7 @@ export function parseConfig(env) {
398
413
  deepgramApiKey,
399
414
  deepgramSttModel,
400
415
  deepgramTtsVoice,
416
+ deepgramTtsSpeed,
401
417
  cartesiaApiKey,
402
418
  forgeDrafterRuntime,
403
419
  forgeAuditorRuntime,
@@ -32,6 +32,8 @@ export const QUERY_ACTION_TYPES = new Set([
32
32
  'forgeStatus',
33
33
  // Voice
34
34
  'voiceStatus',
35
+ // Spawn
36
+ 'spawnAgent',
35
37
  ]);
36
38
  export function hasQueryAction(actionTypes) {
37
39
  return actionTypes.some((t) => QUERY_ACTION_TYPES.has(t));
@@ -0,0 +1,118 @@
1
+ import { resolveChannel, findChannelRaw, describeChannelType } from './action-utils.js';
2
+ import { NO_MENTIONS } from './allowed-mentions.js';
3
+ import { splitDiscord } from './output-utils.js';
4
+ const SPAWN_TYPE_MAP = {
5
+ spawnAgent: true,
6
+ };
7
+ export const SPAWN_ACTION_TYPES = new Set(Object.keys(SPAWN_TYPE_MAP));
8
+ // ---------------------------------------------------------------------------
9
+ // Single executor
10
+ // ---------------------------------------------------------------------------
11
+ export async function executeSpawnAction(action, ctx, spawnCtx) {
12
+ if ((spawnCtx.depth ?? 0) >= 1) {
13
+ return { ok: false, error: 'spawnAgent blocked: recursion depth >= 1 (spawned agents cannot spawn further agents)' };
14
+ }
15
+ switch (action.type) {
16
+ case 'spawnAgent': {
17
+ if (!action.channel?.trim()) {
18
+ return { ok: false, error: 'spawnAgent requires a non-empty channel' };
19
+ }
20
+ if (!action.prompt?.trim()) {
21
+ return { ok: false, error: 'spawnAgent requires a non-empty prompt' };
22
+ }
23
+ // Resolve the target channel before the expensive runtime call.
24
+ const targetChannel = resolveChannel(ctx.guild, action.channel);
25
+ if (!targetChannel) {
26
+ const raw = findChannelRaw(ctx.guild, action.channel);
27
+ if (raw) {
28
+ const kind = describeChannelType(raw);
29
+ return { ok: false, error: `spawnAgent: "${action.channel}" is a ${kind} channel (use a text channel)` };
30
+ }
31
+ return { ok: false, error: `spawnAgent: channel "${action.channel}" not found` };
32
+ }
33
+ const label = action.label?.trim() || 'agent';
34
+ const timeoutMs = spawnCtx.timeoutMs ?? 120_000;
35
+ const model = action.model ?? spawnCtx.model;
36
+ try {
37
+ let text = '';
38
+ const stream = spawnCtx.runtime.invoke({
39
+ prompt: action.prompt,
40
+ model,
41
+ cwd: spawnCtx.cwd,
42
+ timeoutMs,
43
+ });
44
+ for await (const event of stream) {
45
+ if (event.type === 'text_delta') {
46
+ text += event.text;
47
+ }
48
+ else if (event.type === 'text_final') {
49
+ text = event.text;
50
+ }
51
+ else if (event.type === 'error') {
52
+ return { ok: false, error: `spawnAgent (${label}) failed: ${event.message}` };
53
+ }
54
+ }
55
+ const outputText = text.trim() || `Agent (${label}) completed with no output.`;
56
+ const chunks = splitDiscord(outputText);
57
+ for (const chunk of chunks) {
58
+ await targetChannel.send({ content: chunk, allowedMentions: NO_MENTIONS });
59
+ }
60
+ return {
61
+ ok: true,
62
+ summary: `Agent (${label}) posted to #${targetChannel.name}`,
63
+ };
64
+ }
65
+ catch (err) {
66
+ const msg = err instanceof Error ? err.message : String(err);
67
+ return { ok: false, error: `spawnAgent (${label}) failed: ${msg}` };
68
+ }
69
+ }
70
+ }
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // Parallel coordinator
74
+ // ---------------------------------------------------------------------------
75
+ /**
76
+ * Execute multiple spawnAgent actions in parallel, bounded by maxConcurrent.
77
+ * Results are returned in the same order as the input actions.
78
+ */
79
+ export async function executeSpawnActions(actions, ctx, spawnCtx) {
80
+ if (actions.length === 0)
81
+ return [];
82
+ const maxConcurrent = spawnCtx.maxConcurrent ?? 4;
83
+ const results = new Array(actions.length);
84
+ let i = 0;
85
+ while (i < actions.length) {
86
+ const batch = actions.slice(i, i + maxConcurrent);
87
+ const settled = await Promise.allSettled(batch.map((action) => executeSpawnAction(action, ctx, spawnCtx)));
88
+ for (let j = 0; j < settled.length; j++) {
89
+ const item = settled[j];
90
+ results[i + j] = item.status === 'fulfilled'
91
+ ? item.value
92
+ : { ok: false, error: item.reason instanceof Error ? item.reason.message : String(item.reason) };
93
+ }
94
+ i += maxConcurrent;
95
+ }
96
+ return results;
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Prompt section
100
+ // ---------------------------------------------------------------------------
101
+ export function spawnActionsPromptSection() {
102
+ return `### Spawn Agent
103
+
104
+ **spawnAgent** — Spawn a parallel sub-agent in a target channel:
105
+ \`\`\`
106
+ <discord-action>{"type":"spawnAgent","channel":"general","prompt":"List all open tasks and summarize their status","label":"task-summary"}</discord-action>
107
+ \`\`\`
108
+ - \`channel\` (required): Target channel name or ID where the spawned agent posts its output.
109
+ - \`prompt\` (required): The instruction to send to the sub-agent.
110
+ - \`model\` (optional): Model override for the spawned invocation.
111
+ - \`label\` (optional): A short human-readable label for the agent (used in error messages).
112
+
113
+ #### Spawn Guidelines
114
+ - Multiple spawnAgent actions in a single response are run in parallel for efficiency.
115
+ - Spawned agents run at recursion depth 1 and cannot themselves spawn further agents.
116
+ - The spawned agent runs fire-and-forget: it posts its output directly to the target channel.
117
+ - Keep prompts focused — each agent handles a single well-defined task.`;
118
+ }
@@ -0,0 +1,385 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import { ChannelType } from 'discord.js';
3
+ import { SPAWN_ACTION_TYPES, executeSpawnAction, executeSpawnActions, spawnActionsPromptSection, } from './actions-spawn.js';
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+ function makeMockChannel() {
8
+ return {
9
+ id: 'ch-general',
10
+ name: 'general',
11
+ type: ChannelType.GuildText,
12
+ send: vi.fn(async () => ({ id: 'sent-1' })),
13
+ };
14
+ }
15
+ function makeCtx(channel) {
16
+ const ch = channel ?? makeMockChannel();
17
+ return {
18
+ guild: {
19
+ channels: {
20
+ cache: {
21
+ get: (id) => id === ch.id ? ch : undefined,
22
+ find: (fn) => fn(ch) ? ch : undefined,
23
+ },
24
+ },
25
+ },
26
+ client: {},
27
+ channelId: 'test-channel',
28
+ messageId: 'test-message',
29
+ };
30
+ }
31
+ function makeRuntime(events) {
32
+ return {
33
+ id: 'other',
34
+ capabilities: new Set(),
35
+ invoke: vi.fn(async function* () {
36
+ for (const event of events) {
37
+ yield event;
38
+ }
39
+ }),
40
+ };
41
+ }
42
+ function makeSpawnCtx(overrides) {
43
+ return {
44
+ runtime: makeRuntime([{ type: 'text_delta', text: 'Agent output' }, { type: 'done' }]),
45
+ model: 'claude-opus-4',
46
+ cwd: '/tmp/workspace',
47
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
48
+ ...overrides,
49
+ };
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Tests
53
+ // ---------------------------------------------------------------------------
54
+ describe('SPAWN_ACTION_TYPES', () => {
55
+ it('contains spawnAgent', () => {
56
+ expect(SPAWN_ACTION_TYPES.has('spawnAgent')).toBe(true);
57
+ });
58
+ it('does not contain non-spawn types', () => {
59
+ expect(SPAWN_ACTION_TYPES.has('forgeCreate')).toBe(false);
60
+ expect(SPAWN_ACTION_TYPES.has('planRun')).toBe(false);
61
+ expect(SPAWN_ACTION_TYPES.has('cronCreate')).toBe(false);
62
+ });
63
+ });
64
+ describe('executeSpawnAction', () => {
65
+ beforeEach(() => {
66
+ vi.clearAllMocks();
67
+ });
68
+ describe('spawnAgent', () => {
69
+ it('posts agent text output to target channel', async () => {
70
+ const channel = makeMockChannel();
71
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Say hello' }, makeCtx(channel), makeSpawnCtx());
72
+ expect(result.ok).toBe(true);
73
+ expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: 'Agent output' }));
74
+ });
75
+ it('returns summary indicating agent posted to channel', async () => {
76
+ const channel = makeMockChannel();
77
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Say hello' }, makeCtx(channel), makeSpawnCtx());
78
+ expect(result.ok).toBe(true);
79
+ if (result.ok) {
80
+ expect(result.summary).toContain('#general');
81
+ }
82
+ });
83
+ it('fails on empty channel', async () => {
84
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: '', prompt: 'Do something' }, makeCtx(), makeSpawnCtx());
85
+ expect(result.ok).toBe(false);
86
+ if (!result.ok)
87
+ expect(result.error).toContain('requires a non-empty channel');
88
+ });
89
+ it('fails on whitespace-only channel', async () => {
90
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: ' ', prompt: 'Do something' }, makeCtx(), makeSpawnCtx());
91
+ expect(result.ok).toBe(false);
92
+ if (!result.ok)
93
+ expect(result.error).toContain('requires a non-empty channel');
94
+ });
95
+ it('fails on empty prompt', async () => {
96
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: '' }, makeCtx(), makeSpawnCtx());
97
+ expect(result.ok).toBe(false);
98
+ if (!result.ok)
99
+ expect(result.error).toContain('requires a non-empty prompt');
100
+ });
101
+ it('fails on whitespace-only prompt', async () => {
102
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: ' ' }, makeCtx(), makeSpawnCtx());
103
+ expect(result.ok).toBe(false);
104
+ if (!result.ok)
105
+ expect(result.error).toContain('requires a non-empty prompt');
106
+ });
107
+ it('fails when channel is not found', async () => {
108
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'nonexistent', prompt: 'Do something' }, makeCtx(), makeSpawnCtx());
109
+ expect(result.ok).toBe(false);
110
+ if (!result.ok)
111
+ expect(result.error).toContain('not found');
112
+ });
113
+ it('fails when channel is a non-text type', async () => {
114
+ const forumChannel = { id: 'ch-forum', name: 'ideas', type: ChannelType.GuildForum, send: vi.fn() };
115
+ const ctx = {
116
+ guild: {
117
+ channels: {
118
+ cache: {
119
+ get: (id) => id === forumChannel.id ? forumChannel : undefined,
120
+ find: (fn) => fn(forumChannel) ? forumChannel : undefined,
121
+ },
122
+ },
123
+ },
124
+ client: {},
125
+ channelId: 'test-channel',
126
+ messageId: 'test-message',
127
+ };
128
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'ideas', prompt: 'Do something' }, ctx, makeSpawnCtx());
129
+ expect(result.ok).toBe(false);
130
+ if (!result.ok) {
131
+ expect(result.error).toContain('forum');
132
+ }
133
+ });
134
+ it('blocks at recursion depth >= 1', async () => {
135
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something' }, makeCtx(), makeSpawnCtx({ depth: 1 }));
136
+ expect(result.ok).toBe(false);
137
+ if (!result.ok)
138
+ expect(result.error).toContain('recursion depth');
139
+ });
140
+ it('uses label in error messages on runtime error event', async () => {
141
+ const runtime = makeRuntime([{ type: 'error', message: 'Runtime error' }]);
142
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something', label: 'my-agent' }, makeCtx(), makeSpawnCtx({ runtime }));
143
+ expect(result.ok).toBe(false);
144
+ if (!result.ok) {
145
+ expect(result.error).toContain('my-agent');
146
+ expect(result.error).toContain('Runtime error');
147
+ }
148
+ });
149
+ it('prefers text_final over accumulated text_delta and posts to channel', async () => {
150
+ const runtime = makeRuntime([
151
+ { type: 'text_delta', text: 'partial ' },
152
+ { type: 'text_delta', text: 'output' },
153
+ { type: 'text_final', text: 'Final output' },
154
+ { type: 'done' },
155
+ ]);
156
+ const channel = makeMockChannel();
157
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something' }, makeCtx(channel), makeSpawnCtx({ runtime }));
158
+ expect(result.ok).toBe(true);
159
+ expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: 'Final output' }));
160
+ });
161
+ it('accumulates multiple text_delta events and posts combined text to channel', async () => {
162
+ const runtime = makeRuntime([
163
+ { type: 'text_delta', text: 'Hello ' },
164
+ { type: 'text_delta', text: 'world' },
165
+ { type: 'done' },
166
+ ]);
167
+ const channel = makeMockChannel();
168
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Greet' }, makeCtx(channel), makeSpawnCtx({ runtime }));
169
+ expect(result.ok).toBe(true);
170
+ expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: 'Hello world' }));
171
+ });
172
+ it('handles runtime throw as error result', async () => {
173
+ const runtime = {
174
+ id: 'other',
175
+ capabilities: new Set(),
176
+ invoke: vi.fn(async function* () {
177
+ throw new Error('connection failed');
178
+ }),
179
+ };
180
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something' }, makeCtx(), makeSpawnCtx({ runtime }));
181
+ expect(result.ok).toBe(false);
182
+ if (!result.ok)
183
+ expect(result.error).toContain('connection failed');
184
+ });
185
+ it('posts fallback message with label to channel when agent outputs nothing', async () => {
186
+ const runtime = makeRuntime([{ type: 'done' }]);
187
+ const channel = makeMockChannel();
188
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something', label: 'silent-agent' }, makeCtx(channel), makeSpawnCtx({ runtime }));
189
+ expect(result.ok).toBe(true);
190
+ expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('silent-agent') }));
191
+ expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('no output') }));
192
+ });
193
+ it('uses "agent" as default label in fallback message', async () => {
194
+ const runtime = makeRuntime([{ type: 'done' }]);
195
+ const channel = makeMockChannel();
196
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something' }, makeCtx(channel), makeSpawnCtx({ runtime }));
197
+ expect(result.ok).toBe(true);
198
+ expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('agent') }));
199
+ });
200
+ it('invokes runtime with correct model, cwd, and prompt', async () => {
201
+ const runtime = makeRuntime([{ type: 'done' }]);
202
+ const spawnCtx = makeSpawnCtx({ runtime, model: 'claude-opus-4-6', cwd: '/my/cwd' });
203
+ await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something specific' }, makeCtx(), spawnCtx);
204
+ expect(runtime.invoke).toHaveBeenCalledWith(expect.objectContaining({
205
+ model: 'claude-opus-4-6',
206
+ cwd: '/my/cwd',
207
+ prompt: 'Do something specific',
208
+ }));
209
+ });
210
+ it('action.model overrides spawnCtx.model', async () => {
211
+ const runtime = makeRuntime([{ type: 'done' }]);
212
+ const spawnCtx = makeSpawnCtx({ runtime, model: 'claude-haiku-4-5-20251001' });
213
+ await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Task', model: 'claude-opus-4-6' }, makeCtx(), spawnCtx);
214
+ expect(runtime.invoke).toHaveBeenCalledWith(expect.objectContaining({ model: 'claude-opus-4-6' }));
215
+ });
216
+ it('passes timeoutMs to runtime invocation', async () => {
217
+ const runtime = makeRuntime([{ type: 'done' }]);
218
+ const spawnCtx = makeSpawnCtx({ runtime, timeoutMs: 30_000 });
219
+ await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Quick task' }, makeCtx(), spawnCtx);
220
+ expect(runtime.invoke).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 30_000 }));
221
+ });
222
+ it('uses default timeout of 120_000 when not specified', async () => {
223
+ const runtime = makeRuntime([{ type: 'done' }]);
224
+ const spawnCtx = makeSpawnCtx({ runtime });
225
+ await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Task' }, makeCtx(), spawnCtx);
226
+ expect(runtime.invoke).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 120_000 }));
227
+ });
228
+ it('posts output without allowed mentions', async () => {
229
+ const channel = makeMockChannel();
230
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Say hello' }, makeCtx(channel), makeSpawnCtx());
231
+ expect(result.ok).toBe(true);
232
+ expect(channel.send).toHaveBeenCalledWith(expect.objectContaining({ allowedMentions: expect.objectContaining({ parse: [] }) }));
233
+ });
234
+ it('handles channel.send error as error result', async () => {
235
+ const channel = { ...makeMockChannel(), send: vi.fn(async () => { throw new Error('Missing Access'); }) };
236
+ const result = await executeSpawnAction({ type: 'spawnAgent', channel: 'general', prompt: 'Do something' }, makeCtx(channel), makeSpawnCtx());
237
+ expect(result.ok).toBe(false);
238
+ if (!result.ok)
239
+ expect(result.error).toContain('Missing Access');
240
+ });
241
+ });
242
+ });
243
+ describe('executeSpawnActions', () => {
244
+ beforeEach(() => {
245
+ vi.clearAllMocks();
246
+ });
247
+ it('returns empty array for no actions', async () => {
248
+ const results = await executeSpawnActions([], makeCtx(), makeSpawnCtx());
249
+ expect(results).toEqual([]);
250
+ });
251
+ it('runs a single agent and returns its result', async () => {
252
+ const results = await executeSpawnActions([{ type: 'spawnAgent', channel: 'general', prompt: 'Single task' }], makeCtx(), makeSpawnCtx());
253
+ expect(results).toHaveLength(1);
254
+ expect(results[0].ok).toBe(true);
255
+ });
256
+ it('runs multiple agents and returns results in order', async () => {
257
+ let callCount = 0;
258
+ const runtime = {
259
+ id: 'other',
260
+ capabilities: new Set(),
261
+ invoke: vi.fn(async function* () {
262
+ const n = ++callCount;
263
+ yield { type: 'text_delta', text: `result-${n}` };
264
+ yield { type: 'done' };
265
+ }),
266
+ };
267
+ const spawnCtx = makeSpawnCtx({ runtime });
268
+ const results = await executeSpawnActions([
269
+ { type: 'spawnAgent', channel: 'general', prompt: 'First task' },
270
+ { type: 'spawnAgent', channel: 'general', prompt: 'Second task' },
271
+ { type: 'spawnAgent', channel: 'general', prompt: 'Third task' },
272
+ ], makeCtx(), spawnCtx);
273
+ expect(results).toHaveLength(3);
274
+ expect(results.every((r) => r.ok)).toBe(true);
275
+ });
276
+ it('respects maxConcurrent limit per batch', async () => {
277
+ let concurrentCount = 0;
278
+ let maxSeen = 0;
279
+ const runtime = {
280
+ id: 'other',
281
+ capabilities: new Set(),
282
+ invoke: vi.fn(async function* () {
283
+ concurrentCount++;
284
+ maxSeen = Math.max(maxSeen, concurrentCount);
285
+ yield { type: 'text_delta', text: 'ok' };
286
+ yield { type: 'done' };
287
+ concurrentCount--;
288
+ }),
289
+ };
290
+ await executeSpawnActions(Array.from({ length: 8 }, (_, i) => ({ type: 'spawnAgent', channel: 'general', prompt: `Task ${i}` })), makeCtx(), makeSpawnCtx({ runtime, maxConcurrent: 3 }));
291
+ expect(maxSeen).toBeLessThanOrEqual(3);
292
+ });
293
+ it('uses default maxConcurrent of 4', async () => {
294
+ let concurrentCount = 0;
295
+ let maxSeen = 0;
296
+ const runtime = {
297
+ id: 'other',
298
+ capabilities: new Set(),
299
+ invoke: vi.fn(async function* () {
300
+ concurrentCount++;
301
+ maxSeen = Math.max(maxSeen, concurrentCount);
302
+ yield { type: 'text_delta', text: 'ok' };
303
+ yield { type: 'done' };
304
+ concurrentCount--;
305
+ }),
306
+ };
307
+ await executeSpawnActions(Array.from({ length: 10 }, (_, i) => ({ type: 'spawnAgent', channel: 'general', prompt: `Task ${i}` })), makeCtx(), makeSpawnCtx({ runtime }));
308
+ expect(maxSeen).toBeLessThanOrEqual(4);
309
+ });
310
+ it('collects errors without aborting other agents', async () => {
311
+ let callIndex = 0;
312
+ const runtime = {
313
+ id: 'other',
314
+ capabilities: new Set(),
315
+ invoke: vi.fn(async function* () {
316
+ const i = callIndex++;
317
+ if (i === 1) {
318
+ yield { type: 'error', message: 'agent-1 failed' };
319
+ }
320
+ else {
321
+ yield { type: 'text_delta', text: `result-${i}` };
322
+ yield { type: 'done' };
323
+ }
324
+ }),
325
+ };
326
+ const results = await executeSpawnActions([
327
+ { type: 'spawnAgent', channel: 'general', prompt: 'First' },
328
+ { type: 'spawnAgent', channel: 'general', prompt: 'Second' },
329
+ { type: 'spawnAgent', channel: 'general', prompt: 'Third' },
330
+ ], makeCtx(), makeSpawnCtx({ runtime }));
331
+ expect(results).toHaveLength(3);
332
+ expect(results[0].ok).toBe(true);
333
+ expect(results[1].ok).toBe(false);
334
+ if (!results[1].ok)
335
+ expect(results[1].error).toContain('agent-1 failed');
336
+ expect(results[2].ok).toBe(true);
337
+ });
338
+ it('all results are ok: false when depth >= 1 (recursion guard)', async () => {
339
+ const results = await executeSpawnActions([
340
+ { type: 'spawnAgent', channel: 'general', prompt: 'First' },
341
+ { type: 'spawnAgent', channel: 'general', prompt: 'Second' },
342
+ ], makeCtx(), makeSpawnCtx({ depth: 1 }));
343
+ expect(results).toHaveLength(2);
344
+ expect(results.every((r) => !r.ok)).toBe(true);
345
+ for (const r of results) {
346
+ if (!r.ok)
347
+ expect(r.error).toContain('recursion depth');
348
+ }
349
+ });
350
+ });
351
+ describe('spawnActionsPromptSection', () => {
352
+ it('returns non-empty prompt section containing spawnAgent', () => {
353
+ const section = spawnActionsPromptSection();
354
+ expect(section).toContain('spawnAgent');
355
+ });
356
+ it('documents the channel field', () => {
357
+ const section = spawnActionsPromptSection();
358
+ expect(section).toContain('channel');
359
+ });
360
+ it('documents the prompt field', () => {
361
+ const section = spawnActionsPromptSection();
362
+ expect(section).toContain('prompt');
363
+ });
364
+ it('documents the model field', () => {
365
+ const section = spawnActionsPromptSection();
366
+ expect(section).toContain('model');
367
+ });
368
+ it('documents the label field', () => {
369
+ const section = spawnActionsPromptSection();
370
+ expect(section).toContain('label');
371
+ });
372
+ it('mentions parallel execution', () => {
373
+ const section = spawnActionsPromptSection();
374
+ expect(section).toContain('parallel');
375
+ });
376
+ it('mentions recursion depth guard', () => {
377
+ const section = spawnActionsPromptSection();
378
+ expect(section).toContain('recursion');
379
+ });
380
+ it('includes a usage example block', () => {
381
+ const section = spawnActionsPromptSection();
382
+ expect(section).toContain('<discord-action>');
383
+ expect(section).toContain('spawnAgent');
384
+ });
385
+ });
@@ -14,6 +14,7 @@ import { CONFIG_ACTION_TYPES, executeConfigAction, configActionsPromptSection }
14
14
  import { executeReactionPromptAction as executeReactionPrompt, REACTION_PROMPT_ACTION_TYPES, reactionPromptSection } from './reaction-prompts.js';
15
15
  import { IMAGEGEN_ACTION_TYPES, executeImagegenAction, imagegenActionsPromptSection } from './actions-imagegen.js';
16
16
  import { VOICE_ACTION_TYPES, executeVoiceAction, voiceActionsPromptSection } from './actions-voice.js';
17
+ import { SPAWN_ACTION_TYPES, executeSpawnActions, spawnActionsPromptSection } from './actions-spawn.js';
17
18
  import { describeDestructiveConfirmationRequirement } from './destructive-confirmation.js';
18
19
  // ---------------------------------------------------------------------------
19
20
  // Valid types (union of all sub-module type sets)
@@ -68,6 +69,9 @@ function buildValidTypes(flags) {
68
69
  if (flags.voice)
69
70
  for (const t of VOICE_ACTION_TYPES)
70
71
  types.add(t);
72
+ if (flags.spawn)
73
+ for (const t of SPAWN_ACTION_TYPES)
74
+ types.add(t);
71
75
  return types;
72
76
  }
73
77
  function rewriteLegacyPlanCloseToTaskClose(parsed, flags) {
@@ -421,9 +425,37 @@ export function parseDiscordActions(text, flags) {
421
425
  // ---------------------------------------------------------------------------
422
426
  export async function executeDiscordActions(actions, ctx, log, subs) {
423
427
  const effectiveSubs = subs ?? {};
428
+ // --- Spawn pre-pass: collect all spawnAgent actions and run in parallel ---
429
+ const spawnResultByIndex = new Map();
430
+ if (effectiveSubs.spawnCtx) {
431
+ const spawnActions = [];
432
+ const spawnIndices = [];
433
+ for (let i = 0; i < actions.length; i++) {
434
+ if (SPAWN_ACTION_TYPES.has(actions[i].type)) {
435
+ spawnActions.push(actions[i]);
436
+ spawnIndices.push(i);
437
+ }
438
+ }
439
+ if (spawnActions.length > 0) {
440
+ const spawnResults = await executeSpawnActions(spawnActions, ctx, effectiveSubs.spawnCtx);
441
+ for (let i = 0; i < spawnIndices.length; i++) {
442
+ spawnResultByIndex.set(spawnIndices[i], spawnResults[i]);
443
+ }
444
+ }
445
+ }
424
446
  const results = [];
425
- for (const action of actions) {
447
+ for (let actionIdx = 0; actionIdx < actions.length; actionIdx++) {
448
+ const action = actions[actionIdx];
426
449
  try {
450
+ // Spawn actions were executed in parallel in the pre-pass above.
451
+ if (spawnResultByIndex.has(actionIdx)) {
452
+ const result = spawnResultByIndex.get(actionIdx);
453
+ results.push(result);
454
+ if (result.ok) {
455
+ log?.info({ action: action.type, summary: result.summary }, `discord:action ${action.type}`);
456
+ }
457
+ continue;
458
+ }
427
459
  let result;
428
460
  const destructiveCheck = describeDestructiveConfirmationRequirement(action, ctx.confirmation);
429
461
  if (!destructiveCheck.allow) {
@@ -520,6 +552,10 @@ export async function executeDiscordActions(actions, ctx, log, subs) {
520
552
  result = await executeVoiceAction(action, ctx, effectiveSubs.voiceCtx);
521
553
  }
522
554
  }
555
+ else if (SPAWN_ACTION_TYPES.has(action.type)) {
556
+ // spawnCtx not configured — would have been handled in pre-pass otherwise.
557
+ result = { ok: false, error: 'Spawn subsystem not configured' };
558
+ }
523
559
  else {
524
560
  result = { ok: false, error: `Unknown action type: ${String(action.type ?? 'unknown')}` };
525
561
  }
@@ -547,7 +583,7 @@ export async function executeDiscordActions(actions, ctx, log, subs) {
547
583
  export function buildDisplayResultLines(actions, results) {
548
584
  return results
549
585
  .map((r, i) => {
550
- if (r.ok && (actions[i]?.type === 'sendMessage' || actions[i]?.type === 'sendFile'))
586
+ if (r.ok && (actions[i]?.type === 'sendMessage' || actions[i]?.type === 'sendFile' || actions[i]?.type === 'spawnAgent'))
551
587
  return null;
552
588
  return r.ok ? `Done: ${r.summary}` : `Failed: ${r.error}`;
553
589
  })
@@ -611,6 +647,9 @@ Setting DISCOCLAW_DISCORD_ACTIONS=1 publishes this standard guidance (even if on
611
647
  if (flags.voice) {
612
648
  sections.push(voiceActionsPromptSection());
613
649
  }
650
+ if (flags.spawn) {
651
+ sections.push(spawnActionsPromptSection());
652
+ }
614
653
  sections.push(`### Rules
615
654
  - Only the action types listed above are supported.
616
655
  - Never emit an action with empty, placeholder, or missing values for required parameters. If you don't have the value (e.g., no messageId for react), skip the action entirely.
@@ -341,6 +341,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
341
341
  defer: !isDm && (params.discordActionsDefer ?? false),
342
342
  imagegen: params.discordActionsImagegen ?? false,
343
343
  voice: params.discordActionsVoice ?? false,
344
+ spawn: params.discordActionsSpawn ?? false,
344
345
  };
345
346
  if (isBotMessage) {
346
347
  actionFlags.channels = false;
@@ -356,6 +357,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
356
357
  actionFlags.tasks = false;
357
358
  actionFlags.imagegen = false;
358
359
  actionFlags.voice = false;
360
+ actionFlags.spawn = false;
359
361
  actionFlags.polls = false;
360
362
  actionFlags.messaging = true;
361
363
  }
@@ -1719,6 +1721,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
1719
1721
  configCtx: params.configCtx,
1720
1722
  imagegenCtx: params.imagegenCtx,
1721
1723
  voiceCtx: params.voiceCtx,
1724
+ spawnCtx: params.spawnCtx,
1722
1725
  });
1723
1726
  const displayLines = buildDisplayResultLines([confirmAction], actionResults);
1724
1727
  const content = displayLines.length > 0
@@ -2247,6 +2250,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
2247
2250
  configCtx: params.configCtx,
2248
2251
  imagegenCtx: params.imagegenCtx,
2249
2252
  voiceCtx: params.voiceCtx,
2253
+ spawnCtx: params.spawnCtx,
2250
2254
  });
2251
2255
  for (const result of actionResults) {
2252
2256
  metrics.recordActionResult(result.ok);
@@ -322,6 +322,7 @@ function createReactionHandler(mode, params, queue, statusRef) {
322
322
  defer: !isDm && (params.discordActionsDefer ?? false),
323
323
  imagegen: params.discordActionsImagegen ?? false,
324
324
  voice: params.discordActionsVoice ?? false,
325
+ spawn: params.discordActionsSpawn ?? false,
325
326
  };
326
327
  if (params.discordActionsEnabled && !isDm) {
327
328
  prompt += '\n\n---\n' + discordActionsPromptSection(actionFlags, params.botDisplayName);
@@ -554,6 +555,7 @@ function createReactionHandler(mode, params, queue, statusRef) {
554
555
  configCtx: params.configCtx,
555
556
  imagegenCtx: params.imagegenCtx,
556
557
  voiceCtx: params.voiceCtx,
558
+ spawnCtx: params.spawnCtx,
557
559
  });
558
560
  actionResults = results;
559
561
  for (const result of results) {
@@ -97,7 +97,7 @@ export async function handleUpdateCommand(cmd, opts = {}) {
97
97
  }
98
98
  if (npmMode) {
99
99
  progress('Installing latest version from npm...');
100
- const install = await run('npm', ['install', '-g', 'discoclaw@latest'], { timeout: 120_000 });
100
+ const install = await run('npm', ['install', '-g', 'discoclaw@latest', '--loglevel=error'], { timeout: 120_000 });
101
101
  if (install.exitCode !== 0) {
102
102
  const detail = (install.stderr || install.stdout).trim().slice(0, 500);
103
103
  return { reply: `\`npm install -g discoclaw@latest\` failed:\n\`\`\`\n${detail}\n\`\`\`` };
@@ -334,6 +334,7 @@ describe('handleUpdateCommand: npm-managed mode', () => {
334
334
  const calls = execFile.mock.calls;
335
335
  const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install') && args.includes('discoclaw@latest'));
336
336
  expect(installCall).toBeDefined();
337
+ expect(installCall[1]).toContain('--loglevel=error');
337
338
  });
338
339
  it('apply reports error on install failure', async () => {
339
340
  const { execFile } = await import('node:child_process');
package/dist/index.js CHANGED
@@ -724,6 +724,7 @@ const botParams = {
724
724
  discordActionsMemory: discordActionsMemory && durableMemoryEnabled,
725
725
  discordActionsImagegen: cfg.discordActionsImagegen,
726
726
  discordActionsVoice: cfg.discordActionsVoice && cfg.voiceEnabled,
727
+ discordActionsSpawn: cfg.discordActionsSpawn,
727
728
  discordActionsConfig: discordActionsEnabled, // Always enabled when actions are on — model switching is a core capability.
728
729
  discordActionsDefer: cfg.discordActionsDefer,
729
730
  deferMaxDelaySeconds: cfg.deferMaxDelaySeconds,
@@ -735,6 +736,7 @@ const botParams = {
735
736
  planCtx: undefined,
736
737
  memoryCtx: undefined,
737
738
  imagegenCtx: undefined,
739
+ spawnCtx: undefined,
738
740
  voiceCtx: undefined,
739
741
  voiceStatusCtx: undefined,
740
742
  setTtsVoice: undefined,
@@ -1091,6 +1093,16 @@ if (taskCtx) {
1091
1093
  };
1092
1094
  log.info('imagegen:action context initialized');
1093
1095
  }
1096
+ if (discordActionsEnabled && cfg.discordActionsSpawn) {
1097
+ botParams.spawnCtx = {
1098
+ runtime: limitedRuntime,
1099
+ model: runtimeModel,
1100
+ cwd: workspaceCwd,
1101
+ log,
1102
+ maxConcurrent: cfg.spawnMaxConcurrent,
1103
+ };
1104
+ log.info({ maxConcurrent: cfg.spawnMaxConcurrent }, 'spawn:action context initialized');
1105
+ }
1094
1106
  if (cfg.voiceEnabled) {
1095
1107
  const voiceLogChannelRef = cfg.voiceLogChannel ?? system?.voiceLogChannelId;
1096
1108
  const transcriptMirror = voiceLogChannelRef
@@ -1269,6 +1281,7 @@ if (taskCtx) {
1269
1281
  deepgramApiKey: cfg.deepgramApiKey,
1270
1282
  deepgramSttModel: cfg.deepgramSttModel,
1271
1283
  deepgramTtsVoice: overrides.ttsVoice ?? cfg.deepgramTtsVoice,
1284
+ deepgramTtsSpeed: cfg.deepgramTtsSpeed,
1272
1285
  cartesiaApiKey: cfg.cartesiaApiKey,
1273
1286
  openaiApiKey: cfg.openaiApiKey,
1274
1287
  },
@@ -1354,6 +1367,7 @@ if (cronEnabled && effectiveCronForum) {
1354
1367
  defer: false,
1355
1368
  imagegen: Boolean(botParams.imagegenCtx), // Follows env flag (DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN + API key) — cron jobs may generate images if explicitly configured.
1356
1369
  voice: Boolean(botParams.voiceCtx), // Follows env flag (DISCOCLAW_DISCORD_ACTIONS_VOICE + VOICE_ENABLED) — cron jobs may use voice if configured.
1370
+ spawn: false, // Spawn is excluded from cron flows to prevent recursive agent spawning from scheduled jobs.
1357
1371
  };
1358
1372
  const cronRunControl = new CronRunControl();
1359
1373
  // Load cron tag map (strict, but fallback to empty on first run)
@@ -48,7 +48,7 @@ export async function getLatestNpmVersion() {
48
48
  */
49
49
  export async function npmGlobalUpgrade() {
50
50
  try {
51
- const result = await execa('npm', ['install', '-g', 'discoclaw'], {
51
+ const result = await execa('npm', ['install', '-g', 'discoclaw', '--loglevel=error'], {
52
52
  timeout: 120_000,
53
53
  });
54
54
  return {
@@ -81,7 +81,7 @@ describe('npmGlobalUpgrade', () => {
81
81
  expect(result.exitCode).toBe(0);
82
82
  expect(result.stdout).toBe('added discoclaw@1.2.3');
83
83
  expect(result.stderr).toBe('');
84
- expect(mockExeca).toHaveBeenCalledWith('npm', ['install', '-g', 'discoclaw'], {
84
+ expect(mockExeca).toHaveBeenCalledWith('npm', ['install', '-g', 'discoclaw', '--loglevel=error'], {
85
85
  timeout: 120_000,
86
86
  });
87
87
  });
@@ -12,12 +12,17 @@ export class DeepgramTtsProvider {
12
12
  apiKey;
13
13
  model;
14
14
  sampleRate;
15
+ speed;
15
16
  log;
16
17
  fetchFn;
17
18
  constructor(opts) {
19
+ if (opts.speed !== undefined && (opts.speed < 0.5 || opts.speed > 1.5)) {
20
+ throw new RangeError(`DeepgramTtsProvider: speed must be in range [0.5, 1.5], got ${opts.speed}`);
21
+ }
18
22
  this.apiKey = opts.apiKey;
19
23
  this.model = opts.model ?? DEFAULT_MODEL;
20
24
  this.sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE;
25
+ this.speed = opts.speed;
21
26
  this.log = opts.log;
22
27
  this.fetchFn = opts.fetchFn ?? globalThis.fetch;
23
28
  }
@@ -37,6 +42,9 @@ export class DeepgramTtsProvider {
37
42
  sample_rate: String(this.sampleRate),
38
43
  container: 'none',
39
44
  });
45
+ if (this.speed !== undefined) {
46
+ params.set('speed', String(this.speed));
47
+ }
40
48
  const url = `${DEEPGRAM_SPEECH_URL}?${params.toString()}`;
41
49
  this.log.info({ model: this.model, textLength: text.length }, 'Deepgram TTS: sending synthesis request');
42
50
  const response = await this.fetchFn(url, {
@@ -34,6 +34,7 @@ function makeProvider(overrides = {}) {
34
34
  apiKey: overrides.apiKey ?? 'test-key',
35
35
  model: overrides.model,
36
36
  sampleRate: overrides.sampleRate,
37
+ speed: overrides.speed,
37
38
  log: overrides.log ?? createLogger(),
38
39
  fetchFn: overrides.fetchFn ?? mockFetch(),
39
40
  });
@@ -183,6 +184,28 @@ describe('DeepgramTtsProvider', () => {
183
184
  expect(log.warn).not.toHaveBeenCalled();
184
185
  });
185
186
  });
187
+ describe('speed parameter', () => {
188
+ it('includes speed in the URL when set', async () => {
189
+ const fetchFn = mockFetch([new Uint8Array([1])]);
190
+ const provider = makeProvider({ fetchFn, speed: 1.2 });
191
+ await collectFrames(provider.synthesize('hello'));
192
+ const [url] = vi.mocked(fetchFn).mock.calls[0];
193
+ expect(url).toContain('speed=1.2');
194
+ });
195
+ it('omits speed from the URL when not set', async () => {
196
+ const fetchFn = mockFetch([new Uint8Array([1])]);
197
+ const provider = makeProvider({ fetchFn });
198
+ await collectFrames(provider.synthesize('hello'));
199
+ const [url] = vi.mocked(fetchFn).mock.calls[0];
200
+ expect(url).not.toContain('speed=');
201
+ });
202
+ it('throws RangeError when speed is below 0.5', () => {
203
+ expect(() => makeProvider({ speed: 0.4 })).toThrow(RangeError);
204
+ });
205
+ it('throws RangeError when speed is above 1.5', () => {
206
+ expect(() => makeProvider({ speed: 1.6 })).toThrow(RangeError);
207
+ });
208
+ });
186
209
  it('single large chunk yields one frame', async () => {
187
210
  const big = new Uint8Array(16384);
188
211
  big.fill(42);
@@ -30,6 +30,7 @@ export function createTtsProvider(config, log) {
30
30
  return new DeepgramTtsProvider({
31
31
  apiKey: config.deepgramApiKey,
32
32
  model: config.deepgramTtsVoice,
33
+ speed: config.deepgramTtsSpeed,
33
34
  log,
34
35
  });
35
36
  }
@@ -10,4 +10,5 @@
10
10
  export const VOICE_STYLE_INSTRUCTION = 'Telegraphic style: answer first, explain after only if needed. ' +
11
11
  'No markdown (no bullets, headers, bold, code blocks). ' +
12
12
  'No preambles ("Great question", "Sure", "Of course"). ' +
13
- 'No filler. Short sentences. One idea per sentence.';
13
+ 'No filler. Short sentences. One idea per sentence. ' +
14
+ 'Never read codes, IDs, or hashes verbatim — describe what they refer to instead.';
@@ -17,4 +17,8 @@ describe('VOICE_STYLE_INSTRUCTION', () => {
17
17
  it('contains key term "no markdown"', () => {
18
18
  expect(VOICE_STYLE_INSTRUCTION.toLowerCase()).toContain('no markdown');
19
19
  });
20
+ it('contains key term "codes" or "IDs"', () => {
21
+ const lower = VOICE_STYLE_INSTRUCTION.toLowerCase();
22
+ expect(lower.includes('codes') || lower.includes('ids')).toBe(true);
23
+ });
20
24
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Minimal Discord bridge routing messages to AI runtimes",
5
5
  "license": "MIT",
6
6
  "repository": {