discoclaw 1.0.0 → 1.1.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/dev.md +4 -4
- package/.env.example.full +8 -7
- package/dist/config.js +8 -6
- package/dist/config.test.js +20 -4
- package/dist/cron/cron-sync.test.js +113 -0
- package/dist/cron/forum-sync.js +21 -17
- package/dist/cron/forum-sync.test.js +180 -7
- package/dist/cron/parser.js +63 -0
- package/dist/cron/parser.test.js +97 -1
- package/dist/cron/run-stats.js +14 -2
- package/dist/cron/run-stats.test.js +8 -8
- package/dist/dashboard/api/settings.js +48 -0
- package/dist/dashboard/api/settings.test.js +116 -0
- package/dist/dashboard/page.js +185 -1
- package/dist/dashboard/server.js +27 -1
- package/dist/dashboard/settings-keys.js +129 -0
- package/dist/discord/actions-crons.js +37 -11
- package/dist/discord/actions-forge.js +5 -0
- package/dist/discord/actions-plan.js +5 -0
- package/dist/discord/forge-commands.js +5 -0
- package/dist/discord/message-coordinator.js +28 -6
- package/dist/discord/message-history.js +33 -2
- package/dist/discord/message-history.test.js +107 -2
- package/dist/discord/plan-commands.js +5 -0
- package/dist/discord/plan-forge-availability.js +2 -2
- package/dist/discord/plan-forge-availability.test.js +2 -0
- package/dist/index.js +4 -0
- package/docs/configuration.md +10 -0
- package/package.json +1 -1
package/.context/dev.md
CHANGED
|
@@ -171,18 +171,18 @@ Two setup paths:
|
|
|
171
171
|
### Plan & Forge
|
|
172
172
|
| Variable | Default | Description |
|
|
173
173
|
|----------|---------|-------------|
|
|
174
|
-
| `DISCOCLAW_PLAN_COMMANDS_ENABLED` | `
|
|
175
|
-
| `PLAN_PHASES_ENABLED` | `
|
|
174
|
+
| `DISCOCLAW_PLAN_COMMANDS_ENABLED` | `0` | Enable plan commands (`!plan`, `!plan phase`, etc.) |
|
|
175
|
+
| `PLAN_PHASES_ENABLED` | `0` | Enable phase-by-phase plan execution |
|
|
176
176
|
| `PLAN_PHASE_MAX_CONTEXT_FILES` | `5` | Max `.context/` files injected per plan phase |
|
|
177
177
|
| `PLAN_PHASE_TIMEOUT_MS` | `1800000` | Per-phase timeout in milliseconds |
|
|
178
178
|
| `PLAN_PHASE_AUDIT_FIX_MAX` | `3` | Max audit-fix attempts per phase before giving up |
|
|
179
|
-
| `DISCOCLAW_FORGE_COMMANDS_ENABLED` | `
|
|
179
|
+
| `DISCOCLAW_FORGE_COMMANDS_ENABLED` | `0` | Enable forge commands (`!forge`) |
|
|
180
180
|
| `FORGE_MAX_AUDIT_ROUNDS` | `5` | Max audit rounds before forge accepts the draft |
|
|
181
181
|
| `FORGE_DRAFTER_MODEL` | *(empty — uses `RUNTIME_MODEL`)* | Override model for the forge drafter step |
|
|
182
182
|
| `FORGE_AUDITOR_MODEL` | *(empty — uses `RUNTIME_MODEL`)* | Override model for the forge auditor step |
|
|
183
183
|
| `FORGE_TIMEOUT_MS` | `1800000` | Per-forge-session timeout in milliseconds |
|
|
184
184
|
| `FORGE_PROGRESS_THROTTLE_MS` | `3000` | Min ms between forge progress Discord updates |
|
|
185
|
-
| `FORGE_AUTO_IMPLEMENT` | `
|
|
185
|
+
| `FORGE_AUTO_IMPLEMENT` | `0` | Automatically implement the approved forge plan without a separate confirm step |
|
|
186
186
|
| `FORGE_DRAFTER_RUNTIME` | *(empty — uses `PRIMARY_RUNTIME`)* | Runtime adapter for the forge drafter (e.g. `openai`, `claude`) |
|
|
187
187
|
| `FORGE_AUDITOR_RUNTIME` | *(empty — uses `PRIMARY_RUNTIME`)* | Runtime adapter for the forge auditor |
|
|
188
188
|
|
package/.env.example.full
CHANGED
|
@@ -217,11 +217,11 @@ DISCORD_GUILD_ID=
|
|
|
217
217
|
#DISCOCLAW_DISCORD_ACTIONS_POLLS=1
|
|
218
218
|
#DISCOCLAW_DISCORD_ACTIONS_TASKS=1
|
|
219
219
|
# Allow the AI to self-initiate forge runs (draft + audit loops) via action blocks.
|
|
220
|
-
# Requires DISCOCLAW_FORGE_COMMANDS_ENABLED=1. Only one forge at a time. Default:
|
|
221
|
-
#DISCOCLAW_DISCORD_ACTIONS_FORGE=
|
|
220
|
+
# Requires DISCOCLAW_FORGE_COMMANDS_ENABLED=1. Only one forge at a time. Default: 0.
|
|
221
|
+
#DISCOCLAW_DISCORD_ACTIONS_FORGE=0
|
|
222
222
|
# Allow the AI to create, inspect, approve, run, and close plans via action blocks.
|
|
223
|
-
# Requires DISCOCLAW_PLAN_COMMANDS_ENABLED=1. Default:
|
|
224
|
-
#DISCOCLAW_DISCORD_ACTIONS_PLAN=
|
|
223
|
+
# Requires DISCOCLAW_PLAN_COMMANDS_ENABLED=1. Default: 0.
|
|
224
|
+
#DISCOCLAW_DISCORD_ACTIONS_PLAN=0
|
|
225
225
|
# Allow the AI to read/write durable memory (facts, preferences) via action blocks.
|
|
226
226
|
# Requires DISCOCLAW_DURABLE_MEMORY_ENABLED=1. Default: 1.
|
|
227
227
|
#DISCOCLAW_DISCORD_ACTIONS_MEMORY=1
|
|
@@ -541,9 +541,10 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
|
|
|
541
541
|
# ----------------------------------------------------------
|
|
542
542
|
# Plan & Forge — AI-assisted planning and implementation
|
|
543
543
|
# ----------------------------------------------------------
|
|
544
|
-
# Enable/disable !plan and !forge commands.
|
|
545
|
-
#
|
|
546
|
-
#
|
|
544
|
+
# Enable/disable !plan and !forge commands. Default: 0 (off).
|
|
545
|
+
# When off, !plan and !forge respond with a friendly nudge explaining how to enable.
|
|
546
|
+
#DISCOCLAW_PLAN_COMMANDS_ENABLED=0
|
|
547
|
+
#DISCOCLAW_FORGE_COMMANDS_ENABLED=0
|
|
547
548
|
# Phase decomposition for approved plans.
|
|
548
549
|
#PLAN_PHASES_ENABLED=1
|
|
549
550
|
# Max files per phase batch during decomposition.
|
package/dist/config.js
CHANGED
|
@@ -384,8 +384,8 @@ export function parseConfig(env) {
|
|
|
384
384
|
const discordActionsTasks = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_TASKS', true);
|
|
385
385
|
const discordActionsCrons = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_CRONS', true);
|
|
386
386
|
const discordActionsBotProfile = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_BOT_PROFILE', true);
|
|
387
|
-
const discordActionsForge = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_FORGE',
|
|
388
|
-
const discordActionsPlan = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_PLAN',
|
|
387
|
+
const discordActionsForge = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_FORGE', false);
|
|
388
|
+
const discordActionsPlan = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_PLAN', false);
|
|
389
389
|
const discordActionsMemory = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_MEMORY', true);
|
|
390
390
|
const discordActionsDefer = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_DEFER', true);
|
|
391
391
|
const discordActionsLoop = parseBoolean(env, 'DISCOCLAW_DISCORD_ACTIONS_LOOP', true);
|
|
@@ -694,6 +694,8 @@ export function parseConfig(env) {
|
|
|
694
694
|
loopMaxIntervalSeconds,
|
|
695
695
|
loopMaxConcurrent,
|
|
696
696
|
messageHistoryBudget: parseNonNegativeInt(env, 'DISCOCLAW_MESSAGE_HISTORY_BUDGET', 3000),
|
|
697
|
+
messageHistoryFetchLimit: parsePositiveInt(env, 'DISCOCLAW_MESSAGE_HISTORY_FETCH_LIMIT', 10),
|
|
698
|
+
messageHistoryMaxAgeMs: parsePositiveInt(env, 'DISCOCLAW_MESSAGE_HISTORY_MAX_AGE_HOURS', 48) * 3_600_000,
|
|
697
699
|
summaryEnabled: parseBoolean(env, 'DISCOCLAW_SUMMARY_ENABLED', true),
|
|
698
700
|
summaryModel: parseTrimmedString(env, 'DISCOCLAW_SUMMARY_MODEL') ?? fastModel,
|
|
699
701
|
summaryMaxChars: parseNonNegativeInt(env, 'DISCOCLAW_SUMMARY_MAX_CHARS', 2000),
|
|
@@ -710,19 +712,19 @@ export function parseConfig(env) {
|
|
|
710
712
|
memoryConsolidationThreshold: parsePositiveInt(env, 'DISCOCLAW_MEMORY_CONSOLIDATION_THRESHOLD', 50),
|
|
711
713
|
memoryConsolidationModel: parseTrimmedString(env, 'DISCOCLAW_MEMORY_CONSOLIDATION_MODEL') ?? fastModel,
|
|
712
714
|
memoryCommandsEnabled: parseBoolean(env, 'DISCOCLAW_MEMORY_COMMANDS_ENABLED', true),
|
|
713
|
-
planCommandsEnabled: parseBoolean(env, 'DISCOCLAW_PLAN_COMMANDS_ENABLED',
|
|
714
|
-
planPhasesEnabled: parseBoolean(env, 'PLAN_PHASES_ENABLED',
|
|
715
|
+
planCommandsEnabled: parseBoolean(env, 'DISCOCLAW_PLAN_COMMANDS_ENABLED', false),
|
|
716
|
+
planPhasesEnabled: parseBoolean(env, 'PLAN_PHASES_ENABLED', parseBoolean(env, 'DISCOCLAW_PLAN_COMMANDS_ENABLED', false)),
|
|
715
717
|
planPhaseMaxContextFiles: parsePositiveInt(env, 'PLAN_PHASE_MAX_CONTEXT_FILES', 5),
|
|
716
718
|
planPhaseTimeoutMs: parsePositiveNumber(env, 'PLAN_PHASE_TIMEOUT_MS', DEFAULT_THIRTY_MINUTES_MS),
|
|
717
719
|
planPhaseMaxAuditFixAttempts: parseNonNegativeInt(env, 'PLAN_PHASE_AUDIT_FIX_MAX', 3),
|
|
718
720
|
planForgeHeartbeatIntervalMs: parseNonNegativeInt(env, 'PLAN_FORGE_HEARTBEAT_INTERVAL_MS', DEFAULT_PLAN_FORGE_HEARTBEAT_INTERVAL_MS),
|
|
719
|
-
forgeCommandsEnabled: parseBoolean(env, 'DISCOCLAW_FORGE_COMMANDS_ENABLED',
|
|
721
|
+
forgeCommandsEnabled: parseBoolean(env, 'DISCOCLAW_FORGE_COMMANDS_ENABLED', false),
|
|
720
722
|
forgeMaxAuditRounds: parsePositiveInt(env, 'FORGE_MAX_AUDIT_ROUNDS', 5),
|
|
721
723
|
forgeDrafterModel: parseTrimmedString(env, 'FORGE_DRAFTER_MODEL'),
|
|
722
724
|
forgeAuditorModel: parseTrimmedString(env, 'FORGE_AUDITOR_MODEL'),
|
|
723
725
|
forgeTimeoutMs: parsePositiveNumber(env, 'FORGE_TIMEOUT_MS', DEFAULT_THIRTY_MINUTES_MS),
|
|
724
726
|
forgeProgressThrottleMs: parseNonNegativeInt(env, 'FORGE_PROGRESS_THROTTLE_MS', 3000),
|
|
725
|
-
forgeAutoImplement: parseBoolean(env, 'FORGE_AUTO_IMPLEMENT',
|
|
727
|
+
forgeAutoImplement: parseBoolean(env, 'FORGE_AUTO_IMPLEMENT', parseBoolean(env, 'DISCOCLAW_FORGE_COMMANDS_ENABLED', false)),
|
|
726
728
|
completionNotifyEnabled: parseBoolean(env, 'DISCOCLAW_COMPLETION_NOTIFY', true),
|
|
727
729
|
completionNotifyThresholdMs: parseNonNegativeInt(env, 'DISCOCLAW_COMPLETION_NOTIFY_THRESHOLD_MS', 30000),
|
|
728
730
|
actionFollowupTimeoutMs: parseNonNegativeInt(env, 'DISCOCLAW_ACTION_FOLLOWUP_TIMEOUT_MS', 30000),
|
package/dist/config.test.js
CHANGED
|
@@ -275,9 +275,9 @@ describe('parseConfig', () => {
|
|
|
275
275
|
const { config } = parseConfig(env());
|
|
276
276
|
expect(config.discordActionsBotProfile).toBe(true);
|
|
277
277
|
});
|
|
278
|
-
it('defaults discordActionsPlan to
|
|
278
|
+
it('defaults discordActionsPlan to false', () => {
|
|
279
279
|
const { config } = parseConfig(env());
|
|
280
|
-
expect(config.discordActionsPlan).toBe(
|
|
280
|
+
expect(config.discordActionsPlan).toBe(false);
|
|
281
281
|
});
|
|
282
282
|
it('defaults discordActionsEnabled to true', () => {
|
|
283
283
|
const { config } = parseConfig(env());
|
|
@@ -503,9 +503,9 @@ describe('parseConfig', () => {
|
|
|
503
503
|
expect(warnings.some((w) => w.includes('FORGE_AUDITOR_RUNTIME=openrouter'))).toBe(true);
|
|
504
504
|
});
|
|
505
505
|
// --- Forge auto-implement ---
|
|
506
|
-
it('defaults forgeAutoImplement to
|
|
506
|
+
it('defaults forgeAutoImplement to false', () => {
|
|
507
507
|
const { config } = parseConfig(env());
|
|
508
|
-
expect(config.forgeAutoImplement).toBe(
|
|
508
|
+
expect(config.forgeAutoImplement).toBe(false);
|
|
509
509
|
});
|
|
510
510
|
it('parses FORGE_AUTO_IMPLEMENT=0 as false', () => {
|
|
511
511
|
const { config } = parseConfig(env({ FORGE_AUTO_IMPLEMENT: '0' }));
|
|
@@ -1192,4 +1192,20 @@ describe('parseConfig', () => {
|
|
|
1192
1192
|
const { config } = parseConfig(env());
|
|
1193
1193
|
expect(config.coldStorageChannelFilter).toEqual([]);
|
|
1194
1194
|
});
|
|
1195
|
+
it('defaults messageHistoryFetchLimit to 10', () => {
|
|
1196
|
+
const { config } = parseConfig(env());
|
|
1197
|
+
expect(config.messageHistoryFetchLimit).toBe(10);
|
|
1198
|
+
});
|
|
1199
|
+
it('parses DISCOCLAW_MESSAGE_HISTORY_FETCH_LIMIT when set', () => {
|
|
1200
|
+
const { config } = parseConfig(env({ DISCOCLAW_MESSAGE_HISTORY_FETCH_LIMIT: '25' }));
|
|
1201
|
+
expect(config.messageHistoryFetchLimit).toBe(25);
|
|
1202
|
+
});
|
|
1203
|
+
it('defaults messageHistoryMaxAgeMs to 48 hours in milliseconds', () => {
|
|
1204
|
+
const { config } = parseConfig(env());
|
|
1205
|
+
expect(config.messageHistoryMaxAgeMs).toBe(48 * 3_600_000);
|
|
1206
|
+
});
|
|
1207
|
+
it('parses DISCOCLAW_MESSAGE_HISTORY_MAX_AGE_HOURS and converts to ms', () => {
|
|
1208
|
+
const { config } = parseConfig(env({ DISCOCLAW_MESSAGE_HISTORY_MAX_AGE_HOURS: '24' }));
|
|
1209
|
+
expect(config.messageHistoryMaxAgeMs).toBe(24 * 3_600_000);
|
|
1210
|
+
});
|
|
1195
1211
|
});
|
|
@@ -452,4 +452,117 @@ describe('runCronSync', () => {
|
|
|
452
452
|
expect(result.orphansDetected).toBe(1);
|
|
453
453
|
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ threadId: 'thread-orphan' }), expect.stringContaining('orphan'));
|
|
454
454
|
});
|
|
455
|
+
it('sync preserves disabled flag without modifying it', async () => {
|
|
456
|
+
const forum = makeForum([{ id: 'thread-1', name: 'Paused Job', parentId: 'forum-1' }]);
|
|
457
|
+
const client = makeClient(forum);
|
|
458
|
+
const statsStore = makeStatsStore([
|
|
459
|
+
makeRecord({
|
|
460
|
+
cronId: 'cron-1',
|
|
461
|
+
threadId: 'thread-1',
|
|
462
|
+
disabled: true,
|
|
463
|
+
pauseSource: 'user',
|
|
464
|
+
cadence: 'daily',
|
|
465
|
+
purposeTags: ['monitoring'],
|
|
466
|
+
model: 'haiku',
|
|
467
|
+
}),
|
|
468
|
+
]);
|
|
469
|
+
const scheduler = makeScheduler([
|
|
470
|
+
{ id: 'thread-1', threadId: 'thread-1', cronId: 'cron-1', name: 'Paused Job', schedule: '0 7 * * *', prompt: 'Check things' },
|
|
471
|
+
]);
|
|
472
|
+
await runCronSync({
|
|
473
|
+
client: client,
|
|
474
|
+
forumId: 'forum-1',
|
|
475
|
+
scheduler,
|
|
476
|
+
statsStore,
|
|
477
|
+
runtime: makeMockRuntime('monitoring'),
|
|
478
|
+
tagMap: { ...defaultTagMap },
|
|
479
|
+
autoTag: false,
|
|
480
|
+
autoTagModel: 'haiku',
|
|
481
|
+
cwd: '/tmp',
|
|
482
|
+
log: mockLog(),
|
|
483
|
+
throttleMs: 0,
|
|
484
|
+
});
|
|
485
|
+
// Verify disabled and pauseSource were not altered by sync
|
|
486
|
+
const record = statsStore.getRecord('cron-1');
|
|
487
|
+
expect(record?.disabled).toBe(true);
|
|
488
|
+
expect(record?.pauseSource).toBe('user');
|
|
489
|
+
});
|
|
490
|
+
it('phase 5: recreating disabled cron calls scheduler.disable on new thread', async () => {
|
|
491
|
+
const create = vi.fn(async ({ name, message }) => ({
|
|
492
|
+
id: 'thread-new',
|
|
493
|
+
name,
|
|
494
|
+
message,
|
|
495
|
+
}));
|
|
496
|
+
const forum = makeForum([], { create });
|
|
497
|
+
const client = makeClient(forum);
|
|
498
|
+
const statsStore = makeStatsStore([
|
|
499
|
+
makeRecord({
|
|
500
|
+
cronId: 'cron-disabled',
|
|
501
|
+
threadId: 'thread-old',
|
|
502
|
+
disabled: true,
|
|
503
|
+
pauseSource: 'user',
|
|
504
|
+
cadence: 'daily',
|
|
505
|
+
schedule: '0 7 * * *',
|
|
506
|
+
timezone: 'UTC',
|
|
507
|
+
channel: 'general',
|
|
508
|
+
prompt: 'Disabled job that needs thread recreation.',
|
|
509
|
+
projectionStatus: 'missing',
|
|
510
|
+
projectionHash: 'stale-hash',
|
|
511
|
+
}),
|
|
512
|
+
]);
|
|
513
|
+
const scheduler = makeScheduler([]);
|
|
514
|
+
const result = await runCronSync({
|
|
515
|
+
client: client,
|
|
516
|
+
forumId: 'forum-1',
|
|
517
|
+
scheduler,
|
|
518
|
+
statsStore,
|
|
519
|
+
runtime: makeMockRuntime('monitoring'),
|
|
520
|
+
tagMap: { ...defaultTagMap },
|
|
521
|
+
autoTag: false,
|
|
522
|
+
autoTagModel: 'haiku',
|
|
523
|
+
cwd: '/tmp',
|
|
524
|
+
log: mockLog(),
|
|
525
|
+
throttleMs: 0,
|
|
526
|
+
});
|
|
527
|
+
expect(result.projectionsRepaired).toBe(1);
|
|
528
|
+
// Scheduler should have been called: register then disable
|
|
529
|
+
expect(scheduler.register).toHaveBeenCalled();
|
|
530
|
+
expect(scheduler.disable).toHaveBeenCalledWith('thread-new');
|
|
531
|
+
// Record should still be disabled after recreation
|
|
532
|
+
const record = statsStore.getRecord('cron-disabled');
|
|
533
|
+
expect(record?.disabled).toBe(true);
|
|
534
|
+
expect(record?.threadId).toBe('thread-new');
|
|
535
|
+
});
|
|
536
|
+
it('phase 3: updates status messages for disabled crons', async () => {
|
|
537
|
+
const forum = makeForum([{ id: 'thread-1', name: 'Paused Job', parentId: 'forum-1' }]);
|
|
538
|
+
const client = makeClient(forum);
|
|
539
|
+
const statsStore = makeStatsStore([
|
|
540
|
+
makeRecord({
|
|
541
|
+
cronId: 'cron-1',
|
|
542
|
+
threadId: 'thread-1',
|
|
543
|
+
disabled: true,
|
|
544
|
+
pauseSource: 'user',
|
|
545
|
+
cadence: 'daily',
|
|
546
|
+
model: 'haiku',
|
|
547
|
+
}),
|
|
548
|
+
]);
|
|
549
|
+
const scheduler = makeScheduler([
|
|
550
|
+
{ id: 'thread-1', threadId: 'thread-1', cronId: 'cron-1', name: 'Paused Job', schedule: '0 7 * * *', prompt: 'Check things' },
|
|
551
|
+
]);
|
|
552
|
+
const result = await runCronSync({
|
|
553
|
+
client: client,
|
|
554
|
+
forumId: 'forum-1',
|
|
555
|
+
scheduler,
|
|
556
|
+
statsStore,
|
|
557
|
+
runtime: makeMockRuntime('monitoring'),
|
|
558
|
+
tagMap: { ...defaultTagMap },
|
|
559
|
+
autoTag: false,
|
|
560
|
+
autoTagModel: 'haiku',
|
|
561
|
+
cwd: '/tmp',
|
|
562
|
+
log: mockLog(),
|
|
563
|
+
throttleMs: 0,
|
|
564
|
+
});
|
|
565
|
+
// Disabled crons should still get status message updates
|
|
566
|
+
expect(result.statusMessagesUpdated).toBe(1);
|
|
567
|
+
});
|
|
455
568
|
});
|
package/dist/cron/forum-sync.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChannelType, EmbedBuilder } from 'discord.js';
|
|
2
|
-
import { parseCronDefinition } from './parser.js';
|
|
2
|
+
import { parseCronDefinition, parseStarterContent } from './parser.js';
|
|
3
3
|
import { generateCronId, parseCronIdFromContent } from './run-stats.js';
|
|
4
4
|
import { detectCadence } from './cadence.js';
|
|
5
5
|
import { ensureStatusMessage } from './discord-sync.js';
|
|
@@ -106,15 +106,24 @@ async function loadThreadAsCron(thread, guildId, scheduler, runtime, opts) {
|
|
|
106
106
|
}
|
|
107
107
|
return false;
|
|
108
108
|
}
|
|
109
|
-
|
|
109
|
+
// Try deterministic parse of bot-formatted starter first (no LLM needed).
|
|
110
|
+
const deterministicDef = parseStarterContent(starter.content);
|
|
111
|
+
const def = deterministicDef ?? await parseCronDefinition(starter.content, runtime, { model: opts.cronModel, cwd: opts.cwd });
|
|
110
112
|
if (!def) {
|
|
111
|
-
opts.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
if (opts.isNew) {
|
|
114
|
+
// Fresh thread — user just created it and needs feedback.
|
|
115
|
+
opts.log?.warn({ threadId: thread.id, name: thread.name }, 'cron:forum parse failed');
|
|
116
|
+
scheduler.disable(thread.id);
|
|
117
|
+
try {
|
|
118
|
+
await thread.send('Could not parse this cron definition. Please edit the starter message with a clearer schedule, timezone, target channel, and instruction.');
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Ignore send failures.
|
|
122
|
+
}
|
|
115
123
|
}
|
|
116
|
-
|
|
117
|
-
//
|
|
124
|
+
else {
|
|
125
|
+
// Boot/re-parse — soft failure: leave unregistered so cron-sync can retry.
|
|
126
|
+
opts.log?.warn({ threadId: thread.id, name: thread.name }, 'cron:forum parse failed (boot re-parse), skipping without disabling');
|
|
118
127
|
}
|
|
119
128
|
return false;
|
|
120
129
|
}
|
|
@@ -417,15 +426,10 @@ export async function initCronForum(opts) {
|
|
|
417
426
|
// Archive state changed.
|
|
418
427
|
if (oldThread.archived !== newThread.archived) {
|
|
419
428
|
if (newThread.archived) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
const record = statsStore.getRecordByThreadId(newThread.id);
|
|
425
|
-
if (record) {
|
|
426
|
-
void statsStore.upsertRecord(record.cronId, newThread.id, { disabled: true }).catch(() => { });
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
+
// Auto-archive (Discord inactivity) must NOT disable crons.
|
|
430
|
+
// Only explicit cronPause commands persist disabled: true.
|
|
431
|
+
// The cron keeps firing to its target channel; the thread is just UI.
|
|
432
|
+
log?.info({ threadId: newThread.id }, 'cron:forum thread archived (cron continues)');
|
|
429
433
|
}
|
|
430
434
|
else {
|
|
431
435
|
// Reject unarchived manual threads not already grandfathered into the scheduler.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { ChannelType } from 'discord.js';
|
|
3
3
|
vi.mock('./parser.js', () => {
|
|
4
|
-
return { parseCronDefinition: vi.fn() };
|
|
4
|
+
return { parseCronDefinition: vi.fn(), parseStarterContent: vi.fn() };
|
|
5
5
|
});
|
|
6
6
|
// Mock ensureStatusMessage and detectCadence to avoid side effects.
|
|
7
7
|
vi.mock('./discord-sync.js', async (importOriginal) => {
|
|
@@ -64,11 +64,13 @@ function makeScheduler() {
|
|
|
64
64
|
describe('initCronForum', () => {
|
|
65
65
|
let initCronForum;
|
|
66
66
|
let parseCronDefinition;
|
|
67
|
+
let parseStarterContent;
|
|
67
68
|
beforeEach(async () => {
|
|
68
69
|
// Dynamic import after mocks are registered.
|
|
69
70
|
({ initCronForum } = await import('./forum-sync.js'));
|
|
70
|
-
({ parseCronDefinition } = await import('./parser.js'));
|
|
71
|
+
({ parseCronDefinition, parseStarterContent } = await import('./parser.js'));
|
|
71
72
|
vi.mocked(parseCronDefinition).mockReset();
|
|
73
|
+
vi.mocked(parseStarterContent).mockReset();
|
|
72
74
|
});
|
|
73
75
|
it('does not register when starter author is not allowlisted', async () => {
|
|
74
76
|
const thread = makeThread();
|
|
@@ -134,7 +136,7 @@ describe('initCronForum', () => {
|
|
|
134
136
|
expect(scheduler.register).toHaveBeenCalledOnce();
|
|
135
137
|
expect(scheduler.disable).not.toHaveBeenCalled();
|
|
136
138
|
});
|
|
137
|
-
it('
|
|
139
|
+
it('soft-fails on boot re-parse (isNew: false) when both parsers return null — no disable, no error post', async () => {
|
|
138
140
|
const thread = makeThread();
|
|
139
141
|
thread.fetchStarterMessage.mockResolvedValue({
|
|
140
142
|
id: 'm1',
|
|
@@ -157,8 +159,9 @@ describe('initCronForum', () => {
|
|
|
157
159
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
158
160
|
});
|
|
159
161
|
expect(scheduler.register).not.toHaveBeenCalled();
|
|
160
|
-
|
|
161
|
-
expect(
|
|
162
|
+
// Boot re-parse: soft failure — no disable, no error message.
|
|
163
|
+
expect(scheduler.disable).not.toHaveBeenCalled();
|
|
164
|
+
expect(thread.send).not.toHaveBeenCalled();
|
|
162
165
|
});
|
|
163
166
|
it('registers when parsing succeeds and author is allowlisted', async () => {
|
|
164
167
|
const thread = makeThread();
|
|
@@ -592,14 +595,129 @@ describe('initCronForum', () => {
|
|
|
592
595
|
// Should disable the job because stats record says disabled: true.
|
|
593
596
|
expect(scheduler.disable).toHaveBeenCalledWith('thread-1');
|
|
594
597
|
});
|
|
598
|
+
it('uses deterministic parser when no stats record exists (AI parser not called)', async () => {
|
|
599
|
+
const thread = makeThread();
|
|
600
|
+
const botContent = [
|
|
601
|
+
'**Schedule:** `0 7 * * 1-5` (America/Los_Angeles)',
|
|
602
|
+
'**Channel:** #general',
|
|
603
|
+
'**Input:** prompt-only',
|
|
604
|
+
'',
|
|
605
|
+
'Check the weather.',
|
|
606
|
+
].join('\n');
|
|
607
|
+
thread.fetchStarterMessage.mockResolvedValue({
|
|
608
|
+
id: 'm1',
|
|
609
|
+
content: botContent,
|
|
610
|
+
author: { id: 'bot-user-1' },
|
|
611
|
+
react: vi.fn().mockResolvedValue(undefined),
|
|
612
|
+
});
|
|
613
|
+
thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
|
|
614
|
+
const forum = makeForum([thread]);
|
|
615
|
+
const client = makeClient(forum);
|
|
616
|
+
const scheduler = makeScheduler();
|
|
617
|
+
scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
|
|
618
|
+
// No stats store — fast path is skipped, falls into loadThreadAsCron.
|
|
619
|
+
// Deterministic parser returns a valid def.
|
|
620
|
+
vi.mocked(parseStarterContent).mockReturnValue({
|
|
621
|
+
triggerType: 'schedule',
|
|
622
|
+
schedule: '0 7 * * 1-5',
|
|
623
|
+
timezone: 'America/Los_Angeles',
|
|
624
|
+
channel: 'general',
|
|
625
|
+
prompt: 'Check the weather.',
|
|
626
|
+
});
|
|
627
|
+
await initCronForum({
|
|
628
|
+
client: client,
|
|
629
|
+
forumChannelNameOrId: 'forum-1',
|
|
630
|
+
allowUserIds: new Set(['u-allowed']),
|
|
631
|
+
scheduler: scheduler,
|
|
632
|
+
runtime: {},
|
|
633
|
+
cronModel: 'haiku',
|
|
634
|
+
cwd: '/tmp',
|
|
635
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
636
|
+
});
|
|
637
|
+
// Deterministic parser was called, AI parser was NOT called.
|
|
638
|
+
expect(parseStarterContent).toHaveBeenCalledWith(botContent);
|
|
639
|
+
expect(parseCronDefinition).not.toHaveBeenCalled();
|
|
640
|
+
expect(scheduler.register).toHaveBeenCalledOnce();
|
|
641
|
+
});
|
|
642
|
+
it('falls through to AI parser when deterministic parse returns null', async () => {
|
|
643
|
+
const thread = makeThread();
|
|
644
|
+
thread.fetchStarterMessage.mockResolvedValue({
|
|
645
|
+
id: 'm1',
|
|
646
|
+
content: 'Every weekday at 7am Pacific, check the weather and post to #general',
|
|
647
|
+
author: { id: 'u-allowed' },
|
|
648
|
+
react: vi.fn().mockResolvedValue(undefined),
|
|
649
|
+
});
|
|
650
|
+
thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
|
|
651
|
+
const forum = makeForum([thread]);
|
|
652
|
+
const client = makeClient(forum);
|
|
653
|
+
const scheduler = makeScheduler();
|
|
654
|
+
scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
|
|
655
|
+
// Deterministic parser returns null (freeform text).
|
|
656
|
+
vi.mocked(parseStarterContent).mockReturnValue(null);
|
|
657
|
+
vi.mocked(parseCronDefinition).mockResolvedValue({
|
|
658
|
+
triggerType: 'schedule',
|
|
659
|
+
schedule: '0 7 * * 1-5',
|
|
660
|
+
timezone: 'America/Los_Angeles',
|
|
661
|
+
channel: 'general',
|
|
662
|
+
prompt: 'Check the weather.',
|
|
663
|
+
});
|
|
664
|
+
await initCronForum({
|
|
665
|
+
client: client,
|
|
666
|
+
forumChannelNameOrId: 'forum-1',
|
|
667
|
+
allowUserIds: new Set(['u-allowed']),
|
|
668
|
+
scheduler: scheduler,
|
|
669
|
+
runtime: {},
|
|
670
|
+
cronModel: 'haiku',
|
|
671
|
+
cwd: '/tmp',
|
|
672
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
673
|
+
});
|
|
674
|
+
// Deterministic parser was called first, then AI parser.
|
|
675
|
+
expect(parseStarterContent).toHaveBeenCalled();
|
|
676
|
+
expect(parseCronDefinition).toHaveBeenCalled();
|
|
677
|
+
expect(scheduler.register).toHaveBeenCalledOnce();
|
|
678
|
+
});
|
|
679
|
+
it('new thread (isNew: true) still posts error when both parsers fail', async () => {
|
|
680
|
+
// This test uses the threadCreate listener which passes isNew: true.
|
|
681
|
+
const forum = makeForum([]);
|
|
682
|
+
const client = makeClient(forum);
|
|
683
|
+
const scheduler = makeScheduler();
|
|
684
|
+
scheduler.getJob.mockReturnValue(undefined);
|
|
685
|
+
await initCronForum({
|
|
686
|
+
client: client,
|
|
687
|
+
forumChannelNameOrId: 'forum-1',
|
|
688
|
+
allowUserIds: new Set(['u-allowed']),
|
|
689
|
+
scheduler: scheduler,
|
|
690
|
+
runtime: {},
|
|
691
|
+
cronModel: 'haiku',
|
|
692
|
+
cwd: '/tmp',
|
|
693
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
694
|
+
});
|
|
695
|
+
const threadCreateCallbacks = client._listeners['threadCreate'] ?? [];
|
|
696
|
+
const listener = threadCreateCallbacks[0];
|
|
697
|
+
vi.mocked(parseStarterContent).mockReturnValue(null);
|
|
698
|
+
vi.mocked(parseCronDefinition).mockResolvedValue(null);
|
|
699
|
+
const thread = makeThread({ id: 'thread-new-fail', parentId: 'forum-1' });
|
|
700
|
+
thread.fetchStarterMessage.mockResolvedValue({
|
|
701
|
+
id: 'm1',
|
|
702
|
+
content: 'total nonsense',
|
|
703
|
+
author: { id: 'bot-user-1' },
|
|
704
|
+
react: vi.fn().mockResolvedValue(undefined),
|
|
705
|
+
});
|
|
706
|
+
await listener(thread);
|
|
707
|
+
// isNew: true — should disable and post error message.
|
|
708
|
+
expect(scheduler.disable).toHaveBeenCalledWith('thread-new-fail');
|
|
709
|
+
expect(thread.send).toHaveBeenCalledWith(expect.stringContaining('Could not parse'));
|
|
710
|
+
});
|
|
595
711
|
});
|
|
596
712
|
describe('threadCreate listener', () => {
|
|
597
713
|
let initCronForum;
|
|
598
714
|
let parseCronDefinition;
|
|
715
|
+
let parseStarterContent;
|
|
599
716
|
beforeEach(async () => {
|
|
600
717
|
({ initCronForum } = await import('./forum-sync.js'));
|
|
601
|
-
({ parseCronDefinition } = await import('./parser.js'));
|
|
718
|
+
({ parseCronDefinition, parseStarterContent } = await import('./parser.js'));
|
|
602
719
|
vi.mocked(parseCronDefinition).mockReset();
|
|
720
|
+
vi.mocked(parseStarterContent).mockReset();
|
|
603
721
|
});
|
|
604
722
|
async function setupAndGetListener(opts = {}) {
|
|
605
723
|
const forum = makeForum([]);
|
|
@@ -712,10 +830,12 @@ describe('threadCreate listener', () => {
|
|
|
712
830
|
describe('threadUpdate listener', () => {
|
|
713
831
|
let initCronForum;
|
|
714
832
|
let parseCronDefinition;
|
|
833
|
+
let parseStarterContent;
|
|
715
834
|
beforeEach(async () => {
|
|
716
835
|
({ initCronForum } = await import('./forum-sync.js'));
|
|
717
|
-
({ parseCronDefinition } = await import('./parser.js'));
|
|
836
|
+
({ parseCronDefinition, parseStarterContent } = await import('./parser.js'));
|
|
718
837
|
vi.mocked(parseCronDefinition).mockReset();
|
|
838
|
+
vi.mocked(parseStarterContent).mockReset();
|
|
719
839
|
});
|
|
720
840
|
async function setupAndGetListener(opts = {}) {
|
|
721
841
|
const forum = makeForum([]);
|
|
@@ -730,11 +850,64 @@ describe('threadUpdate listener', () => {
|
|
|
730
850
|
cronModel: 'haiku',
|
|
731
851
|
cwd: '/tmp',
|
|
732
852
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
853
|
+
statsStore: opts.statsStore,
|
|
733
854
|
});
|
|
734
855
|
const threadUpdateCallbacks = client._listeners['threadUpdate'] ?? [];
|
|
735
856
|
expect(threadUpdateCallbacks.length).toBeGreaterThan(0);
|
|
736
857
|
return { listener: threadUpdateCallbacks[0], scheduler, client };
|
|
737
858
|
}
|
|
859
|
+
it('does not disable scheduler or persist disabled when thread is archived', async () => {
|
|
860
|
+
const scheduler = makeScheduler();
|
|
861
|
+
const statsStore = {
|
|
862
|
+
getRecordByThreadId: vi.fn().mockReturnValue({ cronId: 'cron-1', threadId: 'thread-1', disabled: false }),
|
|
863
|
+
getRecord: vi.fn(),
|
|
864
|
+
getStore: vi.fn().mockReturnValue({ jobs: {} }),
|
|
865
|
+
upsertRecord: vi.fn(async () => ({})),
|
|
866
|
+
};
|
|
867
|
+
const { listener } = await setupAndGetListener({ scheduler, statsStore });
|
|
868
|
+
const oldThread = makeThread({ id: 'thread-1', parentId: 'forum-1', archived: false });
|
|
869
|
+
const newThread = makeThread({ id: 'thread-1', parentId: 'forum-1', archived: true });
|
|
870
|
+
await listener(oldThread, newThread);
|
|
871
|
+
// Archiving should NOT disable the scheduler — cron keeps running.
|
|
872
|
+
expect(scheduler.disable).not.toHaveBeenCalled();
|
|
873
|
+
// Should NOT persist disabled: true to the stats store.
|
|
874
|
+
expect(statsStore.upsertRecord).not.toHaveBeenCalled();
|
|
875
|
+
});
|
|
876
|
+
it('cron survives archive+unarchive cycle without becoming stuck disabled', async () => {
|
|
877
|
+
const scheduler = makeScheduler();
|
|
878
|
+
scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
|
|
879
|
+
vi.mocked(parseCronDefinition).mockResolvedValue({
|
|
880
|
+
triggerType: 'schedule',
|
|
881
|
+
schedule: '0 7 * * *',
|
|
882
|
+
timezone: 'UTC',
|
|
883
|
+
channel: 'general',
|
|
884
|
+
prompt: 'Say hello.',
|
|
885
|
+
});
|
|
886
|
+
const statsStore = {
|
|
887
|
+
getRecordByThreadId: vi.fn().mockReturnValue({ cronId: 'cron-1', threadId: 'thread-1', disabled: false }),
|
|
888
|
+
getRecord: vi.fn().mockReturnValue({ cronId: 'cron-1', threadId: 'thread-1', disabled: false }),
|
|
889
|
+
getStore: vi.fn().mockReturnValue({ jobs: {} }),
|
|
890
|
+
upsertRecord: vi.fn(async () => ({})),
|
|
891
|
+
};
|
|
892
|
+
const { listener } = await setupAndGetListener({ scheduler, statsStore });
|
|
893
|
+
// Step 1: archive
|
|
894
|
+
const oldThread = makeThread({ id: 'thread-1', parentId: 'forum-1', archived: false });
|
|
895
|
+
const archivedThread = makeThread({ id: 'thread-1', parentId: 'forum-1', archived: true });
|
|
896
|
+
await listener(oldThread, archivedThread);
|
|
897
|
+
// Step 2: unarchive
|
|
898
|
+
const unarchivedThread = makeThread({ id: 'thread-1', parentId: 'forum-1', archived: false });
|
|
899
|
+
unarchivedThread.fetchStarterMessage.mockResolvedValue({
|
|
900
|
+
id: 'm1',
|
|
901
|
+
content: 'every day at 7am say hello',
|
|
902
|
+
author: { id: 'u-allowed' },
|
|
903
|
+
react: vi.fn().mockResolvedValue(undefined),
|
|
904
|
+
});
|
|
905
|
+
unarchivedThread.messages = { fetch: vi.fn().mockResolvedValue(new Map()) };
|
|
906
|
+
await listener(archivedThread, unarchivedThread);
|
|
907
|
+
// The cron should be re-registered, NOT disabled.
|
|
908
|
+
expect(scheduler.register).toHaveBeenCalled();
|
|
909
|
+
expect(scheduler.disable).not.toHaveBeenCalled();
|
|
910
|
+
});
|
|
738
911
|
it('rejects unarchived manual thread not in scheduler', async () => {
|
|
739
912
|
const scheduler = makeScheduler();
|
|
740
913
|
scheduler.getJob.mockReturnValue(undefined);
|
package/dist/cron/parser.js
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
import { resolveModel } from '../runtime/model-tiers.js';
|
|
2
2
|
import { getDefaultTimezone } from './default-timezone.js';
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic parser for bot-formatted starter messages produced by
|
|
5
|
+
* `buildStarterContent` in `src/discord/actions-crons.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Expected format:
|
|
8
|
+
* **Schedule:** `<schedule>` (<timezone>)
|
|
9
|
+
* **Channel:** #<channel>
|
|
10
|
+
* **Input:** <input-mode>
|
|
11
|
+
* [optional ```bash block```]
|
|
12
|
+
*
|
|
13
|
+
* <prompt text>
|
|
14
|
+
*
|
|
15
|
+
* Returns null if any required field is missing — non-bot-formatted
|
|
16
|
+
* messages silently fall through to AI.
|
|
17
|
+
*/
|
|
18
|
+
export function parseStarterContent(text) {
|
|
19
|
+
// Extract schedule and timezone from: **Schedule:** `<schedule>` (<timezone>)
|
|
20
|
+
const scheduleMatch = text.match(/\*\*Schedule:\*\*\s*`([^`]+)`\s*\(([^)]+)\)/);
|
|
21
|
+
if (!scheduleMatch)
|
|
22
|
+
return null;
|
|
23
|
+
const schedule = scheduleMatch[1].trim();
|
|
24
|
+
const timezone = scheduleMatch[2].trim();
|
|
25
|
+
if (!schedule || !timezone)
|
|
26
|
+
return null;
|
|
27
|
+
// Extract channel from: **Channel:** #<channel>
|
|
28
|
+
const channelMatch = text.match(/\*\*Channel:\*\*\s*#(\S+)/);
|
|
29
|
+
if (!channelMatch)
|
|
30
|
+
return null;
|
|
31
|
+
const channel = channelMatch[1].trim();
|
|
32
|
+
if (!channel)
|
|
33
|
+
return null;
|
|
34
|
+
// The prompt is everything after the blank-line separator following the metadata block.
|
|
35
|
+
// The metadata block ends after the **Input:** line (and optional ```bash``` block).
|
|
36
|
+
// Split on the first blank line that follows the metadata lines.
|
|
37
|
+
const lines = text.split('\n');
|
|
38
|
+
let blankLineIdx = -1;
|
|
39
|
+
let pastMetadata = false;
|
|
40
|
+
for (let i = 0; i < lines.length; i++) {
|
|
41
|
+
const line = lines[i];
|
|
42
|
+
// Track when we've passed the metadata lines (lines starting with ** or code fences)
|
|
43
|
+
if (line.startsWith('**') || line.startsWith('```')) {
|
|
44
|
+
pastMetadata = true;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// After metadata, find the first blank line
|
|
48
|
+
if (pastMetadata && line.trim() === '') {
|
|
49
|
+
blankLineIdx = i;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (blankLineIdx === -1)
|
|
54
|
+
return null;
|
|
55
|
+
const prompt = lines.slice(blankLineIdx + 1).join('\n').trim();
|
|
56
|
+
if (!prompt)
|
|
57
|
+
return null;
|
|
58
|
+
return {
|
|
59
|
+
triggerType: 'schedule',
|
|
60
|
+
schedule,
|
|
61
|
+
timezone,
|
|
62
|
+
channel,
|
|
63
|
+
prompt,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
3
66
|
function buildSystemPrompt() {
|
|
4
67
|
const defaultTz = getDefaultTimezone();
|
|
5
68
|
return `You are a cron definition parser. Extract a cron schedule from a natural-language task description.
|