discoclaw 0.1.5 → 0.1.8
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 +1 -1
- package/.context/pa.md +1 -1
- package/.context/runtime.md +1 -1
- package/.env.example.full +49 -2
- package/README.md +14 -5
- package/dist/cli/index.js +45 -0
- package/dist/cli/init-wizard.js +11 -4
- package/dist/cli/init-wizard.test.js +54 -0
- package/dist/config.js +27 -5
- package/dist/config.test.js +91 -7
- package/dist/cron/executor.js +18 -0
- package/dist/cron/executor.test.js +118 -0
- package/dist/cron/run-stats.js +33 -2
- package/dist/cron/run-stats.test.js +91 -2
- package/dist/discord/action-categories.js +41 -0
- package/dist/discord/actions-config.js +6 -0
- package/dist/discord/actions-config.test.js +57 -0
- package/dist/discord/actions-imagegen.js +177 -62
- package/dist/discord/actions-imagegen.test.js +361 -5
- package/dist/discord/allowlist.js +17 -0
- package/dist/discord/allowlist.test.js +21 -1
- package/dist/discord/deferred-runner.js +36 -7
- package/dist/discord/deferred-runner.test.js +194 -0
- package/dist/discord/forge-commands.js +8 -1
- package/dist/discord/memory-timing.integration.test.js +2 -0
- package/dist/discord/message-batching.js +38 -0
- package/dist/discord/message-batching.test.js +176 -0
- package/dist/discord/message-coordinator.js +156 -41
- package/dist/discord/message-coordinator.onboarding.test.js +2 -0
- package/dist/discord/message-coordinator.plan-run.test.js +2 -0
- package/dist/discord/message-coordinator.reaction-cleanup.test.js +205 -0
- package/dist/discord/models-command.js +2 -0
- package/dist/discord/models-command.test.js +5 -0
- package/dist/discord/output-common.js +90 -4
- package/dist/discord/output-common.test.js +76 -1
- package/dist/discord/output-utils.js +3 -0
- package/dist/discord/plan-manager.js +6 -2
- package/dist/discord/plan-manager.test.js +5 -0
- package/dist/discord/prompt-common.js +1 -1
- package/dist/discord/prompt-common.test.js +2 -0
- package/dist/discord/reaction-handler.js +2 -5
- package/dist/discord/reaction-handler.test.js +40 -1
- package/dist/discord/startup-profile.test.js +2 -0
- package/dist/discord/summarizer.test.js +37 -0
- package/dist/discord/update-command.js +60 -0
- package/dist/discord/update-command.test.js +68 -0
- package/dist/discord/user-errors.js +10 -0
- package/dist/discord/user-errors.test.js +9 -1
- package/dist/discord-followup.test.js +155 -1
- package/dist/discord.fail-closed.test.js +6 -0
- package/dist/discord.health-command.integration.test.js +2 -0
- package/dist/discord.prompt-context.test.js +162 -0
- package/dist/discord.status-wiring.test.js +2 -0
- package/dist/health/startup-healing.js +19 -0
- package/dist/health/startup-healing.test.js +35 -1
- package/dist/index.js +24 -7
- package/dist/npm-managed.js +82 -0
- package/dist/npm-managed.test.js +123 -0
- package/dist/observability/metrics.js +2 -0
- package/dist/runtime/cli-adapter.js +2 -2
- package/dist/runtime/model-tiers.js +41 -1
- package/dist/runtime/model-tiers.test.js +38 -2
- package/dist/runtime/strategies/claude-strategy.js +9 -0
- package/dist/tasks/store.js +61 -0
- package/dist/tasks/store.test.js +124 -0
- package/dist/tasks/task-action-executor.test.js +20 -0
- package/dist/tasks/task-action-read-ops.js +7 -1
- package/package.json +1 -1
package/.context/discord.md
CHANGED
|
@@ -91,7 +91,7 @@ Each action category has its own flag (only active when the master switch is `1`
|
|
|
91
91
|
Notes:
|
|
92
92
|
- `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
93
|
- 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
|
-
- `generateImage`
|
|
94
|
+
- `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`.
|
|
95
95
|
|
|
96
96
|
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
97
|
|
package/.context/pa.md
CHANGED
|
@@ -14,7 +14,7 @@ For architecture details, see `.context/architecture.md`.
|
|
|
14
14
|
| `SOUL.md` | Core personality and values | Every prompt |
|
|
15
15
|
| `IDENTITY.md` | Name and vibe | Every prompt |
|
|
16
16
|
| `USER.md` | Who you're helping | Every prompt |
|
|
17
|
-
| `AGENTS.md` | Your personal rules and conventions |
|
|
17
|
+
| `AGENTS.md` | Your personal rules and conventions | Every prompt |
|
|
18
18
|
| `TOOLS.md` | Available tools and integrations | Every prompt |
|
|
19
19
|
| `HEARTBEAT.md` | Periodic self-check template | By cron |
|
|
20
20
|
| `MEMORY.md` | Curated long-term memory | DM prompts |
|
package/.context/runtime.md
CHANGED
|
@@ -194,7 +194,7 @@ Both require `CLAUDE_OUTPUT_FORMAT=stream-json` for structured events.
|
|
|
194
194
|
|
|
195
195
|
**Budget semantics:** For multi-turn sessions, budget accumulates across turns and cannot be reset mid-session. Recommend $5-10 for multi-turn.
|
|
196
196
|
|
|
197
|
-
**Append system prompt:** When set, workspace PA files (SOUL.md, IDENTITY.md, USER.md, TOOLS.md) are skipped from the context file list (their content is already in the system prompt). PA context modules (`.context/pa.md`, `.context/pa-safety.md`) and channel-specific context are unaffected. **Note:** Do not set this on first run before `workspace/BOOTSTRAP.md` has been consumed — the skip logic also bypasses BOOTSTRAP.md loading.
|
|
197
|
+
**Append system prompt:** When set, workspace PA files (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, TOOLS.md) are skipped from the context file list (their content is already in the system prompt). PA context modules (`.context/pa.md`, `.context/pa-safety.md`) and channel-specific context are unaffected. **Note:** Do not set this on first run before `workspace/BOOTSTRAP.md` has been consumed — the skip logic also bypasses BOOTSTRAP.md loading.
|
|
198
198
|
|
|
199
199
|
- **Session scanner** (`src/runtime/session-scanner.ts`): watches `~/.claude/projects/<escaped-cwd>/<session-id>.jsonl`, skips pre-existing content, degrades gracefully if the file never appears.
|
|
200
200
|
- **Tool-aware queue** (`src/discord/tool-aware-queue.ts`): state machine that suppresses narration text before tools, shows human-readable activity labels (from `src/runtime/tool-labels.ts`), and streams the final answer after all tool use completes.
|
package/.env.example.full
CHANGED
|
@@ -58,6 +58,28 @@ DISCORD_ALLOW_USER_IDS=
|
|
|
58
58
|
# Individual overrides (DISCOCLAW_SUMMARY_MODEL, etc.) still win when set.
|
|
59
59
|
#DISCOCLAW_FAST_MODEL=fast
|
|
60
60
|
|
|
61
|
+
# --- Tier model overrides ---
|
|
62
|
+
# Override the concrete model resolved for any runtime × tier combination.
|
|
63
|
+
# Format: DISCOCLAW_TIER_<RUNTIME>_<FAST|CAPABLE>=<model>
|
|
64
|
+
# Unset = use the built-in default shown in the comments.
|
|
65
|
+
# Concrete model names (e.g. sonnet, gpt-4o-mini) are passed through unchanged.
|
|
66
|
+
#
|
|
67
|
+
# Claude Code adapter (default: fast=haiku, capable=opus):
|
|
68
|
+
#DISCOCLAW_TIER_CLAUDE_CODE_FAST=haiku
|
|
69
|
+
#DISCOCLAW_TIER_CLAUDE_CODE_CAPABLE=opus
|
|
70
|
+
#
|
|
71
|
+
# Gemini CLI adapter (default: fast=gemini-2.5-flash, capable=gemini-2.5-pro):
|
|
72
|
+
#DISCOCLAW_TIER_GEMINI_FAST=gemini-2.5-flash
|
|
73
|
+
#DISCOCLAW_TIER_GEMINI_CAPABLE=gemini-2.5-pro
|
|
74
|
+
#
|
|
75
|
+
# OpenAI-compatible adapter (default: adapter-default for both tiers):
|
|
76
|
+
#DISCOCLAW_TIER_OPENAI_FAST=gpt-4o-mini
|
|
77
|
+
#DISCOCLAW_TIER_OPENAI_CAPABLE=gpt-4o
|
|
78
|
+
#
|
|
79
|
+
# Codex CLI adapter (default: adapter-default for both tiers):
|
|
80
|
+
#DISCOCLAW_TIER_CODEX_FAST=
|
|
81
|
+
#DISCOCLAW_TIER_CODEX_CAPABLE=
|
|
82
|
+
|
|
61
83
|
# Output format for the Claude CLI. stream-json gives smoother streaming.
|
|
62
84
|
#CLAUDE_OUTPUT_FORMAT=stream-json
|
|
63
85
|
|
|
@@ -314,12 +336,19 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
|
|
|
314
336
|
#DISCOCLAW_SESSION_SCANNING=1
|
|
315
337
|
# Parse tool-use events during streaming for better progress reporting and stall suppression.
|
|
316
338
|
#DISCOCLAW_TOOL_AWARE_STREAMING=1
|
|
317
|
-
# Stream stall detection: kill one-shot process if no stdout/stderr for this long (ms). 0 = disabled.
|
|
339
|
+
# Stream stall detection: kill one-shot process if no stdout/stderr for this long (ms). 0 = disabled. (default: 600000)
|
|
318
340
|
#DISCOCLAW_STREAM_STALL_TIMEOUT_MS=120000
|
|
319
341
|
# Progress stall timeout: alert after this many ms with no progress event (ms). 0 = disabled.
|
|
320
342
|
#DISCOCLAW_PROGRESS_STALL_TIMEOUT_MS=300000
|
|
321
|
-
# Stream stall warning: show user-visible warning in Discord after this many ms of no events. 0 = disabled.
|
|
343
|
+
# Stream stall warning: show user-visible warning in Discord after this many ms of no events. 0 = disabled. (default: 300000)
|
|
322
344
|
#DISCOCLAW_STREAM_STALL_WARNING_MS=60000
|
|
345
|
+
# Post a brief "Done (Xm Ys)" completion notice as a new message after long-running runs finish.
|
|
346
|
+
# Discord's unread indicator fires on new messages but not edits, so users who left the channel
|
|
347
|
+
# while the bot was working will see an unread badge. Set to 0 to disable.
|
|
348
|
+
#DISCOCLAW_COMPLETION_NOTIFY=1
|
|
349
|
+
# Minimum elapsed time (ms) before a completion notice is sent. Runs shorter than this are
|
|
350
|
+
# considered "fast" and don't need a notification. Default: 30000 (30 seconds).
|
|
351
|
+
#DISCOCLAW_COMPLETION_NOTIFY_THRESHOLD_MS=30000
|
|
323
352
|
|
|
324
353
|
# ----------------------------------------------------------
|
|
325
354
|
# Plan & Forge — AI-assisted planning and implementation
|
|
@@ -411,3 +440,21 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
|
|
|
411
440
|
# Chromium path for agent-browser. Only needed if the bundled browser
|
|
412
441
|
# (from `agent-browser install`) doesn't work.
|
|
413
442
|
#AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
|
443
|
+
|
|
444
|
+
# ----------------------------------------------------------
|
|
445
|
+
# Image generation
|
|
446
|
+
# ----------------------------------------------------------
|
|
447
|
+
# Master switch — enables the imagegen Discord action category (default: off).
|
|
448
|
+
# When enabled, the AI can generate images via action blocks using OpenAI or Gemini Imagen.
|
|
449
|
+
DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
|
|
450
|
+
# API key for Gemini Imagen models (imagen-4.0-generate-001 and similar).
|
|
451
|
+
# Leave unset to use OpenAI only.
|
|
452
|
+
#IMAGEGEN_GEMINI_API_KEY=
|
|
453
|
+
# Override the default image generation model. If unset, auto-detected:
|
|
454
|
+
# only IMAGEGEN_GEMINI_API_KEY set → imagen-4.0-generate-001; otherwise → dall-e-3.
|
|
455
|
+
# OpenAI models: dall-e-3, gpt-image-1
|
|
456
|
+
# Gemini models: imagen-4.0-generate-001, imagen-4.0-fast-generate-001, imagen-4.0-ultra-generate-001
|
|
457
|
+
#IMAGEGEN_DEFAULT_MODEL=
|
|
458
|
+
# Note: OpenAI image generation reuses OPENAI_API_KEY (documented above in the
|
|
459
|
+
# OpenAI-compatible HTTP adapter section). When DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1,
|
|
460
|
+
# at least one of OPENAI_API_KEY or IMAGEGEN_GEMINI_API_KEY must be set.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# DiscoClaw
|
|
6
6
|
|
|
7
|
-
A personal AI orchestrator that turns Discord into a persistent workspace — built on three pillars: **Memory**, **Tasks**, and **
|
|
7
|
+
A personal AI orchestrator that turns Discord into a persistent workspace — built on three pillars: **Memory**, **Tasks**, and **Automations**.
|
|
8
8
|
|
|
9
9
|
DiscoClaw is an orchestrator: it coordinates between a user interface (Discord), one or more AI runtimes (Claude Code, OpenAI, Codex), and local system resources — managing conversation state, task routing, scheduling, and tool access. The intelligence is rented; the coordination is owned.
|
|
10
10
|
|
|
@@ -39,11 +39,11 @@ A lightweight in-process task store that syncs bidirectionally with Discord foru
|
|
|
39
39
|
- **Create from either side** — Ask your assistant in chat or use task commands
|
|
40
40
|
- **Bidirectional sync** — Status, priority, and tags stay in sync between the task store and Discord threads
|
|
41
41
|
- **Status emoji and auto-tagging** — Thread names show live status at a glance
|
|
42
|
-
- **Discord actions** — Your assistant manages tasks through conversation: create channels, send messages, search history, run polls, and more
|
|
42
|
+
- **Discord actions** — Your assistant manages tasks through conversation: create channels, send messages, search history, run polls, and more (see [docs/discord-actions.md](docs/discord-actions.md))
|
|
43
43
|
|
|
44
44
|
**Why Discord fits:** forum threads = task cards, archive = done, thread names show live status.
|
|
45
45
|
|
|
46
|
-
##
|
|
46
|
+
## Automations — the bot acts on its own
|
|
47
47
|
|
|
48
48
|
Recurring tasks defined as forum threads in plain language — no crontab, no separate scheduler UI.
|
|
49
49
|
|
|
@@ -56,7 +56,7 @@ Recurring tasks defined as forum threads in plain language — no crontab, no se
|
|
|
56
56
|
|
|
57
57
|
## How it works
|
|
58
58
|
|
|
59
|
-
DiscoClaw orchestrates the flow between Discord and AI runtimes (Claude Code by default, with OpenAI, Codex, and OpenRouter adapters available via `PRIMARY_RUNTIME`). It doesn't contain intelligence itself — it decides *when* to call the AI, *what context* to give it, and *what to do* with the output. When you send a message, the orchestrator:
|
|
59
|
+
DiscoClaw orchestrates the flow between Discord and AI runtimes (Claude Code by default, with Gemini, OpenAI, Codex, and OpenRouter adapters available via `PRIMARY_RUNTIME`). It doesn't contain intelligence itself — it decides *when* to call the AI, *what context* to give it, and *what to do* with the output. When you send a message, the orchestrator:
|
|
60
60
|
|
|
61
61
|
1. Checks the user allowlist (fail-closed — empty list means respond to nobody)
|
|
62
62
|
2. Assembles context: per-channel rules, conversation history, rolling summary, and durable memory
|
|
@@ -93,10 +93,11 @@ Author one recipe file for an integration, share it, then let another user's Dis
|
|
|
93
93
|
- **Node.js >=20** — check with `node --version`
|
|
94
94
|
- One primary runtime:
|
|
95
95
|
- **Claude CLI** on your `PATH` — check with `claude --version` (see [Claude CLI docs](https://docs.anthropic.com/en/docs/claude-code) to install), or
|
|
96
|
+
- **Gemini CLI** on your `PATH` — check with `gemini --version`, or
|
|
96
97
|
- **Codex CLI** on your `PATH` — check with `codex --version`, or
|
|
97
98
|
- **OpenAI-compatible API key** via `OPENAI_API_KEY`, or
|
|
98
99
|
- **OpenRouter API key** via `OPENROUTER_API_KEY` (access to many providers)
|
|
99
|
-
- Runtime-specific access for your chosen provider (Anthropic plan/API credits for Claude, OpenAI access for Codex/OpenAI models)
|
|
100
|
+
- Runtime-specific access for your chosen provider (Anthropic plan/API credits for Claude, Google account for Gemini, OpenAI access for Codex/OpenAI models)
|
|
100
101
|
|
|
101
102
|
**Contributors (from source):**
|
|
102
103
|
- Everything above, plus **pnpm** — enable via Corepack (`corepack enable`) or install separately
|
|
@@ -158,6 +159,14 @@ pnpm dev
|
|
|
158
159
|
|
|
159
160
|
**Global install:**
|
|
160
161
|
|
|
162
|
+
If DiscoClaw is running, update from Discord:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
!update apply
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Or from the command line:
|
|
169
|
+
|
|
161
170
|
```bash
|
|
162
171
|
npm update -g discoclaw
|
|
163
172
|
discoclaw install-daemon # re-register the service after updating
|
package/dist/cli/index.js
CHANGED
|
@@ -16,6 +16,49 @@ switch (command) {
|
|
|
16
16
|
case 'install-daemon':
|
|
17
17
|
await runDaemonInstaller();
|
|
18
18
|
break;
|
|
19
|
+
case 'update': {
|
|
20
|
+
const subcommand = process.argv[3];
|
|
21
|
+
const { isNpmManaged, getLocalVersion, getLatestNpmVersion, npmGlobalUpgrade } = await import('../npm-managed.js');
|
|
22
|
+
const npmMode = await isNpmManaged();
|
|
23
|
+
if (subcommand === 'apply') {
|
|
24
|
+
if (!npmMode) {
|
|
25
|
+
console.error('This instance is not npm-managed. Use the git-based workflow to update.');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
console.log('Installing latest version from npm...');
|
|
29
|
+
const result = await npmGlobalUpgrade();
|
|
30
|
+
if (result.exitCode !== 0) {
|
|
31
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 500);
|
|
32
|
+
console.error(`npm install -g discoclaw failed:\n${detail}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
console.log('Update complete. Restart discoclaw to run the new version.');
|
|
36
|
+
}
|
|
37
|
+
else if (subcommand === undefined) {
|
|
38
|
+
if (!npmMode) {
|
|
39
|
+
console.log('This instance is not npm-managed; update checking is not supported in this mode.');
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
const installed = getLocalVersion();
|
|
43
|
+
const latest = await getLatestNpmVersion();
|
|
44
|
+
if (latest === null) {
|
|
45
|
+
console.error('Failed to fetch latest version from npm registry.');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
if (installed === latest) {
|
|
49
|
+
console.log(`Already on latest version (${installed}).`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.log(`Update available: ${installed} → ${latest}. Run \`discoclaw update apply\` to upgrade.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
console.error(`Unknown update subcommand: ${subcommand}\n`);
|
|
57
|
+
printHelp(version);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
19
62
|
case '--version':
|
|
20
63
|
case '-v':
|
|
21
64
|
console.log(version);
|
|
@@ -38,6 +81,8 @@ function printHelp(ver) {
|
|
|
38
81
|
` install-daemon [--service-name <name>] Register discoclaw as a persistent background service\n` +
|
|
39
82
|
` Use --service-name to run multiple instances side-by-side.\n` +
|
|
40
83
|
` Defaults to "discoclaw".\n` +
|
|
84
|
+
` update Check for available updates (npm-managed installs only)\n` +
|
|
85
|
+
` update apply Install the latest version from npm and print restart reminder\n` +
|
|
41
86
|
`\nOptions:\n` +
|
|
42
87
|
` -v, --version Print version\n` +
|
|
43
88
|
` -h, --help Print this help\n`);
|
package/dist/cli/init-wizard.js
CHANGED
|
@@ -105,8 +105,7 @@ export async function runInitWizard() {
|
|
|
105
105
|
console.error('discoclaw init requires an interactive terminal.\n');
|
|
106
106
|
process.exit(1);
|
|
107
107
|
}
|
|
108
|
-
|
|
109
|
-
const envPath = path.join(cwd, '.env');
|
|
108
|
+
let cwd = process.cwd();
|
|
110
109
|
let rl = null;
|
|
111
110
|
let canceled = false;
|
|
112
111
|
let completed = false;
|
|
@@ -163,8 +162,15 @@ export async function runInitWizard() {
|
|
|
163
162
|
}
|
|
164
163
|
}
|
|
165
164
|
// ── Welcome ──────────────────────────────────────────────────────────────
|
|
166
|
-
console.log(`\nDiscoclaw Init\n
|
|
167
|
-
|
|
165
|
+
console.log(`\nDiscoclaw Init\n==============`);
|
|
166
|
+
const installDirInput = await ask(`Install directory [${cwd}]: `);
|
|
167
|
+
if (installDirInput.trim()) {
|
|
168
|
+
cwd = path.resolve(installDirInput.trim());
|
|
169
|
+
if (!fs.existsSync(cwd)) {
|
|
170
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
console.log(`This wizard creates a .env file and workspace/ directory in:\n ${cwd}\n`);
|
|
168
174
|
// ── Discord bot guidance ──────────────────────────────────────────────────
|
|
169
175
|
console.log(`Discord Bot Setup\n-----------------\n` +
|
|
170
176
|
`If you haven't created a Discord bot yet, follow these steps:\n\n` +
|
|
@@ -188,6 +194,7 @@ export async function runInitWizard() {
|
|
|
188
194
|
const values = {};
|
|
189
195
|
values.DISCOCLAW_DATA_DIR = dataDir;
|
|
190
196
|
// ── Check existing .env ───────────────────────────────────────────────────
|
|
197
|
+
const envPath = path.join(cwd, '.env');
|
|
191
198
|
if (fs.existsSync(envPath)) {
|
|
192
199
|
const existing = fs.readFileSync(envPath, 'utf8');
|
|
193
200
|
const tokenMatch = existing.match(/^DISCORD_TOKEN=(.*)$/m);
|
|
@@ -153,6 +153,7 @@ describe('runInitWizard', () => {
|
|
|
153
153
|
const previousCwd = process.cwd();
|
|
154
154
|
const oldEnv = 'DISCORD_TOKEN=old.token.value\nDISCORD_ALLOW_USER_IDS=111111111111111111\n';
|
|
155
155
|
const answers = [
|
|
156
|
+
'', // install directory (default)
|
|
156
157
|
'', // Press Enter to continue
|
|
157
158
|
'', // data directory (default cwd/data)
|
|
158
159
|
'y', // Overwrite existing .env
|
|
@@ -193,6 +194,7 @@ describe('runInitWizard', () => {
|
|
|
193
194
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
|
|
194
195
|
const previousCwd = process.cwd();
|
|
195
196
|
const answers = [
|
|
197
|
+
'', // install directory (default)
|
|
196
198
|
'', // Press Enter to continue
|
|
197
199
|
'', // data directory (default cwd/data)
|
|
198
200
|
// no existing .env
|
|
@@ -229,6 +231,7 @@ describe('runInitWizard', () => {
|
|
|
229
231
|
const previousCwd = process.cwd();
|
|
230
232
|
const customDataDir = path.join(tmpDir, 'my-data');
|
|
231
233
|
const answers = [
|
|
234
|
+
'', // install directory (default)
|
|
232
235
|
'', // Press Enter to continue
|
|
233
236
|
customDataDir, // data directory (custom path)
|
|
234
237
|
'a.b.c', // DISCORD_TOKEN
|
|
@@ -265,6 +268,7 @@ describe('runInitWizard', () => {
|
|
|
265
268
|
'DISCOCLAW_CRON_FORUM=9000000000000000002',
|
|
266
269
|
].join('\n') + '\n';
|
|
267
270
|
const answers = [
|
|
271
|
+
'', // install directory (default)
|
|
268
272
|
'', // Press Enter to continue
|
|
269
273
|
'', // data directory (default cwd/data)
|
|
270
274
|
'y', // Overwrite existing .env
|
|
@@ -299,4 +303,54 @@ describe('runInitWizard', () => {
|
|
|
299
303
|
const tasksIdx = newEnv.indexOf('DISCOCLAW_TASKS_FORUM=');
|
|
300
304
|
expect(tasksIdx).toBeGreaterThan(autoDetectedIdx);
|
|
301
305
|
});
|
|
306
|
+
it('uses a custom install directory when a path is provided', async () => {
|
|
307
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
|
|
308
|
+
const answers = [
|
|
309
|
+
tmpDir, // install directory (custom path)
|
|
310
|
+
'', // Press Enter to continue
|
|
311
|
+
'', // data directory (default)
|
|
312
|
+
'a.b.c', // DISCORD_TOKEN
|
|
313
|
+
'1000000000000000001', // DISCORD_ALLOW_USER_IDS
|
|
314
|
+
'', // provider selection -> default (Claude)
|
|
315
|
+
'', // enable skip permissions
|
|
316
|
+
'', // enable stream-json
|
|
317
|
+
'n', // configure recommended settings
|
|
318
|
+
'n', // configure optional features
|
|
319
|
+
];
|
|
320
|
+
vi.mocked(createInterface).mockReturnValue(makeReadline(answers));
|
|
321
|
+
vi.mocked(execFileSync).mockImplementation(() => {
|
|
322
|
+
throw new Error('binary not found');
|
|
323
|
+
});
|
|
324
|
+
vi.mocked(ensureWorkspaceBootstrapFiles).mockResolvedValue([]);
|
|
325
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
326
|
+
await runInitWizard();
|
|
327
|
+
expect(fs.existsSync(path.join(tmpDir, '.env'))).toBe(true);
|
|
328
|
+
expect(ensureWorkspaceBootstrapFiles).toHaveBeenCalledWith(path.join(tmpDir, 'workspace'));
|
|
329
|
+
});
|
|
330
|
+
it('creates the install directory if it does not exist', async () => {
|
|
331
|
+
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'discoclaw-init-test-'));
|
|
332
|
+
const newDir = path.join(baseDir, 'new-subdir');
|
|
333
|
+
const answers = [
|
|
334
|
+
newDir, // install directory (non-existent path)
|
|
335
|
+
'', // Press Enter to continue
|
|
336
|
+
'', // data directory (default)
|
|
337
|
+
'a.b.c', // DISCORD_TOKEN
|
|
338
|
+
'1000000000000000001', // DISCORD_ALLOW_USER_IDS
|
|
339
|
+
'', // provider selection -> default (Claude)
|
|
340
|
+
'', // enable skip permissions
|
|
341
|
+
'', // enable stream-json
|
|
342
|
+
'n', // configure recommended settings
|
|
343
|
+
'n', // configure optional features
|
|
344
|
+
];
|
|
345
|
+
vi.mocked(createInterface).mockReturnValue(makeReadline(answers));
|
|
346
|
+
vi.mocked(execFileSync).mockImplementation(() => {
|
|
347
|
+
throw new Error('binary not found');
|
|
348
|
+
});
|
|
349
|
+
vi.mocked(ensureWorkspaceBootstrapFiles).mockResolvedValue([]);
|
|
350
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
351
|
+
await runInitWizard();
|
|
352
|
+
expect(fs.existsSync(newDir)).toBe(true);
|
|
353
|
+
expect(fs.existsSync(path.join(newDir, '.env'))).toBe(true);
|
|
354
|
+
expect(ensureWorkspaceBootstrapFiles).toHaveBeenCalledWith(path.join(newDir, 'workspace'));
|
|
355
|
+
});
|
|
302
356
|
});
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseAllowChannelIds, parseAllowUserIds } from './discord/allowlist.js';
|
|
1
|
+
import { parseAllowBotIds, parseAllowChannelIds, parseAllowUserIds } from './discord/allowlist.js';
|
|
2
2
|
export const KNOWN_TOOLS = new Set(['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch']);
|
|
3
3
|
export const DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DELAY_SECONDS = 1800;
|
|
4
4
|
export const DEFAULT_DISCORD_ACTIONS_DEFER_MAX_CONCURRENT = 5;
|
|
@@ -115,6 +115,12 @@ export function parseConfig(env) {
|
|
|
115
115
|
else if (allowUserIds.size === 0) {
|
|
116
116
|
warnings.push('DISCORD_ALLOW_USER_IDS is empty: bot will respond to nobody (fail closed)');
|
|
117
117
|
}
|
|
118
|
+
const allowBotIdsRaw = env.DISCORD_ALLOW_BOT_IDS;
|
|
119
|
+
const allowBotIds = parseAllowBotIds(allowBotIdsRaw);
|
|
120
|
+
if ((allowBotIdsRaw ?? '').trim().length > 0 && allowBotIds.size === 0) {
|
|
121
|
+
warnings.push('DISCORD_ALLOW_BOT_IDS was set but no valid IDs were parsed: trusted-bot allowlist is empty');
|
|
122
|
+
}
|
|
123
|
+
const botMessageMemoryWriteEnabled = parseBoolean(env, 'DISCOCLAW_BOT_MESSAGE_MEMORY_WRITE', false);
|
|
118
124
|
const allowChannelIdsRaw = env.DISCORD_CHANNEL_IDS;
|
|
119
125
|
const restrictChannelIds = (allowChannelIdsRaw ?? '').trim().length > 0;
|
|
120
126
|
const allowChannelIds = parseAllowChannelIds(allowChannelIdsRaw);
|
|
@@ -196,6 +202,8 @@ export function parseConfig(env) {
|
|
|
196
202
|
const openaiApiKey = parseTrimmedString(env, 'OPENAI_API_KEY');
|
|
197
203
|
const openaiBaseUrl = parseTrimmedString(env, 'OPENAI_BASE_URL');
|
|
198
204
|
const openaiModel = parseTrimmedString(env, 'OPENAI_MODEL') ?? 'gpt-4o';
|
|
205
|
+
const imagegenGeminiApiKey = parseTrimmedString(env, 'IMAGEGEN_GEMINI_API_KEY');
|
|
206
|
+
const imagegenDefaultModel = parseTrimmedString(env, 'IMAGEGEN_DEFAULT_MODEL');
|
|
199
207
|
if (primaryRuntime === 'openai' && !openaiApiKey) {
|
|
200
208
|
warnings.push('PRIMARY_RUNTIME=openai but OPENAI_API_KEY is not set; startup will fail unless another runtime is selected.');
|
|
201
209
|
}
|
|
@@ -205,8 +213,16 @@ export function parseConfig(env) {
|
|
|
205
213
|
if (forgeAuditorRuntime === 'openai' && !openaiApiKey) {
|
|
206
214
|
warnings.push('FORGE_AUDITOR_RUNTIME=openai but OPENAI_API_KEY is not set; auditor will fall back to the primary runtime.');
|
|
207
215
|
}
|
|
208
|
-
if (discordActionsImagegen && !openaiApiKey) {
|
|
209
|
-
warnings.push('DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1 but OPENAI_API_KEY is
|
|
216
|
+
if (discordActionsImagegen && !openaiApiKey && !imagegenGeminiApiKey) {
|
|
217
|
+
warnings.push('DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1 but neither OPENAI_API_KEY nor IMAGEGEN_GEMINI_API_KEY is set; imagegen will fail at runtime.');
|
|
218
|
+
}
|
|
219
|
+
if (imagegenDefaultModel) {
|
|
220
|
+
if (imagegenDefaultModel.startsWith('imagen-') && !imagegenGeminiApiKey) {
|
|
221
|
+
warnings.push(`IMAGEGEN_DEFAULT_MODEL="${imagegenDefaultModel}" requires IMAGEGEN_GEMINI_API_KEY but it is not set; imagegen will fail at runtime.`);
|
|
222
|
+
}
|
|
223
|
+
else if ((imagegenDefaultModel.startsWith('dall-e-') || imagegenDefaultModel.startsWith('gpt-image-')) && !openaiApiKey) {
|
|
224
|
+
warnings.push(`IMAGEGEN_DEFAULT_MODEL="${imagegenDefaultModel}" requires OPENAI_API_KEY but it is not set; imagegen will fail at runtime.`);
|
|
225
|
+
}
|
|
210
226
|
}
|
|
211
227
|
const openrouterApiKey = parseTrimmedString(env, 'OPENROUTER_API_KEY');
|
|
212
228
|
const openrouterBaseUrl = parseTrimmedString(env, 'OPENROUTER_BASE_URL');
|
|
@@ -236,6 +252,8 @@ export function parseConfig(env) {
|
|
|
236
252
|
config: {
|
|
237
253
|
token,
|
|
238
254
|
allowUserIds,
|
|
255
|
+
allowBotIds,
|
|
256
|
+
botMessageMemoryWriteEnabled,
|
|
239
257
|
allowChannelIds,
|
|
240
258
|
restrictChannelIds,
|
|
241
259
|
primaryRuntime,
|
|
@@ -307,9 +325,13 @@ export function parseConfig(env) {
|
|
|
307
325
|
forgeTimeoutMs: parsePositiveNumber(env, 'FORGE_TIMEOUT_MS', DEFAULT_THIRTY_MINUTES_MS),
|
|
308
326
|
forgeProgressThrottleMs: parseNonNegativeInt(env, 'FORGE_PROGRESS_THROTTLE_MS', 3000),
|
|
309
327
|
forgeAutoImplement: parseBoolean(env, 'FORGE_AUTO_IMPLEMENT', true),
|
|
328
|
+
completionNotifyEnabled: parseBoolean(env, 'DISCOCLAW_COMPLETION_NOTIFY', true),
|
|
329
|
+
completionNotifyThresholdMs: parseNonNegativeInt(env, 'DISCOCLAW_COMPLETION_NOTIFY_THRESHOLD_MS', 30000),
|
|
310
330
|
openaiApiKey,
|
|
311
331
|
openaiBaseUrl,
|
|
312
332
|
openaiModel,
|
|
333
|
+
imagegenGeminiApiKey,
|
|
334
|
+
imagegenDefaultModel,
|
|
313
335
|
forgeDrafterRuntime,
|
|
314
336
|
forgeAuditorRuntime,
|
|
315
337
|
openrouterApiKey,
|
|
@@ -372,9 +394,9 @@ export function parseConfig(env) {
|
|
|
372
394
|
multiTurnHangTimeoutMs: parsePositiveInt(env, 'DISCOCLAW_MULTI_TURN_HANG_TIMEOUT_MS', 60000),
|
|
373
395
|
multiTurnIdleTimeoutMs: parsePositiveInt(env, 'DISCOCLAW_MULTI_TURN_IDLE_TIMEOUT_MS', 300000),
|
|
374
396
|
multiTurnMaxProcesses: parsePositiveInt(env, 'DISCOCLAW_MULTI_TURN_MAX_PROCESSES', 5),
|
|
375
|
-
streamStallTimeoutMs: parseNonNegativeInt(env, 'DISCOCLAW_STREAM_STALL_TIMEOUT_MS',
|
|
397
|
+
streamStallTimeoutMs: parseNonNegativeInt(env, 'DISCOCLAW_STREAM_STALL_TIMEOUT_MS', 600000),
|
|
376
398
|
progressStallTimeoutMs: parseNonNegativeInt(env, 'DISCOCLAW_PROGRESS_STALL_TIMEOUT_MS', 300000),
|
|
377
|
-
streamStallWarningMs: parseNonNegativeInt(env, 'DISCOCLAW_STREAM_STALL_WARNING_MS',
|
|
399
|
+
streamStallWarningMs: parseNonNegativeInt(env, 'DISCOCLAW_STREAM_STALL_WARNING_MS', 300000),
|
|
378
400
|
maxConcurrentInvocations: parseNonNegativeInt(env, 'DISCOCLAW_MAX_CONCURRENT_INVOCATIONS', 0),
|
|
379
401
|
debugRuntime: parseBoolean(env, 'DISCOCLAW_DEBUG_RUNTIME', false),
|
|
380
402
|
healthCommandsEnabled: parseBoolean(env, 'DISCOCLAW_HEALTH_COMMANDS_ENABLED', true),
|
package/dist/config.test.js
CHANGED
|
@@ -15,6 +15,8 @@ describe('parseConfig', () => {
|
|
|
15
15
|
const { config, warnings, infos } = parseConfig(env());
|
|
16
16
|
expect(config.token).toBe('token');
|
|
17
17
|
expect(config.allowUserIds.has('123')).toBe(true);
|
|
18
|
+
expect(config.allowBotIds).toEqual(new Set());
|
|
19
|
+
expect(config.botMessageMemoryWriteEnabled).toBe(false);
|
|
18
20
|
expect(config.primaryRuntime).toBe('claude');
|
|
19
21
|
expect(config.runtimeModel).toBe('capable');
|
|
20
22
|
expect(config.summaryModel).toBe('fast');
|
|
@@ -28,6 +30,44 @@ describe('parseConfig', () => {
|
|
|
28
30
|
expect(warnings.some((w) => w.includes('category flags are ignored'))).toBe(false);
|
|
29
31
|
expect(infos.some((i) => i.includes('category flags are ignored'))).toBe(false);
|
|
30
32
|
});
|
|
33
|
+
// --- allowBotIds ---
|
|
34
|
+
it('defaults allowBotIds to empty set', () => {
|
|
35
|
+
const { config } = parseConfig(env());
|
|
36
|
+
expect(config.allowBotIds).toEqual(new Set());
|
|
37
|
+
});
|
|
38
|
+
it('parses DISCORD_ALLOW_BOT_IDS as a set of snowflakes', () => {
|
|
39
|
+
const { config } = parseConfig(env({ DISCORD_ALLOW_BOT_IDS: '111, 222 333' }));
|
|
40
|
+
expect(config.allowBotIds).toEqual(new Set(['111', '222', '333']));
|
|
41
|
+
});
|
|
42
|
+
it('drops non-numeric tokens from DISCORD_ALLOW_BOT_IDS', () => {
|
|
43
|
+
const { config } = parseConfig(env({ DISCORD_ALLOW_BOT_IDS: 'mybot 999' }));
|
|
44
|
+
expect(config.allowBotIds).toEqual(new Set(['999']));
|
|
45
|
+
});
|
|
46
|
+
it('returns empty set for allowBotIds when DISCORD_ALLOW_BOT_IDS is undefined', () => {
|
|
47
|
+
const { config } = parseConfig(env({ DISCORD_ALLOW_BOT_IDS: undefined }));
|
|
48
|
+
expect(config.allowBotIds).toEqual(new Set());
|
|
49
|
+
});
|
|
50
|
+
// --- botMessageMemoryWriteEnabled ---
|
|
51
|
+
it('defaults botMessageMemoryWriteEnabled to false', () => {
|
|
52
|
+
const { config } = parseConfig(env());
|
|
53
|
+
expect(config.botMessageMemoryWriteEnabled).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
it('sets botMessageMemoryWriteEnabled to true when DISCOCLAW_BOT_MESSAGE_MEMORY_WRITE=true', () => {
|
|
56
|
+
const { config } = parseConfig(env({ DISCOCLAW_BOT_MESSAGE_MEMORY_WRITE: 'true' }));
|
|
57
|
+
expect(config.botMessageMemoryWriteEnabled).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
it('sets botMessageMemoryWriteEnabled to true when DISCOCLAW_BOT_MESSAGE_MEMORY_WRITE=1', () => {
|
|
60
|
+
const { config } = parseConfig(env({ DISCOCLAW_BOT_MESSAGE_MEMORY_WRITE: '1' }));
|
|
61
|
+
expect(config.botMessageMemoryWriteEnabled).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it('leaves botMessageMemoryWriteEnabled false when DISCOCLAW_BOT_MESSAGE_MEMORY_WRITE=false', () => {
|
|
64
|
+
const { config } = parseConfig(env({ DISCOCLAW_BOT_MESSAGE_MEMORY_WRITE: 'false' }));
|
|
65
|
+
expect(config.botMessageMemoryWriteEnabled).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
it('emits a warning when DISCORD_ALLOW_BOT_IDS is set but yields no valid IDs', () => {
|
|
68
|
+
const { warnings } = parseConfig(env({ DISCORD_ALLOW_BOT_IDS: 'not-a-snowflake' }));
|
|
69
|
+
expect(warnings.some((w) => w.includes('DISCORD_ALLOW_BOT_IDS was set but no valid IDs were parsed'))).toBe(true);
|
|
70
|
+
});
|
|
31
71
|
it('throws on invalid boolean values', () => {
|
|
32
72
|
expect(() => parseConfig(env({ DISCOCLAW_SUMMARY_ENABLED: 'yes' })))
|
|
33
73
|
.toThrow(/DISCOCLAW_SUMMARY_ENABLED must be "0"\/"1" or "true"\/"false"/);
|
|
@@ -203,14 +243,58 @@ describe('parseConfig', () => {
|
|
|
203
243
|
}));
|
|
204
244
|
expect(infos.some((i) => i.includes('DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN'))).toBe(true);
|
|
205
245
|
});
|
|
206
|
-
it('warns when discordActionsImagegen is enabled but
|
|
207
|
-
const { warnings } = parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN: '1', OPENAI_API_KEY: undefined }));
|
|
246
|
+
it('warns when discordActionsImagegen is enabled but neither key is set', () => {
|
|
247
|
+
const { warnings } = parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN: '1', OPENAI_API_KEY: undefined, IMAGEGEN_GEMINI_API_KEY: undefined }));
|
|
208
248
|
expect(warnings.some((w) => w.includes('DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1'))).toBe(true);
|
|
209
249
|
});
|
|
250
|
+
it('does not warn about imagegen key when OPENAI_API_KEY is set', () => {
|
|
251
|
+
const { warnings } = parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN: '1', OPENAI_API_KEY: 'sk-test', IMAGEGEN_GEMINI_API_KEY: undefined }));
|
|
252
|
+
expect(warnings.some((w) => w.includes('DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1'))).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
it('does not warn about imagegen key when IMAGEGEN_GEMINI_API_KEY is set', () => {
|
|
255
|
+
const { warnings } = parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN: '1', OPENAI_API_KEY: undefined, IMAGEGEN_GEMINI_API_KEY: 'gemini-key' }));
|
|
256
|
+
expect(warnings.some((w) => w.includes('DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1'))).toBe(false);
|
|
257
|
+
});
|
|
210
258
|
it('does not warn about imagegen key when discordActionsImagegen is disabled', () => {
|
|
211
|
-
const { warnings } = parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN: '0', OPENAI_API_KEY: undefined }));
|
|
259
|
+
const { warnings } = parseConfig(env({ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN: '0', OPENAI_API_KEY: undefined, IMAGEGEN_GEMINI_API_KEY: undefined }));
|
|
212
260
|
expect(warnings.some((w) => w.includes('DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=1'))).toBe(false);
|
|
213
261
|
});
|
|
262
|
+
it('parses IMAGEGEN_GEMINI_API_KEY when set', () => {
|
|
263
|
+
const { config } = parseConfig(env({ IMAGEGEN_GEMINI_API_KEY: 'gemini-key' }));
|
|
264
|
+
expect(config.imagegenGeminiApiKey).toBe('gemini-key');
|
|
265
|
+
});
|
|
266
|
+
it('returns undefined for imagegenGeminiApiKey when unset', () => {
|
|
267
|
+
const { config } = parseConfig(env());
|
|
268
|
+
expect(config.imagegenGeminiApiKey).toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
it('parses IMAGEGEN_DEFAULT_MODEL when set', () => {
|
|
271
|
+
const { config } = parseConfig(env({ IMAGEGEN_DEFAULT_MODEL: 'imagen-4.0-generate-002', IMAGEGEN_GEMINI_API_KEY: 'gemini-key' }));
|
|
272
|
+
expect(config.imagegenDefaultModel).toBe('imagen-4.0-generate-002');
|
|
273
|
+
});
|
|
274
|
+
it('returns undefined for imagegenDefaultModel when unset', () => {
|
|
275
|
+
const { config } = parseConfig(env());
|
|
276
|
+
expect(config.imagegenDefaultModel).toBeUndefined();
|
|
277
|
+
});
|
|
278
|
+
it('warns when IMAGEGEN_DEFAULT_MODEL is an imagen-* model but IMAGEGEN_GEMINI_API_KEY is unset', () => {
|
|
279
|
+
const { warnings } = parseConfig(env({ IMAGEGEN_DEFAULT_MODEL: 'imagen-4.0-generate-002', IMAGEGEN_GEMINI_API_KEY: undefined }));
|
|
280
|
+
expect(warnings.some((w) => w.includes('IMAGEGEN_DEFAULT_MODEL') && w.includes('IMAGEGEN_GEMINI_API_KEY'))).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
it('warns when IMAGEGEN_DEFAULT_MODEL is a dall-e-* model but OPENAI_API_KEY is unset', () => {
|
|
283
|
+
const { warnings } = parseConfig(env({ IMAGEGEN_DEFAULT_MODEL: 'dall-e-3', OPENAI_API_KEY: undefined }));
|
|
284
|
+
expect(warnings.some((w) => w.includes('IMAGEGEN_DEFAULT_MODEL') && w.includes('OPENAI_API_KEY'))).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
it('warns when IMAGEGEN_DEFAULT_MODEL is a gpt-image-* model but OPENAI_API_KEY is unset', () => {
|
|
287
|
+
const { warnings } = parseConfig(env({ IMAGEGEN_DEFAULT_MODEL: 'gpt-image-1', OPENAI_API_KEY: undefined }));
|
|
288
|
+
expect(warnings.some((w) => w.includes('IMAGEGEN_DEFAULT_MODEL') && w.includes('OPENAI_API_KEY'))).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
it('does not warn about IMAGEGEN_DEFAULT_MODEL when imagen-* and IMAGEGEN_GEMINI_API_KEY is set', () => {
|
|
291
|
+
const { warnings } = parseConfig(env({ IMAGEGEN_DEFAULT_MODEL: 'imagen-4.0-generate-002', IMAGEGEN_GEMINI_API_KEY: 'gemini-key' }));
|
|
292
|
+
expect(warnings.some((w) => w.includes('IMAGEGEN_DEFAULT_MODEL'))).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
it('does not warn about IMAGEGEN_DEFAULT_MODEL when dall-e-* and OPENAI_API_KEY is set', () => {
|
|
295
|
+
const { warnings } = parseConfig(env({ IMAGEGEN_DEFAULT_MODEL: 'dall-e-3', OPENAI_API_KEY: 'sk-test' }));
|
|
296
|
+
expect(warnings.some((w) => w.includes('IMAGEGEN_DEFAULT_MODEL'))).toBe(false);
|
|
297
|
+
});
|
|
214
298
|
it('defaults sessionScanning to true', () => {
|
|
215
299
|
const { config } = parseConfig(env());
|
|
216
300
|
expect(config.sessionScanning).toBe(true);
|
|
@@ -535,13 +619,13 @@ describe('parseConfig', () => {
|
|
|
535
619
|
expect(warnings).toContainEqual(expect.stringContaining('CLAUDE_VERBOSE=1 ignored'));
|
|
536
620
|
});
|
|
537
621
|
// --- Stream stall detection ---
|
|
538
|
-
it('defaults streamStallTimeoutMs to
|
|
622
|
+
it('defaults streamStallTimeoutMs to 600000', () => {
|
|
539
623
|
const { config } = parseConfig(env());
|
|
540
|
-
expect(config.streamStallTimeoutMs).toBe(
|
|
624
|
+
expect(config.streamStallTimeoutMs).toBe(600000);
|
|
541
625
|
});
|
|
542
|
-
it('defaults streamStallWarningMs to
|
|
626
|
+
it('defaults streamStallWarningMs to 300000', () => {
|
|
543
627
|
const { config } = parseConfig(env());
|
|
544
|
-
expect(config.streamStallWarningMs).toBe(
|
|
628
|
+
expect(config.streamStallWarningMs).toBe(300000);
|
|
545
629
|
});
|
|
546
630
|
it('parses custom streamStallTimeoutMs', () => {
|
|
547
631
|
const { config } = parseConfig(env({ DISCOCLAW_STREAM_STALL_TIMEOUT_MS: '30000' }));
|
package/dist/cron/executor.js
CHANGED
|
@@ -49,6 +49,15 @@ export async function executeCronJob(job, ctx) {
|
|
|
49
49
|
job.running = true;
|
|
50
50
|
ctx.runControl?.register(job.id, requestCancel);
|
|
51
51
|
try {
|
|
52
|
+
// Best-effort: write running status to persistent store before execution begins.
|
|
53
|
+
if (ctx.statsStore && job.cronId) {
|
|
54
|
+
try {
|
|
55
|
+
await ctx.statsStore.recordRunStart(job.cronId);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Non-fatal — don't block execution.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
52
61
|
// Resolve the target channel from the job's owning guild.
|
|
53
62
|
const guild = ctx.client.guilds.cache.get(job.guildId);
|
|
54
63
|
if (!guild) {
|
|
@@ -195,6 +204,14 @@ export async function executeCronJob(job, ctx) {
|
|
|
195
204
|
if (!output.trim() && collectedImages.length === 0) {
|
|
196
205
|
metrics.increment('cron.run.skipped');
|
|
197
206
|
ctx.log?.warn({ jobId: job.id }, 'cron:exec empty output');
|
|
207
|
+
if (ctx.statsStore && job.cronId) {
|
|
208
|
+
try {
|
|
209
|
+
await ctx.statsStore.recordRun(job.cronId, 'success');
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Best-effort.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
198
215
|
return;
|
|
199
216
|
}
|
|
200
217
|
let processedText = output;
|
|
@@ -223,6 +240,7 @@ export async function executeCronJob(job, ctx) {
|
|
|
223
240
|
forgeCtx: ctx.forgeCtx,
|
|
224
241
|
planCtx: ctx.planCtx,
|
|
225
242
|
memoryCtx: ctx.memoryCtx,
|
|
243
|
+
imagegenCtx: ctx.imagegenCtx,
|
|
226
244
|
});
|
|
227
245
|
for (const result of results) {
|
|
228
246
|
metrics.recordActionResult(result.ok);
|