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.
- package/.context/discord.md +3 -0
- package/.context/memory.md +4 -2
- package/.context/pa.md +1 -0
- package/.context/tasks.md +7 -0
- package/.context/voice.md +1 -0
- package/.env.example +2 -2
- package/.env.example.full +22 -7
- package/README.md +12 -0
- package/dist/config.js +16 -0
- package/dist/cron/auto-tag.js +9 -6
- package/dist/cron/cron-sync-coordinator.test.js +5 -4
- package/dist/cron/cron-sync.js +50 -3
- package/dist/cron/cron-tag-map-watcher.test.js +1 -1
- package/dist/cron/executor.js +3 -4
- package/dist/cron/run-stats.js +5 -1
- package/dist/cron/run-stats.test.js +6 -6
- package/dist/discord/action-categories.js +2 -0
- package/dist/discord/actions-config.js +1 -1
- package/dist/discord/actions-crons.js +73 -8
- package/dist/discord/actions-crons.test.js +13 -10
- package/dist/discord/actions-messaging.js +3 -2
- package/dist/discord/actions-messaging.test.js +21 -0
- package/dist/discord/actions-spawn.js +118 -0
- package/dist/discord/actions-spawn.test.js +385 -0
- package/dist/discord/actions.js +53 -2
- package/dist/discord/deferred-runner.js +9 -7
- package/dist/discord/deferred-runner.test.js +30 -0
- package/dist/discord/inflight-replies.js +31 -1
- package/dist/discord/inflight-replies.test.js +93 -0
- package/dist/discord/message-coordinator.js +58 -16
- package/dist/discord/message-coordinator.reaction-action-ordering.test.js +188 -0
- package/dist/discord/message-coordinator.reaction-cleanup.test.js +12 -6
- package/dist/discord/output-common.js +3 -3
- package/dist/discord/output-utils.js +57 -2
- package/dist/discord/prompt-common.js +33 -0
- package/dist/discord/prompt-common.test.js +66 -1
- package/dist/discord/reaction-handler.js +9 -6
- package/dist/discord/reaction-handler.test.js +29 -0
- package/dist/discord/transport-client.js +87 -0
- package/dist/discord/transport-client.test.js +273 -0
- package/dist/discord/update-command.js +1 -1
- package/dist/discord/update-command.test.js +1 -0
- package/dist/discord.js +1 -1
- package/dist/discord.render.test.js +51 -1
- package/dist/index.js +19 -1
- package/dist/npm-managed.js +1 -1
- package/dist/npm-managed.test.js +1 -1
- package/dist/runtime/model-tiers.js +10 -6
- package/dist/tasks/task-action-executor.test.js +21 -0
- package/dist/tasks/task-action-mutations.js +4 -0
- package/dist/voice/tts-deepgram.js +8 -0
- package/dist/voice/tts-deepgram.test.js +23 -0
- package/dist/voice/tts-factory.js +1 -0
- package/dist/voice/voice-sanitize.js +42 -0
- package/dist/voice/voice-sanitize.test.js +117 -0
- package/dist/voice/voice-style-prompt.js +4 -1
- package/dist/voice/voice-style-prompt.test.js +4 -0
- package/package.json +1 -1
package/.context/discord.md
CHANGED
|
@@ -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/memory.md
CHANGED
|
@@ -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
|
|
196
|
-
**~
|
|
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,
|
package/dist/cron/auto-tag.js
CHANGED
|
@@ -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
|
|
74
|
-
`multi-step planning,
|
|
75
|
-
`
|
|
76
|
-
`
|
|
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
|
-
|
|
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());
|
package/dist/cron/cron-sync.js
CHANGED
|
@@ -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', () => {
|
package/dist/cron/executor.js
CHANGED
|
@@ -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 =
|
|
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)');
|
package/dist/cron/run-stats.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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');
|
|
@@ -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 |
|