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.
- package/.context/pa.md +1 -1
- package/.context/runtime.md +48 -4
- package/.env.example +6 -0
- package/.env.example.full +7 -0
- package/README.md +5 -1
- package/dist/config.js +2 -0
- package/dist/cron/cron-sync-coordinator.js +4 -0
- package/dist/cron/cron-sync-coordinator.test.js +8 -0
- package/dist/cron/executor.js +36 -1
- package/dist/cron/executor.test.js +157 -0
- package/dist/cron/forum-sync.js +47 -0
- package/dist/cron/forum-sync.test.js +234 -0
- package/dist/cron/run-stats.js +10 -3
- package/dist/cron/run-stats.test.js +67 -3
- package/dist/discord/actions-config.js +41 -8
- package/dist/discord/actions-config.test.js +130 -8
- package/dist/discord/actions-crons.js +18 -0
- package/dist/discord/actions-crons.test.js +12 -0
- package/dist/discord/models-command.js +5 -0
- package/dist/index.js +28 -0
- package/dist/mcp-detect.js +74 -0
- package/dist/mcp-detect.test.js +160 -0
- package/dist/runtime/openai-compat.js +224 -90
- package/dist/runtime/openai-compat.test.js +409 -2
- package/dist/runtime/openai-tool-exec.js +433 -0
- package/dist/runtime/openai-tool-exec.test.js +267 -0
- package/dist/runtime/openai-tool-schemas.js +174 -0
- package/dist/runtime/openai-tool-schemas.test.js +74 -0
- package/dist/runtime/tools/fs-glob.js +102 -0
- package/dist/runtime/tools/fs-glob.test.js +67 -0
- package/dist/runtime/tools/fs-read-file.js +49 -0
- package/dist/runtime/tools/fs-read-file.test.js +51 -0
- package/dist/runtime/tools/fs-realpath.js +51 -0
- package/dist/runtime/tools/fs-realpath.test.js +72 -0
- package/dist/runtime/tools/fs-write-file.js +45 -0
- package/dist/runtime/tools/fs-write-file.test.js +56 -0
- package/dist/runtime/tools/image-download.js +138 -0
- package/dist/runtime/tools/image-download.test.js +106 -0
- package/dist/runtime/tools/path-security.js +72 -0
- package/dist/runtime/tools/types.js +4 -0
- package/dist/workspace-bootstrap.js +0 -1
- package/dist/workspace-bootstrap.test.js +0 -2
- package/package.json +1 -1
- package/templates/mcp.json +8 -0
- package/templates/workspace/TOOLS.md +70 -1
- 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
|
|
package/.context/runtime.md
CHANGED
|
@@ -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)
|
|
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)
|
|
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
|
});
|
package/dist/cron/executor.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/cron/forum-sync.js
CHANGED
|
@@ -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++;
|