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.
Files changed (68) hide show
  1. package/.context/discord.md +1 -1
  2. package/.context/pa.md +1 -1
  3. package/.context/runtime.md +1 -1
  4. package/.env.example.full +49 -2
  5. package/README.md +14 -5
  6. package/dist/cli/index.js +45 -0
  7. package/dist/cli/init-wizard.js +11 -4
  8. package/dist/cli/init-wizard.test.js +54 -0
  9. package/dist/config.js +27 -5
  10. package/dist/config.test.js +91 -7
  11. package/dist/cron/executor.js +18 -0
  12. package/dist/cron/executor.test.js +118 -0
  13. package/dist/cron/run-stats.js +33 -2
  14. package/dist/cron/run-stats.test.js +91 -2
  15. package/dist/discord/action-categories.js +41 -0
  16. package/dist/discord/actions-config.js +6 -0
  17. package/dist/discord/actions-config.test.js +57 -0
  18. package/dist/discord/actions-imagegen.js +177 -62
  19. package/dist/discord/actions-imagegen.test.js +361 -5
  20. package/dist/discord/allowlist.js +17 -0
  21. package/dist/discord/allowlist.test.js +21 -1
  22. package/dist/discord/deferred-runner.js +36 -7
  23. package/dist/discord/deferred-runner.test.js +194 -0
  24. package/dist/discord/forge-commands.js +8 -1
  25. package/dist/discord/memory-timing.integration.test.js +2 -0
  26. package/dist/discord/message-batching.js +38 -0
  27. package/dist/discord/message-batching.test.js +176 -0
  28. package/dist/discord/message-coordinator.js +156 -41
  29. package/dist/discord/message-coordinator.onboarding.test.js +2 -0
  30. package/dist/discord/message-coordinator.plan-run.test.js +2 -0
  31. package/dist/discord/message-coordinator.reaction-cleanup.test.js +205 -0
  32. package/dist/discord/models-command.js +2 -0
  33. package/dist/discord/models-command.test.js +5 -0
  34. package/dist/discord/output-common.js +90 -4
  35. package/dist/discord/output-common.test.js +76 -1
  36. package/dist/discord/output-utils.js +3 -0
  37. package/dist/discord/plan-manager.js +6 -2
  38. package/dist/discord/plan-manager.test.js +5 -0
  39. package/dist/discord/prompt-common.js +1 -1
  40. package/dist/discord/prompt-common.test.js +2 -0
  41. package/dist/discord/reaction-handler.js +2 -5
  42. package/dist/discord/reaction-handler.test.js +40 -1
  43. package/dist/discord/startup-profile.test.js +2 -0
  44. package/dist/discord/summarizer.test.js +37 -0
  45. package/dist/discord/update-command.js +60 -0
  46. package/dist/discord/update-command.test.js +68 -0
  47. package/dist/discord/user-errors.js +10 -0
  48. package/dist/discord/user-errors.test.js +9 -1
  49. package/dist/discord-followup.test.js +155 -1
  50. package/dist/discord.fail-closed.test.js +6 -0
  51. package/dist/discord.health-command.integration.test.js +2 -0
  52. package/dist/discord.prompt-context.test.js +162 -0
  53. package/dist/discord.status-wiring.test.js +2 -0
  54. package/dist/health/startup-healing.js +19 -0
  55. package/dist/health/startup-healing.test.js +35 -1
  56. package/dist/index.js +24 -7
  57. package/dist/npm-managed.js +82 -0
  58. package/dist/npm-managed.test.js +123 -0
  59. package/dist/observability/metrics.js +2 -0
  60. package/dist/runtime/cli-adapter.js +2 -2
  61. package/dist/runtime/model-tiers.js +41 -1
  62. package/dist/runtime/model-tiers.test.js +38 -2
  63. package/dist/runtime/strategies/claude-strategy.js +9 -0
  64. package/dist/tasks/store.js +61 -0
  65. package/dist/tasks/store.test.js +124 -0
  66. package/dist/tasks/task-action-executor.test.js +20 -0
  67. package/dist/tasks/task-action-read-ops.js +7 -1
  68. package/package.json +1 -1
@@ -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` requires `IMAGEGEN_API_KEY` to be set (any OpenAI-compatible image generation endpoint). Optional: `IMAGEGEN_BASE_URL` to point at a non-OpenAI provider. Defaults to the DALL-E 3 model at `https://api.openai.com/v1`.
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 | Scaffolded on setup; accessible via Read tool |
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 |
@@ -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 **Crons**.
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
- ## Crons — the bot acts on its own
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`);
@@ -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
- const cwd = process.cwd();
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==============\n` +
167
- `This wizard creates a .env file and workspace/ directory in:\n ${cwd}\n`);
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 not set; imagegen will fail at runtime.');
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', 300000),
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', 150000),
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),
@@ -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 OPENAI_API_KEY is unset', () => {
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 300000', () => {
622
+ it('defaults streamStallTimeoutMs to 600000', () => {
539
623
  const { config } = parseConfig(env());
540
- expect(config.streamStallTimeoutMs).toBe(300000);
624
+ expect(config.streamStallTimeoutMs).toBe(600000);
541
625
  });
542
- it('defaults streamStallWarningMs to 150000', () => {
626
+ it('defaults streamStallWarningMs to 300000', () => {
543
627
  const { config } = parseConfig(env());
544
- expect(config.streamStallWarningMs).toBe(150000);
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' }));
@@ -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);