discoclaw 0.4.0 → 0.5.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 (58) hide show
  1. package/.context/discord.md +3 -0
  2. package/.context/memory.md +4 -2
  3. package/.context/pa.md +1 -0
  4. package/.context/tasks.md +7 -0
  5. package/.context/voice.md +1 -0
  6. package/.env.example +2 -2
  7. package/.env.example.full +22 -7
  8. package/README.md +12 -0
  9. package/dist/config.js +16 -0
  10. package/dist/cron/auto-tag.js +9 -6
  11. package/dist/cron/cron-sync-coordinator.test.js +5 -4
  12. package/dist/cron/cron-sync.js +50 -3
  13. package/dist/cron/cron-tag-map-watcher.test.js +1 -1
  14. package/dist/cron/executor.js +3 -4
  15. package/dist/cron/run-stats.js +5 -1
  16. package/dist/cron/run-stats.test.js +6 -6
  17. package/dist/discord/action-categories.js +2 -0
  18. package/dist/discord/actions-config.js +1 -1
  19. package/dist/discord/actions-crons.js +73 -8
  20. package/dist/discord/actions-crons.test.js +13 -10
  21. package/dist/discord/actions-messaging.js +3 -2
  22. package/dist/discord/actions-messaging.test.js +21 -0
  23. package/dist/discord/actions-spawn.js +118 -0
  24. package/dist/discord/actions-spawn.test.js +385 -0
  25. package/dist/discord/actions.js +53 -2
  26. package/dist/discord/deferred-runner.js +9 -7
  27. package/dist/discord/deferred-runner.test.js +30 -0
  28. package/dist/discord/inflight-replies.js +31 -1
  29. package/dist/discord/inflight-replies.test.js +93 -0
  30. package/dist/discord/message-coordinator.js +58 -16
  31. package/dist/discord/message-coordinator.reaction-action-ordering.test.js +188 -0
  32. package/dist/discord/message-coordinator.reaction-cleanup.test.js +12 -6
  33. package/dist/discord/output-common.js +3 -3
  34. package/dist/discord/output-utils.js +57 -2
  35. package/dist/discord/prompt-common.js +33 -0
  36. package/dist/discord/prompt-common.test.js +66 -1
  37. package/dist/discord/reaction-handler.js +9 -6
  38. package/dist/discord/reaction-handler.test.js +29 -0
  39. package/dist/discord/transport-client.js +87 -0
  40. package/dist/discord/transport-client.test.js +273 -0
  41. package/dist/discord/update-command.js +1 -1
  42. package/dist/discord/update-command.test.js +1 -0
  43. package/dist/discord.js +1 -1
  44. package/dist/discord.render.test.js +51 -1
  45. package/dist/index.js +19 -1
  46. package/dist/npm-managed.js +1 -1
  47. package/dist/npm-managed.test.js +1 -1
  48. package/dist/runtime/model-tiers.js +10 -6
  49. package/dist/tasks/task-action-executor.test.js +21 -0
  50. package/dist/tasks/task-action-mutations.js +4 -0
  51. package/dist/voice/tts-deepgram.js +8 -0
  52. package/dist/voice/tts-deepgram.test.js +23 -0
  53. package/dist/voice/tts-factory.js +1 -0
  54. package/dist/voice/voice-sanitize.js +42 -0
  55. package/dist/voice/voice-sanitize.test.js +117 -0
  56. package/dist/voice/voice-style-prompt.js +4 -1
  57. package/dist/voice/voice-style-prompt.test.js +4 -0
  58. package/package.json +1 -1
@@ -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
 
@@ -187,13 +187,14 @@ no separator). The three memory builders run in `Promise.all` so they add no lat
187
187
  | Rolling summary | 2000 chars | on | The `fast`-tier model is prompted with `"Keep the summary under {maxChars} characters"`. Replaces itself each update rather than growing. |
188
188
  | Message history | 3000 chars | on | Fetches up to 10 messages, walks backward from newest. Bot messages truncated to fit; user messages that don't fit cause a hard stop. |
189
189
  | Short-term memory | 1000 chars | **on** | Filters by max age (default 6h), sorts newest-first, accumulates lines until budget hit. |
190
+ | Open tasks | 600 chars | **on** | Queries TaskStore for non-closed tasks at invocation time, formats as a compact list. |
190
191
  | Auto-extraction | n/a | **off** | Write-side only — extracts facts for future prompts, adds nothing to the current turn. |
191
192
  | Workspace files | no budget | on (DMs only) | Loaded as file paths, not inlined. The runtime reads them on demand. |
192
193
 
193
194
  ### Default prompt overhead
194
195
 
195
- With the three enabled layers at default settings, worst-case memory overhead is
196
- **~7000 chars (~1750 tokens)**. With all layers enabled, ~8000 chars (~2000 tokens).
196
+ With the four enabled layers at default settings, worst-case memory overhead is
197
+ **~8600 chars (~2150 tokens)**.
197
198
  This is modest against typical `capable`-tier context windows.
198
199
 
199
200
  In practice most prompts use far less — a user with 5 durable items and a short summary
@@ -216,6 +217,7 @@ Memory sections are injected into every prompt in this order:
216
217
  Context files (PA + MEMORY.md + daily logs + channel context)
217
218
  → Durable memory section (up to 2000 chars)
218
219
  → Short-term memory section (up to 1000 chars)
220
+ → Open tasks section (up to 600 chars)
219
221
  → Rolling summary section (up to 2000 chars)
220
222
  → Message history (up to 3000 chars)
221
223
  → Discord actions
package/.context/pa.md CHANGED
@@ -26,6 +26,7 @@ Templates live in `templates/workspace/` and are scaffolded on first run (copy-i
26
26
  - **Never go silent.** Acknowledge before tool calls.
27
27
  - Narrate failures and pivots.
28
28
  - Summarize outcomes; don't assume the user saw tool output.
29
+ - **Close the loop.** When query actions return data, answer the user's original question in the same response. Don't chain query after query without delivering a conclusion. If you need more data, say what you're checking and why — but every auto-follow-up response should either deliver the answer or visibly advance toward one.
29
30
  - **Never edit `tasks.jsonl`, cron store files, or other data files directly.** Always use the corresponding discord action (`taskUpdate`, `taskCreate`, `cronUpdate`, etc.). Direct file edits bypass Discord thread sync and leave the UI stale.
30
31
 
31
32
  ## Discord Formatting
package/.context/tasks.md CHANGED
@@ -5,6 +5,13 @@ Tasks are backed by an in-process `TaskStore` and synced to Discord forum thread
5
5
  Ground-zero post-hard-cut refactor tracker: `docs/tasks-ground-zero-post-hard-cut-plan.md`
6
6
  Ground-zero task architecture refactor status: COMPLETE (FROZEN), verified on 2026-02-21.
7
7
 
8
+ ## Prompt Injection
9
+
10
+ Open task statuses are injected into prompts at invocation time, sourced directly
11
+ from the TaskStore. This ensures every new session starts with accurate task state
12
+ regardless of rolling summary freshness. See `.context/memory.md` for assembly order
13
+ and token budget.
14
+
8
15
  ## Data Model
9
16
 
10
17
  Canonical type: `TaskData` in `src/tasks/types.ts`.
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.3` | 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 CHANGED
@@ -32,7 +32,7 @@ DISCORD_ALLOW_USER_IDS=
32
32
  # connect and persisted to system-scaffold.json. Only set this to override the auto-created channel.
33
33
  #DISCOCLAW_CRON_FORUM=
34
34
 
35
- # Default model for cron job execution: fast | capable (or concrete model names).
35
+ # Default model for cron job execution: fast | capable | deep (or concrete model names).
36
36
  # Defaults to capable (resolves to Sonnet on Claude Code) — avoids using Opus on routine cron work.
37
37
  # Override at runtime via `!models set cron-exec <model>`.
38
38
  #DISCOCLAW_CRON_EXEC_MODEL=capable
@@ -62,7 +62,7 @@ DISCORD_GUILD_ID=
62
62
  # Disable Codex session persistence/resume (workaround for session DB issues):
63
63
  #CODEX_DISABLE_SESSIONS=1
64
64
 
65
- # Model tier: fast | capable (provider-agnostic).
65
+ # Model tier: fast | capable | deep (provider-agnostic).
66
66
  # Concrete model names (e.g. opus, sonnet, gpt-4o) are still accepted as passthrough.
67
67
  #RUNTIME_MODEL=capable
68
68
 
package/.env.example.full CHANGED
@@ -49,36 +49,40 @@ DISCORD_ALLOW_USER_IDS=
49
49
  #PRIMARY_RUNTIME=claude
50
50
 
51
51
  # --- Primary models ---
52
- # Model tier: fast | capable (provider-agnostic).
52
+ # Model tier: fast | capable | deep (provider-agnostic).
53
53
  # Concrete model names (e.g. opus, sonnet, gpt-4o) are still accepted as passthrough.
54
54
  #RUNTIME_MODEL=capable
55
55
 
56
56
  # --- Fast-tier default ---
57
57
  # Sets the default model for all "small" tasks (summary, cron, cron auto-tag, task auto-tag).
58
- # Individual overrides (DISCOCLAW_SUMMARY_MODEL, etc.) still win when set.
58
+ # Valid tiers: fast | capable | deep. Individual overrides (DISCOCLAW_SUMMARY_MODEL, etc.) still win when set.
59
59
  #DISCOCLAW_FAST_MODEL=fast
60
60
 
61
61
  # --- Tier model overrides ---
62
62
  # Override the concrete model resolved for any runtime × tier combination.
63
- # Format: DISCOCLAW_TIER_<RUNTIME>_<FAST|CAPABLE>=<model>
63
+ # Format: DISCOCLAW_TIER_<RUNTIME>_<FAST|CAPABLE|DEEP>=<model>
64
64
  # Unset = use the built-in default shown in the comments.
65
65
  # Concrete model names (e.g. sonnet, gpt-4o-mini) are passed through unchanged.
66
66
  #
67
67
  # Claude Code adapter (default: fast=haiku, capable=sonnet):
68
68
  #DISCOCLAW_TIER_CLAUDE_CODE_FAST=haiku
69
69
  #DISCOCLAW_TIER_CLAUDE_CODE_CAPABLE=sonnet
70
+ #DISCOCLAW_TIER_CLAUDE_CODE_DEEP=claude-opus-4-6
70
71
  #
71
72
  # Gemini CLI adapter (default: fast=gemini-2.5-flash, capable=gemini-2.5-pro):
72
73
  #DISCOCLAW_TIER_GEMINI_FAST=gemini-2.5-flash
73
74
  #DISCOCLAW_TIER_GEMINI_CAPABLE=gemini-2.5-pro
75
+ #DISCOCLAW_TIER_GEMINI_DEEP=gemini-2.5-pro
74
76
  #
75
77
  # OpenAI-compatible adapter (default: adapter-default for both tiers):
76
78
  #DISCOCLAW_TIER_OPENAI_FAST=gpt-4o-mini
77
79
  #DISCOCLAW_TIER_OPENAI_CAPABLE=gpt-4o
80
+ #DISCOCLAW_TIER_OPENAI_DEEP=
78
81
  #
79
82
  # Codex CLI adapter (default: adapter-default for both tiers):
80
83
  #DISCOCLAW_TIER_CODEX_FAST=
81
84
  #DISCOCLAW_TIER_CODEX_CAPABLE=
85
+ #DISCOCLAW_TIER_CODEX_DEEP=
82
86
  #
83
87
  # Tool-tier map — override which tool tier a model resolves to.
84
88
  # Comma-separated model=tier pairs. Tiers: basic, standard, full.
@@ -111,9 +115,9 @@ DISCORD_ALLOW_USER_IDS=
111
115
  # Cron — forum-based scheduled tasks (enabled by default)
112
116
  # ----------------------------------------------------------
113
117
  # Forum channel ID is auto-created on first connect (see AUTO-DETECTED above).
114
- # Model tier for cron execution: fast | capable (concrete names accepted as passthrough).
118
+ # Model tier for cron execution: fast | capable | deep (concrete names accepted as passthrough).
115
119
  #DISCOCLAW_CRON_MODEL=fast
116
- # Default model tier for cron job execution (fast | capable, concrete names accepted as passthrough).
120
+ # Default model tier for cron job execution (fast | capable | deep, concrete names accepted as passthrough).
117
121
  # Defaults to capable (resolves to Sonnet on Claude Code) — avoids using Opus on routine cron work.
118
122
  # Per-job overrides and AI-classified model still win when set. Override at runtime via
119
123
  # `!models set cron-exec <model>`.
@@ -197,6 +201,14 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
197
201
  # Maximum number of pending deferred invocations allowed at once (default: 5).
198
202
  #DISCOCLAW_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT=5
199
203
 
204
+ # Allow the AI to spawn parallel sub-agents in target channels.
205
+ # Each spawned agent is a fire-and-forget invocation that posts output to the specified channel.
206
+ # Spawned agents run at recursion depth 1 and cannot themselves spawn further agents.
207
+ # Default: on. Set to 0 to disable.
208
+ #DISCOCLAW_DISCORD_ACTIONS_SPAWN=1
209
+ # Maximum number of spawn actions that may run concurrently (default: 8).
210
+ #DISCOCLAW_DISCORD_ACTIONS_SPAWN_MAX_CONCURRENT=8
211
+
200
212
  # ============================================================
201
213
  # OPTIONAL — uncomment sections you need
202
214
  # ============================================================
@@ -323,7 +335,7 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
323
335
  #USE_GROUP_DIR_CWD=0
324
336
  # Tools available to the runtime (Glob, Grep, Write now included by default).
325
337
  #RUNTIME_TOOLS=Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch
326
- # Fallback model tier when primary is overloaded (concrete names accepted as passthrough).
338
+ # Fallback model tier when primary is overloaded: fast | capable | deep (concrete names accepted as passthrough).
327
339
  #RUNTIME_FALLBACK_MODEL=fast
328
340
  # Max USD spend per Claude CLI process. One-shot: per invocation.
329
341
  # Multi-turn: per session lifetime (accumulates across turns). Recommend $5-10.
@@ -521,7 +533,7 @@ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
521
533
  # in system-scaffold.json). Only set this to override the auto-discovered channel.
522
534
  # Leave unset to disable transcript mirroring.
523
535
  #DISCOCLAW_VOICE_LOG_CHANNEL= # e.g. "voice-log" if using the default scaffold
524
- # Model for voice AI responses: tier (fast | capable) or concrete name (sonnet, opus, haiku).
536
+ # Model for voice AI responses: tier (fast | capable | deep) or concrete name (sonnet, opus, haiku).
525
537
  # Independent of RUNTIME_MODEL — allows tuning voice latency vs quality separately from chat.
526
538
  # Switchable at runtime via `modelSet voice <model>`.
527
539
  # Default: follows DISCOCLAW_FAST_MODEL (override here for voice-specific tuning).
@@ -537,5 +549,8 @@ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
537
549
  # Deepgram TTS voice for speech synthesis (default: aura-2-asteria-en).
538
550
  # See https://developers.deepgram.com/docs/tts-models for available voices.
539
551
  #DEEPGRAM_TTS_VOICE=aura-2-asteria-en
552
+ # Deepgram TTS playback speed (range: 0.5–1.5, default: 1.3).
553
+ # Values below 1.0 slow down speech; values above 1.0 speed it up.
554
+ #DEEPGRAM_TTS_SPEED=1.3
540
555
  # API key for Cartesia Sonic-3 TTS. Required when DISCOCLAW_TTS_PROVIDER=cartesia.
541
556
  #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 1.3;
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,
@@ -59,7 +59,7 @@ export async function autoTagCron(runtime, name, prompt, availableTags, opts) {
59
59
  // Model tier classification
60
60
  // ---------------------------------------------------------------------------
61
61
  /**
62
- * Classify whether a cron job needs capable-tier or can run on fast.
62
+ * Classify whether a cron job needs deep/capable-tier or can run on fast.
63
63
  *
64
64
  * Two-step logic:
65
65
  * 1. Cadence default: frequent/hourly (>1x/day) → fast immediately (cost optimization).
@@ -70,10 +70,11 @@ export async function classifyCronModel(runtime, name, prompt, cadence, opts) {
70
70
  if (cadence === 'frequent' || cadence === 'hourly') {
71
71
  return 'fast';
72
72
  }
73
- const classifyPrompt = `Does this scheduled task require advanced reasoning (complex analysis, ` +
74
- `multi-step planning, nuanced writing) or can it be handled with basic ` +
75
- `capabilities (simple lookups, templated responses, data formatting)?\n\n` +
76
- `Reply with ONLY one word: "capable" or "fast"\n\n` +
73
+ const classifyPrompt = `Does this scheduled task require heavy reasoning (complex analysis, ` +
74
+ `multi-step planning, deep code review), standard advanced capabilities ` +
75
+ `(nuanced writing, moderate analysis), or basic capabilities ` +
76
+ `(simple lookups, templated responses, data formatting)?\n\n` +
77
+ `Reply with ONLY one word: "deep", "capable", or "fast"\n\n` +
77
78
  `Job name: ${name}\n` +
78
79
  `Instruction: ${prompt.slice(0, 500)}`;
79
80
  let finalText = '';
@@ -96,5 +97,7 @@ export async function classifyCronModel(runtime, name, prompt, cadence, opts) {
96
97
  }
97
98
  }
98
99
  const output = (finalText || deltaText).trim().toLowerCase();
99
- return output === 'capable' ? 'capable' : 'fast';
100
+ if (output === 'capable' || output === 'deep')
101
+ return output;
102
+ return 'fast';
100
103
  }
@@ -9,6 +9,7 @@ vi.mock('./cron-sync.js', () => ({
9
9
  tagsApplied: 1,
10
10
  namesUpdated: 0,
11
11
  statusMessagesUpdated: 1,
12
+ promptMessagesCreated: 0,
12
13
  orphansDetected: 0,
13
14
  })),
14
15
  }));
@@ -40,7 +41,7 @@ describe('CronSyncCoordinator', () => {
40
41
  vi.resetAllMocks();
41
42
  mockReload.mockResolvedValue(2);
42
43
  mockRunCronSync.mockResolvedValue({
43
- tagsApplied: 1, namesUpdated: 0, statusMessagesUpdated: 1, orphansDetected: 0,
44
+ tagsApplied: 1, namesUpdated: 0, statusMessagesUpdated: 1, promptMessagesCreated: 0, orphansDetected: 0,
44
45
  });
45
46
  });
46
47
  it('reloads tag map before sync when tagMapPath is set', async () => {
@@ -69,7 +70,7 @@ describe('CronSyncCoordinator', () => {
69
70
  it('coalesced concurrent sync returns null', async () => {
70
71
  let resolveSync;
71
72
  mockRunCronSync.mockImplementation(() => new Promise((resolve) => {
72
- resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 });
73
+ resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
73
74
  }));
74
75
  const coordinator = new CronSyncCoordinator(makeOpts());
75
76
  const first = coordinator.sync();
@@ -85,10 +86,10 @@ describe('CronSyncCoordinator', () => {
85
86
  mockRunCronSync.mockImplementation(() => new Promise((resolve) => {
86
87
  callCount++;
87
88
  if (callCount === 1) {
88
- resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 });
89
+ resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
89
90
  }
90
91
  else {
91
- resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 });
92
+ resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
92
93
  }
93
94
  }));
94
95
  const coordinator = new CronSyncCoordinator(makeOpts());
@@ -1,3 +1,4 @@
1
+ import { EmbedBuilder } from 'discord.js';
1
2
  import { CADENCE_TAGS } from './run-stats.js';
2
3
  import { detectCadence } from './cadence.js';
3
4
  import { autoTagCron, classifyCronModel } from './auto-tag.js';
@@ -24,13 +25,14 @@ export async function runCronSync(opts) {
24
25
  const forum = await resolveForumChannel(client, forumId);
25
26
  if (!forum) {
26
27
  log?.warn({ forumId }, 'cron-sync: forum not found');
27
- return { tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 };
28
+ return { tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 };
28
29
  }
29
30
  const tagMap = opts.tagMap;
30
31
  const purposeTags = purposeTagNames(tagMap);
31
32
  let tagsApplied = 0;
32
33
  let namesUpdated = 0;
33
34
  let statusMessagesUpdated = 0;
35
+ let promptMessagesCreated = 0;
34
36
  let orphansDetected = 0;
35
37
  const asEditableCronThread = (value) => {
36
38
  if (!value || typeof value !== 'object')
@@ -172,6 +174,51 @@ export async function runCronSync(opts) {
172
174
  }
173
175
  await sleep(throttleMs);
174
176
  }
177
+ // Phase 3.5: Prompt message backfill.
178
+ for (const job of jobs) {
179
+ const fullJob = scheduler.getJob(job.id);
180
+ if (!fullJob?.cronId)
181
+ continue;
182
+ const record = statsStore.getRecord(fullJob.cronId);
183
+ if (!record?.prompt || record.promptMessageId)
184
+ continue;
185
+ try {
186
+ let thread = null;
187
+ const cached = client.channels.cache.get(fullJob.threadId);
188
+ if (cached && cached.isThread()) {
189
+ thread = cached;
190
+ }
191
+ else {
192
+ try {
193
+ const fetched = await client.channels.fetch(fullJob.threadId);
194
+ if (fetched && fetched.isThread())
195
+ thread = fetched;
196
+ }
197
+ catch {
198
+ // Thread may have been deleted.
199
+ }
200
+ }
201
+ if (!thread)
202
+ continue;
203
+ const embed = new EmbedBuilder()
204
+ .setTitle('\uD83D\uDCCB Cron Prompt')
205
+ .setDescription(record.prompt.slice(0, 4096))
206
+ .setColor(0x5865F2);
207
+ const msg = await thread.send({ embeds: [embed], allowedMentions: { parse: [] } });
208
+ try {
209
+ await msg.pin();
210
+ }
211
+ catch {
212
+ // Non-fatal if pin fails.
213
+ }
214
+ await statsStore.upsertRecord(record.cronId, record.threadId, { promptMessageId: msg.id });
215
+ promptMessagesCreated++;
216
+ }
217
+ catch (err) {
218
+ log?.warn({ err, cronId: fullJob.cronId }, 'cron-sync:phase3.5 prompt message failed');
219
+ }
220
+ await sleep(throttleMs);
221
+ }
175
222
  // Phase 4: Orphan detection (non-destructive, log only).
176
223
  for (const thread of threads.values()) {
177
224
  const editableThread = asEditableCronThread(thread);
@@ -184,6 +231,6 @@ export async function runCronSync(opts) {
184
231
  log?.warn({ threadId: editableThread.id, name: editableThread.name }, 'cron-sync:phase4 orphan thread (no registered job)');
185
232
  }
186
233
  }
187
- log?.info({ tagsApplied, namesUpdated, statusMessagesUpdated, orphansDetected }, 'cron-sync: complete');
188
- return { tagsApplied, namesUpdated, statusMessagesUpdated, orphansDetected };
234
+ log?.info({ tagsApplied, namesUpdated, statusMessagesUpdated, promptMessagesCreated, orphansDetected }, 'cron-sync: complete');
235
+ return { tagsApplied, namesUpdated, statusMessagesUpdated, promptMessagesCreated, orphansDetected };
189
236
  }
@@ -12,7 +12,7 @@ function mockLog() {
12
12
  }
13
13
  function makeCoordinator() {
14
14
  return {
15
- sync: vi.fn(async () => ({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 })),
15
+ sync: vi.fn(async () => ({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 })),
16
16
  };
17
17
  }
18
18
  describe('startCronTagMapWatcher', () => {
@@ -1,5 +1,6 @@
1
1
  import { acquireCronLock, releaseCronLock } from './job-lock.js';
2
2
  import { resolveChannel } from '../discord/action-utils.js';
3
+ import { DiscordTransportClient } from '../discord/transport-client.js';
3
4
  import * as discordActions from '../discord/actions.js';
4
5
  import { sendChunks, appendUnavailableActionTypesNotice, appendParseFailureNotice } from '../discord/output-common.js';
5
6
  import { buildPromptPreamble, loadWorkspacePaFiles, inlineContextFiles, resolveEffectiveTools } from '../discord/prompt-common.js';
@@ -253,6 +254,7 @@ export async function executeCronJob(job, ctx) {
253
254
  channelId: targetChannel.id,
254
255
  messageId: '',
255
256
  deferScheduler: ctx.deferScheduler,
257
+ transport: new DiscordTransportClient(guild, ctx.client),
256
258
  confirmation: {
257
259
  mode: 'automated',
258
260
  },
@@ -270,11 +272,8 @@ export async function executeCronJob(job, ctx) {
270
272
  metrics.recordActionResult(result.ok);
271
273
  ctx.log?.info({ flow: 'cron', jobId: job.id, ok: result.ok }, 'obs.action.result');
272
274
  }
273
- const displayLines = discordActions.buildDisplayResultLines(actions, results);
274
275
  const anyActionSucceeded = results.some((r) => r.ok);
275
- processedText = displayLines.length > 0
276
- ? cleanText.trimEnd() + '\n\n' + displayLines.join('\n')
277
- : cleanText.trimEnd();
276
+ processedText = discordActions.appendActionResults(cleanText.trimEnd(), actions, results);
278
277
  // When all display lines were suppressed and there's no prose, skip posting.
279
278
  if (!processedText.trim() && anyActionSucceeded && strippedUnrecognizedTypes.length === 0 && parseFailuresCount === 0) {
280
279
  ctx.log?.info({ jobId: job.id }, 'cron:reply suppressed (actions-only, no display text)');
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
5
5
  // Types
6
6
  // ---------------------------------------------------------------------------
7
7
  export const CADENCE_TAGS = ['yearly', 'frequent', 'hourly', 'daily', 'weekly', 'monthly'];
8
- export const CURRENT_VERSION = 7;
8
+ export const CURRENT_VERSION = 8;
9
9
  // ---------------------------------------------------------------------------
10
10
  // Stable Cron ID generation
11
11
  // ---------------------------------------------------------------------------
@@ -313,5 +313,9 @@ export async function loadRunStats(filePath) {
313
313
  if (store.version === 6) {
314
314
  store.version = 7;
315
315
  }
316
+ // Migrate v7 → v8: no-op — new field (promptMessageId) is optional and defaults to absent.
317
+ if (store.version === 7) {
318
+ store.version = 8;
319
+ }
316
320
  return new CronRunStats(store, filePath);
317
321
  }
@@ -38,7 +38,7 @@ describe('CronRunStats', () => {
38
38
  it('creates empty store on missing file', async () => {
39
39
  const stats = await loadRunStats(statsPath);
40
40
  const store = stats.getStore();
41
- expect(store.version).toBe(7);
41
+ expect(store.version).toBe(8);
42
42
  expect(Object.keys(store.jobs)).toHaveLength(0);
43
43
  });
44
44
  it('upserts and retrieves records by cronId', async () => {
@@ -244,7 +244,7 @@ describe('CronRunStats', () => {
244
244
  describe('emptyStore', () => {
245
245
  it('returns valid initial structure', () => {
246
246
  const store = emptyStore();
247
- expect(store.version).toBe(7);
247
+ expect(store.version).toBe(8);
248
248
  expect(store.updatedAt).toBeGreaterThan(0);
249
249
  expect(Object.keys(store.jobs)).toHaveLength(0);
250
250
  });
@@ -271,7 +271,7 @@ describe('loadRunStats version migration', () => {
271
271
  };
272
272
  await fs.writeFile(statsPath, JSON.stringify(v3Store), 'utf-8');
273
273
  const stats = await loadRunStats(statsPath);
274
- expect(stats.getStore().version).toBe(7);
274
+ expect(stats.getStore().version).toBe(8);
275
275
  const rec = stats.getRecord('cron-migrated');
276
276
  expect(rec).toBeDefined();
277
277
  expect(rec.cronId).toBe('cron-migrated');
@@ -301,7 +301,7 @@ describe('loadRunStats version migration', () => {
301
301
  };
302
302
  await fs.writeFile(statsPath, JSON.stringify(v4Store), 'utf-8');
303
303
  const stats = await loadRunStats(statsPath);
304
- expect(stats.getStore().version).toBe(7);
304
+ expect(stats.getStore().version).toBe(8);
305
305
  const rec = stats.getRecord('cron-v4');
306
306
  expect(rec).toBeDefined();
307
307
  expect(rec.cronId).toBe('cron-v4');
@@ -331,7 +331,7 @@ describe('loadRunStats version migration', () => {
331
331
  };
332
332
  await fs.writeFile(statsPath, JSON.stringify(v5Store), 'utf-8');
333
333
  const stats = await loadRunStats(statsPath);
334
- expect(stats.getStore().version).toBe(7);
334
+ expect(stats.getStore().version).toBe(8);
335
335
  const rec = stats.getRecord('cron-v5');
336
336
  expect(rec).toBeDefined();
337
337
  expect(rec.cronId).toBe('cron-v5');
@@ -367,7 +367,7 @@ describe('loadRunStats version migration', () => {
367
367
  };
368
368
  await fs.writeFile(statsPath, JSON.stringify(v6Store), 'utf-8');
369
369
  const stats = await loadRunStats(statsPath);
370
- expect(stats.getStore().version).toBe(7);
370
+ expect(stats.getStore().version).toBe(8);
371
371
  const rec = stats.getRecord('cron-v6');
372
372
  expect(rec).toBeDefined();
373
373
  expect(rec.cronId).toBe('cron-v6');
@@ -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));
@@ -297,7 +297,7 @@ export function configActionsPromptSection() {
297
297
  <discord-action>{"type":"modelSet","role":"fast","model":"haiku"}</discord-action>
298
298
  \`\`\`
299
299
  - \`role\` (required): One of \`chat\`, \`fast\`, \`forge-drafter\`, \`forge-auditor\`, \`summary\`, \`cron\`, \`cron-exec\`, \`voice\`.
300
- - \`model\` (required): Model tier (\`fast\`, \`capable\`), concrete model name (\`haiku\`, \`sonnet\`, \`opus\`), runtime name (\`openrouter\`, \`gemini\` — for \`chat\` role, swaps the active runtime adapter), or \`default\` (for cron-exec only, to revert to the env-configured default (Sonnet by default)).
300
+ - \`model\` (required): Model tier (\`fast\`, \`capable\`, \`deep\`), concrete model name (\`haiku\`, \`sonnet\`, \`opus\`), runtime name (\`openrouter\`, \`gemini\` — for \`chat\` role, swaps the active runtime adapter), or \`default\` (for cron-exec only, to revert to the env-configured default (Sonnet by default)).
301
301
 
302
302
  **Roles:**
303
303
  | Role | What it controls |