discoclaw 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.context/pa.md +1 -1
  2. package/.context/runtime.md +48 -4
  3. package/.env.example +6 -0
  4. package/.env.example.full +7 -0
  5. package/README.md +5 -1
  6. package/dist/config.js +2 -0
  7. package/dist/cron/cron-sync-coordinator.js +4 -0
  8. package/dist/cron/cron-sync-coordinator.test.js +8 -0
  9. package/dist/cron/executor.js +36 -1
  10. package/dist/cron/executor.test.js +157 -0
  11. package/dist/cron/forum-sync.js +47 -0
  12. package/dist/cron/forum-sync.test.js +234 -0
  13. package/dist/cron/run-stats.js +10 -3
  14. package/dist/cron/run-stats.test.js +67 -3
  15. package/dist/discord/actions-config.js +41 -8
  16. package/dist/discord/actions-config.test.js +130 -8
  17. package/dist/discord/actions-crons.js +18 -0
  18. package/dist/discord/actions-crons.test.js +12 -0
  19. package/dist/discord/models-command.js +5 -0
  20. package/dist/index.js +28 -0
  21. package/dist/mcp-detect.js +74 -0
  22. package/dist/mcp-detect.test.js +160 -0
  23. package/dist/runtime/openai-compat.js +224 -90
  24. package/dist/runtime/openai-compat.test.js +409 -2
  25. package/dist/runtime/openai-tool-exec.js +433 -0
  26. package/dist/runtime/openai-tool-exec.test.js +267 -0
  27. package/dist/runtime/openai-tool-schemas.js +174 -0
  28. package/dist/runtime/openai-tool-schemas.test.js +74 -0
  29. package/dist/runtime/tools/fs-glob.js +102 -0
  30. package/dist/runtime/tools/fs-glob.test.js +67 -0
  31. package/dist/runtime/tools/fs-read-file.js +49 -0
  32. package/dist/runtime/tools/fs-read-file.test.js +51 -0
  33. package/dist/runtime/tools/fs-realpath.js +51 -0
  34. package/dist/runtime/tools/fs-realpath.test.js +72 -0
  35. package/dist/runtime/tools/fs-write-file.js +45 -0
  36. package/dist/runtime/tools/fs-write-file.test.js +56 -0
  37. package/dist/runtime/tools/image-download.js +138 -0
  38. package/dist/runtime/tools/image-download.test.js +106 -0
  39. package/dist/runtime/tools/path-security.js +72 -0
  40. package/dist/runtime/tools/types.js +4 -0
  41. package/dist/workspace-bootstrap.js +0 -1
  42. package/dist/workspace-bootstrap.test.js +0 -2
  43. package/package.json +1 -1
  44. package/templates/mcp.json +8 -0
  45. package/templates/workspace/TOOLS.md +70 -1
  46. package/templates/workspace/HEARTBEAT.md +0 -10
package/.context/pa.md CHANGED
@@ -16,7 +16,6 @@ For architecture details, see `.context/architecture.md`.
16
16
  | `USER.md` | Who you're helping | Every prompt |
17
17
  | `AGENTS.md` | Your personal rules and conventions | Every prompt |
18
18
  | `TOOLS.md` | Available tools and integrations | Every prompt |
19
- | `HEARTBEAT.md` | Periodic self-check template | By cron |
20
19
  | `MEMORY.md` | Curated long-term memory | DM prompts |
21
20
  | `BOOTSTRAP.md` | First-run onboarding (deleted after) | Once |
22
21
 
@@ -27,6 +26,7 @@ Templates live in `templates/workspace/` and are scaffolded on first run (copy-i
27
26
  - **Never go silent.** Acknowledge before tool calls.
28
27
  - Narrate failures and pivots.
29
28
  - Summarize outcomes; don't assume the user saw tool output.
29
+ - **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
30
 
31
31
  ## Discord Formatting
32
32
 
@@ -114,20 +114,64 @@ Shutdown: `killAllSubprocesses()` from `cli-adapter.ts` kills all tracked subpro
114
114
  | `OPENROUTER_MODEL` | `anthropic/claude-sonnet-4` | Default model (provider-namespaced) |
115
115
  - Model naming: OpenRouter uses provider-namespaced IDs — e.g. `anthropic/claude-sonnet-4`, `openai/gpt-4o`, `google/gemini-2.5-pro`. Never use bare model names.
116
116
  - Capabilities:
117
- - `streaming_text` only
118
- - No tool execution
117
+ - `streaming_text` only (unless `OPENAI_COMPAT_TOOLS_ENABLED=1` — see [OpenAI-Compat Tool Use](#openai-compat-tool-use) below)
118
+ - No tool execution (unless `OPENAI_COMPAT_TOOLS_ENABLED=1`)
119
119
  - No sessions / multi-turn
120
120
  - Health system: credential presence checked via `checkOpenRouterKey` — returns `skip` if key is missing, `fail` if key is present but invalid/expired.
121
121
 
122
+ ## OpenAI-Compat Tool Use
123
+
124
+ The OpenAI-compatible and OpenRouter adapters support optional server-side tool use via the standard OpenAI function-calling protocol. Disabled by default; enable with `OPENAI_COMPAT_TOOLS_ENABLED=1`.
125
+
126
+ | Env Var | Default | Purpose |
127
+ |---------|---------|---------|
128
+ | `OPENAI_COMPAT_TOOLS_ENABLED` | `0` | Enable tool use (function calling) for OpenAI-compat and OpenRouter adapters |
129
+
130
+ When enabled, the adapter:
131
+ 1. Declares `tools_fs` + `tools_exec` capabilities (making it eligible for tool-bearing prompts).
132
+ 2. Sends OpenAI function-calling tool definitions alongside the chat completion request.
133
+ 3. Runs a synchronous (non-streaming) tool loop: model returns `tool_calls` → server executes them → results fed back → repeat until the model returns a final text response or the safety cap (25 rounds) is reached.
134
+
135
+ ### Available tools
136
+
137
+ The tool surface is the same as the configured `RUNTIME_TOOLS` set, mapped to OpenAI function names:
138
+
139
+ | Discoclaw tool | OpenAI function | Notes |
140
+ |----------------|-----------------|-------|
141
+ | Read | `read_file` | Read file contents (with optional offset/limit) |
142
+ | Write | `write_file` | Create or overwrite a file |
143
+ | Edit | `edit_file` | Exact string replacement in a file |
144
+ | Glob | `list_files` | Find files matching a glob pattern |
145
+ | Grep | `search_content` | Regex search over file contents |
146
+ | Bash | `bash` | Execute a shell command (30s timeout, 100KB output cap) |
147
+ | WebFetch | `web_fetch` | Fetch a web page (15s timeout, 512KB cap, SSRF-protected) |
148
+ | WebSearch | `web_search` | **Stub — not yet implemented.** Returns an error message. |
149
+
150
+ Schemas: `src/runtime/openai-tool-schemas.ts`. Execution handlers: `src/runtime/openai-tool-exec.ts`.
151
+
152
+ ### Security
153
+
154
+ - **Path scoping:** File/path tools (Read, Write, Edit, Glob, Grep, Bash) are scoped to the workspace CWD (`WORKSPACE_CWD`) **plus** any additional directories from `--add-dir` / group CWD configuration. Symlink-escape protection via `fs.realpath`.
155
+ - **SSRF protection:** `web_fetch` blocks private/loopback IPs and localhost hostnames; HTTPS only with redirect rejection.
156
+ - **Bash sandboxing:** 30s timeout, 100KB output cap per stream (stdout/stderr).
157
+
158
+ ### Key files
159
+
160
+ | File | Role |
161
+ |------|------|
162
+ | `src/runtime/openai-compat.ts` | Adapter: tool loop logic, capability declaration |
163
+ | `src/runtime/openai-tool-schemas.ts` | Discoclaw → OpenAI function name mapping and JSON Schema definitions |
164
+ | `src/runtime/openai-tool-exec.ts` | Server-side tool execution handlers with path validation |
165
+
122
166
  ## Tool Surface
123
167
  - Default tools: `Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch` (8 tools).
124
168
  - `Glob` + `Grep` are purpose-built for file search — faster than `find`/`grep` via Bash.
125
169
  - `Write` enables proper file creation (previously required Bash echo/cat workarounds).
126
170
  - Non-Claude adapters use a **capability gate** (`tools_fs`) to determine tool access:
127
171
  - Codex CLI adapter: declares `tools_fs` — receives read-only tools (Read, Glob, Grep) in auditor role.
128
- - OpenAI HTTP adapter: text-only (`streaming_text` only) — no tool execution.
172
+ - OpenAI HTTP adapter: when `OPENAI_COMPAT_TOOLS_ENABLED=1`, declares `tools_fs` + `tools_exec` and runs a server-side tool loop (see below). Otherwise text-only (`streaming_text` only).
129
173
  - Gemini CLI adapter: text-only (`streaming_text` only) — no tool execution, no fs tools (Phase 1).
130
- - OpenRouter adapter: text-only (`streaming_text` only) — no tool execution.
174
+ - OpenRouter adapter: when `OPENAI_COMPAT_TOOLS_ENABLED=1`, declares `tools_fs` + `tools_exec` (same adapter, same flag). Otherwise text-only (`streaming_text` only).
131
175
 
132
176
  ## Per-Workspace Permissions
133
177
  - `workspace/PERMISSIONS.json` controls the tool surface per workspace.
package/.env.example CHANGED
@@ -75,6 +75,12 @@ DISCORD_GUILD_ID=
75
75
  # Default model for the Gemini CLI adapter.
76
76
  #GEMINI_MODEL=gemini-2.5-pro
77
77
 
78
+ # --- OpenAI-compatible HTTP adapter ---
79
+ #OPENAI_API_KEY=
80
+ #OPENAI_MODEL=gpt-4o
81
+ # Enable tool use (function calling) for OpenAI-compat and OpenRouter adapters.
82
+ #OPENAI_COMPAT_TOOLS_ENABLED=0
83
+
78
84
  # --- OpenRouter adapter ---
79
85
  #OPENROUTER_API_KEY=
80
86
  #OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
package/.env.example.full CHANGED
@@ -410,6 +410,13 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
410
410
  #OPENAI_BASE_URL=https://api.openai.com/v1
411
411
  # Default model when FORGE_AUDITOR_MODEL is not set.
412
412
  #OPENAI_MODEL=gpt-4o
413
+ # Enable tool use (function calling) for OpenAI-compat and OpenRouter adapters.
414
+ # When enabled, models can autonomously read files, run commands, edit code, etc.
415
+ # via the standard OpenAI function-calling protocol with a server-side tool loop.
416
+ # File/path access is scoped to the workspace CWD (WORKSPACE_CWD) plus any
417
+ # additional directories from --add-dir / group CWD configuration.
418
+ # Default: 0 (disabled — streaming text only).
419
+ #OPENAI_COMPAT_TOOLS_ENABLED=0
413
420
 
414
421
  # --- OpenRouter adapter ---
415
422
  # API key for OpenRouter. Required when PRIMARY_RUNTIME=openrouter or FORGE_*_RUNTIME=openrouter.
package/README.md CHANGED
@@ -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 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:
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`). The OpenAI-compatible and OpenRouter adapters support optional tool use (function calling) when `OPENAI_COMPAT_TOOLS_ENABLED=1` is set. 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
@@ -87,6 +87,10 @@ DiscoClaw supports a shareable markdown recipe format for passing integrations b
87
87
 
88
88
  Author one recipe file for an integration, share it, then let another user's DiscoClaw agent consume it and produce a local implementation checklist before coding.
89
89
 
90
+ ### MCP (Model Context Protocol)
91
+
92
+ When using the Claude runtime, you can connect external tool servers via MCP. Place a `.mcp.json` file in your workspace directory to configure servers — their tools become available during conversations. See [docs/mcp.md](docs/mcp.md) for the config format, examples, and troubleshooting.
93
+
90
94
  ## Prerequisites
91
95
 
92
96
  **End users:**
package/dist/config.js CHANGED
@@ -202,6 +202,7 @@ export function parseConfig(env) {
202
202
  const openaiApiKey = parseTrimmedString(env, 'OPENAI_API_KEY');
203
203
  const openaiBaseUrl = parseTrimmedString(env, 'OPENAI_BASE_URL');
204
204
  const openaiModel = parseTrimmedString(env, 'OPENAI_MODEL') ?? 'gpt-4o';
205
+ const openaiCompatToolsEnabled = parseBoolean(env, 'OPENAI_COMPAT_TOOLS_ENABLED', false);
205
206
  const imagegenGeminiApiKey = parseTrimmedString(env, 'IMAGEGEN_GEMINI_API_KEY');
206
207
  const imagegenDefaultModel = parseTrimmedString(env, 'IMAGEGEN_DEFAULT_MODEL');
207
208
  if (primaryRuntime === 'openai' && !openaiApiKey) {
@@ -330,6 +331,7 @@ export function parseConfig(env) {
330
331
  openaiApiKey,
331
332
  openaiBaseUrl,
332
333
  openaiModel,
334
+ openaiCompatToolsEnabled,
333
335
  imagegenGeminiApiKey,
334
336
  imagegenDefaultModel,
335
337
  forgeDrafterRuntime,
@@ -15,6 +15,10 @@ export class CronSyncCoordinator {
15
15
  setAutoTagModel(model) {
16
16
  this.opts = { ...this.opts, autoTagModel: model };
17
17
  }
18
+ /** Swap the runtime adapter at runtime (called by modelSet runtime-swap propagation). */
19
+ setRuntime(runtime) {
20
+ this.opts = { ...this.opts, runtime };
21
+ }
18
22
  /**
19
23
  * Run sync with concurrency guard.
20
24
  * Returns null when coalesced into a running sync's follow-up.
@@ -115,4 +115,12 @@ describe('CronSyncCoordinator', () => {
115
115
  const callArgs = mockRunCronSync.mock.calls[0][0];
116
116
  expect(callArgs.autoTagModel).toBe('opus');
117
117
  });
118
+ it('setRuntime updates the runtime used by subsequent syncs', async () => {
119
+ const newRuntime = { id: 'openrouter' };
120
+ const coordinator = new CronSyncCoordinator(makeOpts());
121
+ coordinator.setRuntime(newRuntime);
122
+ await coordinator.sync();
123
+ const callArgs = mockRunCronSync.mock.calls[0][0];
124
+ expect(callArgs.runtime).toBe(newRuntime);
125
+ });
118
126
  });
@@ -124,6 +124,10 @@ export async function executeCronJob(job, ctx) {
124
124
  if (preRunRecord) {
125
125
  effectiveModel = preRunRecord.modelOverride ?? preRunRecord.model ?? cronDefault;
126
126
  }
127
+ // Silent mode: instruct the AI to respond with HEARTBEAT_OK when idle.
128
+ if (preRunRecord?.silent) {
129
+ prompt += '\n\nIMPORTANT: If there is nothing actionable to report, respond with exactly `HEARTBEAT_OK` and nothing else.';
130
+ }
127
131
  ctx.log?.info({ jobId: job.id, name: job.name, channel: job.def.channel, model: effectiveModel, permissionTier: tools.permissionTier }, 'cron:exec start');
128
132
  // Best-effort: update pinned status message to show running indicator.
129
133
  if (preRunRecord && job.cronId) {
@@ -269,13 +273,44 @@ export async function executeCronJob(job, ctx) {
269
273
  }
270
274
  processedText = appendUnavailableActionTypesNotice(processedText, strippedUnrecognizedTypes);
271
275
  processedText = appendParseFailureNotice(processedText, parseFailuresCount);
276
+ // Suppress sentinel outputs (e.g. crons whose prompts say "output nothing if idle").
277
+ // Mirrors the reaction handler's logic at reaction-handler.ts:662-674.
278
+ const strippedText = processedText.replace(/\s+/g, ' ').trim();
279
+ const isSuppressible = strippedText === 'HEARTBEAT_OK' || strippedText === '(no output)';
280
+ if (isSuppressible && collectedImages.length === 0) {
281
+ ctx.log?.info({ jobId: job.id, name: job.name, sentinel: strippedText }, 'cron:exec sentinel output suppressed');
282
+ if (ctx.statsStore && job.cronId) {
283
+ try {
284
+ await ctx.statsStore.recordRun(job.cronId, 'success');
285
+ }
286
+ catch {
287
+ // Best-effort.
288
+ }
289
+ }
290
+ metrics.increment('cron.run.success');
291
+ return;
292
+ }
293
+ // Silent-mode short-response gate: suppress paraphrased "nothing to report" responses.
294
+ if (preRunRecord?.silent && collectedImages.length === 0 && strippedText.length <= 80) {
295
+ ctx.log?.info({ jobId: job.id, name: job.name, len: strippedText.length }, 'cron:exec silent short-response suppressed');
296
+ if (ctx.statsStore && job.cronId) {
297
+ try {
298
+ await ctx.statsStore.recordRun(job.cronId, 'success');
299
+ }
300
+ catch {
301
+ // Best-effort.
302
+ }
303
+ }
304
+ metrics.increment('cron.run.success');
305
+ return;
306
+ }
272
307
  await sendChunks(channelForSend, processedText, collectedImages);
273
308
  ctx.log?.info({ jobId: job.id, name: job.name, channel: job.def.channel }, 'cron:exec done');
309
+ metrics.increment('cron.run.success');
274
310
  // Record successful run.
275
311
  if (ctx.statsStore && job.cronId) {
276
312
  try {
277
313
  await ctx.statsStore.recordRun(job.cronId, 'success');
278
- metrics.increment('cron.run.success');
279
314
  }
280
315
  catch (statsErr) {
281
316
  ctx.log?.warn({ err: statsErr, jobId: job.id }, 'cron:exec stats record failed');
@@ -324,6 +324,55 @@ describe('executeCronJob', () => {
324
324
  expect(subsArg).toMatchObject({ imagegenCtx });
325
325
  executeDiscordActionsSpy.mockRestore();
326
326
  });
327
+ it('suppresses HEARTBEAT_OK output', async () => {
328
+ const ctx = makeCtx({ runtime: makeMockRuntime('HEARTBEAT_OK') });
329
+ const job = makeJob();
330
+ await executeCronJob(job, ctx);
331
+ const guild = ctx.client.guilds.cache.get('guild-1');
332
+ const channel = guild.channels.cache.get('general');
333
+ expect(channel.send).not.toHaveBeenCalled();
334
+ expect(ctx.log?.info).toHaveBeenCalledWith(expect.objectContaining({ jobId: job.id, sentinel: 'HEARTBEAT_OK' }), 'cron:exec sentinel output suppressed');
335
+ });
336
+ it('suppresses (no output) output', async () => {
337
+ const ctx = makeCtx({ runtime: makeMockRuntime('(no output)') });
338
+ const job = makeJob();
339
+ await executeCronJob(job, ctx);
340
+ const guild = ctx.client.guilds.cache.get('guild-1');
341
+ const channel = guild.channels.cache.get('general');
342
+ expect(channel.send).not.toHaveBeenCalled();
343
+ });
344
+ it('does not suppress HEARTBEAT_OK when images are present', async () => {
345
+ const runtime = {
346
+ id: 'claude_code',
347
+ capabilities: new Set(['streaming_text']),
348
+ async *invoke() {
349
+ yield { type: 'text_final', text: 'HEARTBEAT_OK' };
350
+ yield { type: 'image_data', image: { mediaType: 'image/png', base64: 'abc123' } };
351
+ yield { type: 'done' };
352
+ },
353
+ };
354
+ const ctx = makeCtx({ runtime });
355
+ const job = makeJob();
356
+ await executeCronJob(job, ctx);
357
+ const guild = ctx.client.guilds.cache.get('guild-1');
358
+ const channel = guild.channels.cache.get('general');
359
+ expect(channel.send).toHaveBeenCalled();
360
+ });
361
+ it('records success in statsStore when sentinel is suppressed', async () => {
362
+ const statsStore = {
363
+ recordRun: vi.fn().mockResolvedValue(undefined),
364
+ recordRunStart: vi.fn().mockResolvedValue(undefined),
365
+ getRecord: vi.fn().mockReturnValue(undefined),
366
+ upsertRecord: vi.fn().mockResolvedValue(undefined),
367
+ };
368
+ const ctx = makeCtx({ runtime: makeMockRuntime('HEARTBEAT_OK'), statsStore });
369
+ const job = makeJob();
370
+ await executeCronJob(job, ctx);
371
+ expect(statsStore.recordRun).toHaveBeenCalledWith('cron-test0001', 'success');
372
+ const guild = ctx.client.guilds.cache.get('guild-1');
373
+ const channel = guild.channels.cache.get('general');
374
+ expect(channel.send).not.toHaveBeenCalled();
375
+ });
327
376
  it('does not post if output is empty', async () => {
328
377
  const ctx = makeCtx({ runtime: makeMockRuntime('') });
329
378
  const job = makeJob();
@@ -730,3 +779,111 @@ describe('executeCronJob write-ahead status tracking', () => {
730
779
  expect(rec?.lastRunStatus).toBe('success');
731
780
  });
732
781
  });
782
+ // ---------------------------------------------------------------------------
783
+ // Silent mode suppression
784
+ // ---------------------------------------------------------------------------
785
+ describe('executeCronJob silent mode', () => {
786
+ let statsDir;
787
+ beforeEach(async () => {
788
+ statsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'executor-silent-'));
789
+ });
790
+ afterEach(async () => {
791
+ await fs.rm(statsDir, { recursive: true, force: true });
792
+ });
793
+ function makeCapturingRuntime(response) {
794
+ const invokeSpy = vi.fn();
795
+ return {
796
+ runtime: {
797
+ id: 'claude_code',
798
+ capabilities: new Set(['streaming_text']),
799
+ async *invoke(params) {
800
+ invokeSpy(params);
801
+ yield { type: 'text_final', text: response };
802
+ yield { type: 'done' };
803
+ },
804
+ },
805
+ invokeSpy,
806
+ };
807
+ }
808
+ it('injects HEARTBEAT_OK instruction into the prompt when silent is true', async () => {
809
+ const statsPath = path.join(statsDir, 'stats.json');
810
+ const statsStore = await loadRunStats(statsPath);
811
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
812
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
813
+ const ctx = makeCtx({ statsStore, runtime });
814
+ const job = makeJob();
815
+ await executeCronJob(job, ctx);
816
+ expect(invokeSpy).toHaveBeenCalledOnce();
817
+ const prompt = invokeSpy.mock.calls[0][0].prompt;
818
+ expect(prompt).toContain('respond with exactly `HEARTBEAT_OK`');
819
+ });
820
+ it('suppresses short responses under the threshold when silent is true', async () => {
821
+ const statsPath = path.join(statsDir, 'stats.json');
822
+ const statsStore = await loadRunStats(statsPath);
823
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
824
+ const ctx = makeCtx({ statsStore, runtime: makeMockRuntime('No task-labeled emails found.') });
825
+ const job = makeJob();
826
+ await executeCronJob(job, ctx);
827
+ const guild = ctx.client.guilds.cache.get('guild-1');
828
+ const channel = guild.channels.cache.get('general');
829
+ expect(channel.send).not.toHaveBeenCalled();
830
+ expect(ctx.log?.info).toHaveBeenCalledWith(expect.objectContaining({ jobId: job.id, name: job.name }), 'cron:exec silent short-response suppressed');
831
+ });
832
+ it('does NOT suppress longer substantive responses when silent is true', async () => {
833
+ const statsPath = path.join(statsDir, 'stats.json');
834
+ const statsStore = await loadRunStats(statsPath);
835
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
836
+ const longResponse = 'Here is a detailed summary of the tasks completed today, including several important updates that need your attention.';
837
+ const ctx = makeCtx({ statsStore, runtime: makeMockRuntime(longResponse) });
838
+ const job = makeJob();
839
+ await executeCronJob(job, ctx);
840
+ const guild = ctx.client.guilds.cache.get('guild-1');
841
+ const channel = guild.channels.cache.get('general');
842
+ expect(channel.send).toHaveBeenCalledOnce();
843
+ expect(channel.send.mock.calls[0][0].content).toContain(longResponse);
844
+ });
845
+ it('does not apply short-response gate when silent is false', async () => {
846
+ const statsPath = path.join(statsDir, 'stats.json');
847
+ const statsStore = await loadRunStats(statsPath);
848
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: false });
849
+ const ctx = makeCtx({ statsStore, runtime: makeMockRuntime('No emails found.') });
850
+ const job = makeJob();
851
+ await executeCronJob(job, ctx);
852
+ const guild = ctx.client.guilds.cache.get('guild-1');
853
+ const channel = guild.channels.cache.get('general');
854
+ expect(channel.send).toHaveBeenCalledOnce();
855
+ });
856
+ it('does NOT suppress short text when images are present (silent mode)', async () => {
857
+ const statsPath = path.join(statsDir, 'stats.json');
858
+ const statsStore = await loadRunStats(statsPath);
859
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
860
+ const runtime = {
861
+ id: 'claude_code',
862
+ capabilities: new Set(['streaming_text']),
863
+ async *invoke() {
864
+ yield { type: 'text_final', text: 'Short.' };
865
+ yield { type: 'image_data', image: { mediaType: 'image/png', base64: 'abc123' } };
866
+ yield { type: 'done' };
867
+ },
868
+ };
869
+ const ctx = makeCtx({ statsStore, runtime });
870
+ const job = makeJob();
871
+ await executeCronJob(job, ctx);
872
+ const guild = ctx.client.guilds.cache.get('guild-1');
873
+ const channel = guild.channels.cache.get('general');
874
+ expect(channel.send).toHaveBeenCalled();
875
+ });
876
+ it('records success in statsStore when silent short-response is suppressed', async () => {
877
+ const statsPath = path.join(statsDir, 'stats.json');
878
+ const statsStore = await loadRunStats(statsPath);
879
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
880
+ const recordRunSpy = vi.spyOn(statsStore, 'recordRun');
881
+ const ctx = makeCtx({ statsStore, runtime: makeMockRuntime('Nothing to report.') });
882
+ const job = makeJob();
883
+ await executeCronJob(job, ctx);
884
+ expect(recordRunSpy).toHaveBeenCalledWith('cron-test0001', 'success');
885
+ const guild = ctx.client.guilds.cache.get('guild-1');
886
+ const channel = guild.channels.cache.get('general');
887
+ expect(channel.send).not.toHaveBeenCalled();
888
+ });
889
+ });
@@ -148,6 +148,12 @@ async function loadThreadAsCron(thread, guildId, scheduler, runtime, opts) {
148
148
  cadence,
149
149
  // Preserve existing disabled state.
150
150
  disabled: existingRecord?.disabled ?? false,
151
+ // Persist parsed definition so future boots skip AI re-parsing.
152
+ schedule: def.schedule,
153
+ timezone: def.timezone,
154
+ channel: def.channel,
155
+ prompt: def.prompt,
156
+ authorId: starterAuthorId,
151
157
  });
152
158
  // Restore disabled state from stats.
153
159
  if (existingRecord?.disabled) {
@@ -207,6 +213,47 @@ export async function initCronForum(opts) {
207
213
  for (const thread of activeThreads.values()) {
208
214
  if (thread.archived)
209
215
  continue;
216
+ // Fast path: if the stats store already has a parsed definition for this
217
+ // thread, reconstruct the ParsedCronDef and register directly — skipping
218
+ // the AI parser entirely. This avoids wasted AI calls on every boot and
219
+ // prevents parse-failure errors on threads whose starters aren't parseable
220
+ // (e.g. bot-formatted messages created by cronCreate).
221
+ if (statsStore) {
222
+ const record = statsStore.getRecordByThreadId(thread.id);
223
+ if (record && record.channel && record.prompt &&
224
+ (record.schedule || (record.triggerType !== undefined && record.triggerType !== 'schedule'))) {
225
+ // Authorization: verify stored author is still permitted.
226
+ const authorId = record.authorId ?? '';
227
+ const botUserId = client.user?.id ?? '';
228
+ const isBotAuthored = botUserId !== '' && authorId === botUserId;
229
+ if (!authorId || (!allowUserIds.has(authorId) && !isBotAuthored)) {
230
+ // Author not authorized — fall through to loadThreadAsCron which
231
+ // will reject and disable through its existing path.
232
+ }
233
+ else {
234
+ const def = {
235
+ triggerType: record.triggerType ?? 'schedule',
236
+ schedule: record.schedule,
237
+ timezone: record.timezone ?? 'UTC',
238
+ channel: record.channel,
239
+ prompt: record.prompt,
240
+ };
241
+ try {
242
+ scheduler.register(thread.id, thread.id, guildId, thread.name, def, record.cronId);
243
+ if (record.disabled) {
244
+ scheduler.disable(thread.id);
245
+ log?.info({ threadId: thread.id, cronId: record.cronId }, 'cron:forum fast-path restored disabled state');
246
+ }
247
+ loaded++;
248
+ log?.info({ threadId: thread.id, cronId: record.cronId }, 'cron:forum fast-path loaded from stored definition');
249
+ continue;
250
+ }
251
+ catch (err) {
252
+ log?.warn({ err, threadId: thread.id, cronId: record.cronId }, 'cron:forum fast-path register failed, falling through to AI parse');
253
+ }
254
+ }
255
+ }
256
+ }
210
257
  const ok = await loadThreadAsCron(thread, guildId, scheduler, runtime, { cronModel, cwd, log, isNew: false, allowUserIds, statsStore });
211
258
  if (ok)
212
259
  loaded++;