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
@@ -147,6 +147,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
147
147
  cadence,
148
148
  purposeTags,
149
149
  model,
150
+ schedule: action.schedule,
151
+ timezone,
152
+ channel: action.channel,
153
+ prompt: action.prompt,
154
+ authorId: cronCtx.client.user?.id,
150
155
  });
151
156
  // Create status message.
152
157
  try {
@@ -170,6 +175,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
170
175
  }
171
176
  const updates = {};
172
177
  const changes = [];
178
+ // Silent mode.
179
+ if (action.silent !== undefined) {
180
+ updates.silent = action.silent;
181
+ changes.push(`silent → ${action.silent}`);
182
+ }
173
183
  // Model override.
174
184
  if (action.model) {
175
185
  updates.modelOverride = action.model;
@@ -231,6 +241,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
231
241
  const msg = err instanceof Error ? err.message : String(err);
232
242
  return { ok: false, error: `Invalid cron definition: ${msg}` };
233
243
  }
244
+ // Persist updated definition fields.
245
+ updates.schedule = newSchedule;
246
+ updates.timezone = newTimezone;
247
+ updates.channel = newChannel;
248
+ updates.prompt = newPrompt;
234
249
  }
235
250
  await cronCtx.statsStore.upsertRecord(action.cronId, record.threadId, updates);
236
251
  // Update status message.
@@ -313,6 +328,8 @@ export async function executeCronAction(action, ctx, cronCtx) {
313
328
  lines.push(`Runtime: \uD83D\uDD04 running`);
314
329
  }
315
330
  lines.push(`Model: ${record.modelOverride ?? record.model ?? 'N/A'}${record.modelOverride ? ' (override)' : ''}`);
331
+ if (record.silent)
332
+ lines.push(`Silent: yes`);
316
333
  lines.push(`Cadence: ${record.cadence ?? 'N/A'}`);
317
334
  lines.push(`Runs: ${record.runCount} | Last: ${record.lastRunStatus ?? 'never'}`);
318
335
  if (record.lastRunAt)
@@ -562,6 +579,7 @@ export function cronActionsPromptSection() {
562
579
  \`\`\`
563
580
  - \`cronId\` (required): The stable cron ID.
564
581
  - \`schedule\`, \`timezone\`, \`channel\`, \`prompt\`, \`model\`, \`tags\` (optional).
582
+ - \`silent\` (optional): Boolean. When true, suppresses short "nothing to report" responses.
565
583
 
566
584
  **cronList** — List all cron jobs:
567
585
  \`\`\`
@@ -325,6 +325,18 @@ describe('executeCronAction', () => {
325
325
  expect(result.ok).toBe(true);
326
326
  expect(cronCtx.statsStore.upsertRecord).toHaveBeenCalledWith('cron-test0001', 'thread-1', expect.objectContaining({ modelOverride: 'opus' }));
327
327
  });
328
+ it('cronUpdate with silent sets silent flag', async () => {
329
+ const cronCtx = makeCronCtx();
330
+ const result = await executeCronAction({ type: 'cronUpdate', cronId: 'cron-test0001', silent: true }, makeActionCtx(), cronCtx);
331
+ expect(result.ok).toBe(true);
332
+ expect(cronCtx.statsStore.upsertRecord).toHaveBeenCalledWith('cron-test0001', 'thread-1', expect.objectContaining({ silent: true }));
333
+ });
334
+ it('cronUpdate with silent false clears silent flag', async () => {
335
+ const cronCtx = makeCronCtx();
336
+ const result = await executeCronAction({ type: 'cronUpdate', cronId: 'cron-test0001', silent: false }, makeActionCtx(), cronCtx);
337
+ expect(result.ok).toBe(true);
338
+ expect(cronCtx.statsStore.upsertRecord).toHaveBeenCalledWith('cron-test0001', 'thread-1', expect.objectContaining({ silent: false }));
339
+ });
328
340
  it('cronUpdate rejects invalid schedule before thread edits or scheduler mutation', async () => {
329
341
  const cronCtx = makeCronCtx();
330
342
  const thread = cronCtx.client.channels.cache.get('thread-1');
@@ -41,8 +41,13 @@ export function handleModelsCommand(cmd, opts) {
41
41
  '',
42
42
  '**Roles:** `chat`, `fast`, `forge-drafter`, `forge-auditor`, `summary`, `cron`, `cron-exec`',
43
43
  '',
44
+ '**Runtime switching (chat role only):**',
45
+ 'Setting the `chat` role to a runtime name (`openrouter`, `openai`, `gemini`, `codex`, `claude`) switches the active runtime adapter so invocations route through that provider.',
46
+ '',
44
47
  '**Examples:**',
45
48
  '- `!models set chat sonnet`',
49
+ '- `!models set chat openrouter` — switch chat to the OpenRouter runtime',
50
+ '- `!models set chat gemini` — switch chat to the Gemini runtime',
46
51
  '- `!models set fast haiku`',
47
52
  '- `!models set forge-drafter opus`',
48
53
  '- `!models set cron-exec haiku` — run crons on a cheaper model',
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ import { initTasksForumGuard } from './tasks/forum-guard.js';
28
28
  import { reloadTagMapInPlace } from './tasks/tag-map.js';
29
29
  import { ensureWorkspaceBootstrapFiles } from './workspace-bootstrap.js';
30
30
  import { probeWorkspacePermissions } from './workspace-permissions.js';
31
+ import { detectMcpServers } from './mcp-detect.js';
31
32
  import { loadRunStats } from './cron/run-stats.js';
32
33
  import { seedTagMap } from './cron/discord-sync.js';
33
34
  import { loadCronTagMapStrict } from './cron/tag-map.js';
@@ -286,6 +287,27 @@ else if (permProbe.status === 'invalid') {
286
287
  else {
287
288
  log.info({ workspaceCwd, tier: permProbe.permissions.tier }, 'workspace permissions loaded');
288
289
  }
290
+ // --- Detect MCP servers (startup health visibility) ---
291
+ const mcpResult = await detectMcpServers(workspaceCwd);
292
+ if (mcpResult.status === 'missing') {
293
+ log.debug({ workspaceCwd }, 'mcp: no .mcp.json found');
294
+ }
295
+ else if (mcpResult.status === 'invalid') {
296
+ log.warn({ workspaceCwd, reason: mcpResult.reason }, 'mcp: .mcp.json is invalid — MCP servers will not load');
297
+ }
298
+ else {
299
+ const serverNames = mcpResult.servers.map((s) => s.name);
300
+ const claudeInUse = primaryRuntimeName === 'claude'
301
+ || cfg.forgeDrafterRuntime === 'claude'
302
+ || cfg.forgeAuditorRuntime === 'claude';
303
+ let msg = serverNames.length === 0
304
+ ? 'mcp: .mcp.json found but no servers configured'
305
+ : `mcp: ${serverNames.length} server${serverNames.length === 1 ? '' : 's'} configured: ${serverNames.join(', ')}`;
306
+ if (serverNames.length > 0 && !claudeInUse) {
307
+ msg += ' (MCP servers only active with Claude runtime)';
308
+ }
309
+ log.info({ servers: serverNames, count: serverNames.length, strictMcpConfig: cfg.strictMcpConfig }, msg);
310
+ }
289
311
  // --- Resolve bot display name ---
290
312
  const botDisplayName = await resolveDisplayName({
291
313
  configName: cfg.botDisplayName,
@@ -449,6 +471,7 @@ if (cfg.openaiApiKey) {
449
471
  baseUrl: cfg.openaiBaseUrl ?? 'https://api.openai.com/v1',
450
472
  apiKey: cfg.openaiApiKey,
451
473
  defaultModel: cfg.openaiModel,
474
+ enableTools: cfg.openaiCompatToolsEnabled,
452
475
  log,
453
476
  });
454
477
  const openaiRuntime = withConcurrencyLimit(openaiRuntimeRaw, {
@@ -464,6 +487,7 @@ if (cfg.openrouterApiKey) {
464
487
  baseUrl: cfg.openrouterBaseUrl ?? 'https://openrouter.ai/api/v1',
465
488
  apiKey: cfg.openrouterApiKey,
466
489
  defaultModel: cfg.openrouterModel,
490
+ enableTools: cfg.openaiCompatToolsEnabled,
467
491
  log,
468
492
  });
469
493
  const openrouterRuntime = withConcurrencyLimit(openrouterRuntimeRaw, {
@@ -619,6 +643,7 @@ const botParams = {
619
643
  memoryCtx: undefined,
620
644
  imagegenCtx: undefined,
621
645
  configCtx: undefined,
646
+ deferOpts: undefined,
622
647
  messageHistoryBudget,
623
648
  summaryEnabled,
624
649
  summaryModel,
@@ -733,6 +758,7 @@ if (discordActionsEnabled && cfg.discordActionsDefer) {
733
758
  };
734
759
  const deferScheduler = configureDeferredScheduler(deferOpts);
735
760
  botParams.deferScheduler = deferScheduler;
761
+ botParams.deferOpts = deferOpts;
736
762
  }
737
763
  let client, status, system;
738
764
  try {
@@ -920,6 +946,8 @@ if (taskCtx) {
920
946
  botParams.configCtx = {
921
947
  botParams,
922
948
  runtime: limitedRuntime,
949
+ runtimeRegistry,
950
+ runtimeName: primaryRuntimeName,
923
951
  };
924
952
  log.info('config:action context initialized');
925
953
  }
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * Detect MCP servers configured in the workspace `.mcp.json` file.
5
+ * Returns structured info for startup health logging.
6
+ */
7
+ export async function detectMcpServers(workspaceCwd) {
8
+ const mcpPath = path.join(workspaceCwd, '.mcp.json');
9
+ let raw;
10
+ try {
11
+ raw = await fs.readFile(mcpPath, 'utf-8');
12
+ }
13
+ catch {
14
+ return { status: 'missing' };
15
+ }
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(raw);
19
+ }
20
+ catch {
21
+ return { status: 'invalid', reason: 'invalid JSON' };
22
+ }
23
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
24
+ return { status: 'invalid', reason: 'root must be an object' };
25
+ }
26
+ const obj = parsed;
27
+ const mcpServers = obj.mcpServers;
28
+ if (mcpServers === undefined) {
29
+ return { status: 'invalid', reason: 'missing "mcpServers" key' };
30
+ }
31
+ if (typeof mcpServers !== 'object' || mcpServers === null || Array.isArray(mcpServers)) {
32
+ return { status: 'invalid', reason: '"mcpServers" must be an object' };
33
+ }
34
+ const entries = Object.entries(mcpServers);
35
+ const servers = [];
36
+ for (const [name, value] of entries) {
37
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
38
+ return { status: 'invalid', reason: `server "${name}" must be an object` };
39
+ }
40
+ const entry = value;
41
+ if (typeof entry.command !== 'string' || !entry.command) {
42
+ return { status: 'invalid', reason: `server "${name}" missing "command"` };
43
+ }
44
+ const server = { name, command: entry.command };
45
+ if (Array.isArray(entry.args)) {
46
+ server.args = entry.args.filter((a) => typeof a === 'string');
47
+ }
48
+ servers.push(server);
49
+ }
50
+ return { status: 'found', servers };
51
+ }
52
+ /**
53
+ * Log MCP detection results at startup.
54
+ */
55
+ export function logMcpDetection(result, log) {
56
+ switch (result.status) {
57
+ case 'missing':
58
+ log.info({}, 'mcp: no .mcp.json found — MCP servers not configured');
59
+ break;
60
+ case 'invalid':
61
+ log.warn({ reason: result.reason }, 'mcp: .mcp.json is invalid — MCP servers will not load');
62
+ break;
63
+ case 'found': {
64
+ const names = result.servers.map((s) => s.name);
65
+ if (names.length === 0) {
66
+ log.info({}, 'mcp: .mcp.json found but no servers configured');
67
+ }
68
+ else {
69
+ log.info({ count: names.length, servers: names }, `mcp: ${names.length} server${names.length === 1 ? '' : 's'} configured: ${names.join(', ')}`);
70
+ }
71
+ break;
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,160 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { detectMcpServers, logMcpDetection } from './mcp-detect.js';
6
+ function mockLog() {
7
+ return { info: vi.fn(), warn: vi.fn() };
8
+ }
9
+ describe('detectMcpServers', () => {
10
+ const dirs = [];
11
+ afterEach(async () => {
12
+ for (const d of dirs)
13
+ await fs.rm(d, { recursive: true, force: true });
14
+ dirs.length = 0;
15
+ });
16
+ it('returns missing when .mcp.json does not exist', async () => {
17
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
18
+ dirs.push(workspace);
19
+ const result = await detectMcpServers(workspace);
20
+ expect(result).toEqual({ status: 'missing' });
21
+ });
22
+ it('returns invalid for malformed JSON', async () => {
23
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
24
+ dirs.push(workspace);
25
+ await fs.writeFile(path.join(workspace, '.mcp.json'), '{ broken', 'utf-8');
26
+ const result = await detectMcpServers(workspace);
27
+ expect(result).toEqual({ status: 'invalid', reason: 'invalid JSON' });
28
+ });
29
+ it('returns invalid when root is not an object', async () => {
30
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
31
+ dirs.push(workspace);
32
+ await fs.writeFile(path.join(workspace, '.mcp.json'), '"hello"', 'utf-8');
33
+ const result = await detectMcpServers(workspace);
34
+ expect(result).toEqual({ status: 'invalid', reason: 'root must be an object' });
35
+ });
36
+ it('returns invalid when root is an array', async () => {
37
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
38
+ dirs.push(workspace);
39
+ await fs.writeFile(path.join(workspace, '.mcp.json'), '[]', 'utf-8');
40
+ const result = await detectMcpServers(workspace);
41
+ expect(result).toEqual({ status: 'invalid', reason: 'root must be an object' });
42
+ });
43
+ it('returns invalid when mcpServers key is missing', async () => {
44
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
45
+ dirs.push(workspace);
46
+ await fs.writeFile(path.join(workspace, '.mcp.json'), '{}', 'utf-8');
47
+ const result = await detectMcpServers(workspace);
48
+ expect(result).toEqual({ status: 'invalid', reason: 'missing "mcpServers" key' });
49
+ });
50
+ it('returns invalid when mcpServers is not an object', async () => {
51
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
52
+ dirs.push(workspace);
53
+ await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify({ mcpServers: 42 }), 'utf-8');
54
+ const result = await detectMcpServers(workspace);
55
+ expect(result).toEqual({ status: 'invalid', reason: '"mcpServers" must be an object' });
56
+ });
57
+ it('returns found with empty servers when mcpServers is empty', async () => {
58
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
59
+ dirs.push(workspace);
60
+ await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify({ mcpServers: {} }), 'utf-8');
61
+ const result = await detectMcpServers(workspace);
62
+ expect(result).toEqual({ status: 'found', servers: [] });
63
+ });
64
+ it('returns found with server details for valid config', async () => {
65
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
66
+ dirs.push(workspace);
67
+ const config = {
68
+ mcpServers: {
69
+ filesystem: {
70
+ command: 'npx',
71
+ args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/user/docs'],
72
+ },
73
+ 'brave-search': {
74
+ command: 'npx',
75
+ args: ['-y', '@anthropic/mcp-server-brave-search'],
76
+ env: { BRAVE_API_KEY: 'key-here' },
77
+ },
78
+ },
79
+ };
80
+ await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify(config), 'utf-8');
81
+ const result = await detectMcpServers(workspace);
82
+ expect(result).toEqual({
83
+ status: 'found',
84
+ servers: [
85
+ { name: 'filesystem', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/user/docs'] },
86
+ { name: 'brave-search', command: 'npx', args: ['-y', '@anthropic/mcp-server-brave-search'] },
87
+ ],
88
+ });
89
+ });
90
+ it('returns invalid when a server entry is missing command', async () => {
91
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
92
+ dirs.push(workspace);
93
+ const config = {
94
+ mcpServers: {
95
+ broken: { args: ['--flag'] },
96
+ },
97
+ };
98
+ await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify(config), 'utf-8');
99
+ const result = await detectMcpServers(workspace);
100
+ expect(result).toEqual({ status: 'invalid', reason: 'server "broken" missing "command"' });
101
+ });
102
+ it('returns invalid when a server entry is not an object', async () => {
103
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
104
+ dirs.push(workspace);
105
+ const config = { mcpServers: { bad: 'string' } };
106
+ await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify(config), 'utf-8');
107
+ const result = await detectMcpServers(workspace);
108
+ expect(result).toEqual({ status: 'invalid', reason: 'server "bad" must be an object' });
109
+ });
110
+ it('handles server with command only (no args)', async () => {
111
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
112
+ dirs.push(workspace);
113
+ const config = {
114
+ mcpServers: {
115
+ simple: { command: '/usr/local/bin/mcp-server' },
116
+ },
117
+ };
118
+ await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify(config), 'utf-8');
119
+ const result = await detectMcpServers(workspace);
120
+ expect(result).toEqual({
121
+ status: 'found',
122
+ servers: [{ name: 'simple', command: '/usr/local/bin/mcp-server' }],
123
+ });
124
+ });
125
+ });
126
+ describe('logMcpDetection', () => {
127
+ it('logs info for missing .mcp.json', () => {
128
+ const log = mockLog();
129
+ logMcpDetection({ status: 'missing' }, log);
130
+ expect(log.info).toHaveBeenCalledWith({}, expect.stringContaining('no .mcp.json found'));
131
+ expect(log.warn).not.toHaveBeenCalled();
132
+ });
133
+ it('logs warn for invalid .mcp.json', () => {
134
+ const log = mockLog();
135
+ logMcpDetection({ status: 'invalid', reason: 'invalid JSON' }, log);
136
+ expect(log.warn).toHaveBeenCalledWith({ reason: 'invalid JSON' }, expect.stringContaining('invalid'));
137
+ expect(log.info).not.toHaveBeenCalled();
138
+ });
139
+ it('logs info for empty servers', () => {
140
+ const log = mockLog();
141
+ logMcpDetection({ status: 'found', servers: [] }, log);
142
+ expect(log.info).toHaveBeenCalledWith({}, expect.stringContaining('no servers configured'));
143
+ });
144
+ it('logs info with server names for configured servers', () => {
145
+ const log = mockLog();
146
+ logMcpDetection({
147
+ status: 'found',
148
+ servers: [
149
+ { name: 'filesystem', command: 'npx' },
150
+ { name: 'brave-search', command: 'npx' },
151
+ ],
152
+ }, log);
153
+ expect(log.info).toHaveBeenCalledWith({ count: 2, servers: ['filesystem', 'brave-search'] }, expect.stringContaining('2 servers configured: filesystem, brave-search'));
154
+ });
155
+ it('uses singular "server" for single server', () => {
156
+ const log = mockLog();
157
+ logMcpDetection({ status: 'found', servers: [{ name: 'filesystem', command: 'npx' }] }, log);
158
+ expect(log.info).toHaveBeenCalledWith({ count: 1, servers: ['filesystem'] }, expect.stringContaining('1 server configured'));
159
+ });
160
+ });