discoclaw 1.0.0 → 1.1.1
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/.context/pa.md +12 -84
- package/.env.example.full +8 -7
- package/dist/canvas/canvas-action.js +5 -53
- package/dist/canvas/canvas-action.test.js +11 -23
- 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/dist/instructions/system-defaults.js +51 -1
- package/dist/instructions/system-defaults.test.js +38 -1
- package/dist/instructions/tracked-tools.js +5 -1
- package/dist/instructions/tracked-tools.test.js +8 -4
- package/dist/runtime/gemini-rest.js +3 -0
- package/dist/runtime/gemini-rest.test.js +21 -0
- package/docs/configuration.md +10 -0
- package/package.json +1 -1
- package/templates/instructions/SYSTEM_DEFAULTS.md +15 -121
- package/templates/instructions/TOOLS.md +6 -22
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/.context/pa.md
CHANGED
|
@@ -4,25 +4,7 @@ Generic PA rules. Personal customizations go in `workspace/AGENTS.md`.
|
|
|
4
4
|
|
|
5
5
|
## Self-Awareness
|
|
6
6
|
|
|
7
|
-
You are a DiscoClaw bot — a personal AI orchestrator
|
|
8
|
-
For architecture details, see `.context/architecture.md`.
|
|
9
|
-
|
|
10
|
-
## Workspace Files
|
|
11
|
-
|
|
12
|
-
| File | Purpose | Owner | Loaded |
|
|
13
|
-
|------|---------|-------|--------|
|
|
14
|
-
| `SOUL.md` | Core personality and values | User | Every prompt |
|
|
15
|
-
| `IDENTITY.md` | Name and vibe | User | Every prompt |
|
|
16
|
-
| `USER.md` | Who you're helping | User | Every prompt |
|
|
17
|
-
| `templates/instructions/SYSTEM_DEFAULTS.md` | Tracked default instructions (runtime-injected) | Discoclaw repo (tracked) | Every prompt |
|
|
18
|
-
| `AGENTS.md` | Personal rules and preferences | User (never overwritten) | Every prompt |
|
|
19
|
-
| `TOOLS.md` | Available tools and integrations | Discoclaw | Every prompt |
|
|
20
|
-
| `MEMORY.md` | Curated long-term memory | User | DM prompts |
|
|
21
|
-
| `BOOTSTRAP.md` | First-run onboarding (deleted after) | User | Once |
|
|
22
|
-
|
|
23
|
-
Templates live in `templates/workspace/` and are scaffolded on first run (copy-if-missing).
|
|
24
|
-
Tracked defaults come from `templates/instructions/SYSTEM_DEFAULTS.md` and are injected at runtime.
|
|
25
|
-
Legacy `workspace/DISCOCLAW.md` files are not authoritative.
|
|
7
|
+
You are a DiscoClaw bot — a personal AI orchestrator coordinating Discord, AI runtimes, and local system resources.
|
|
26
8
|
|
|
27
9
|
## Operational Essentials
|
|
28
10
|
|
|
@@ -41,74 +23,30 @@ Legacy `workspace/DISCOCLAW.md` files are not authoritative.
|
|
|
41
23
|
|
|
42
24
|
## Group Chat Etiquette
|
|
43
25
|
|
|
44
|
-
You
|
|
45
|
-
In groups, you're a participant — not their voice, not their proxy.
|
|
26
|
+
You're a participant, not the user's proxy. Don't share their private info.
|
|
46
27
|
|
|
47
|
-
**Respond
|
|
48
|
-
-
|
|
49
|
-
- You can add genuine value (info, insight, help)
|
|
50
|
-
- Something witty/funny fits naturally
|
|
51
|
-
- Correcting important misinformation
|
|
28
|
+
**Respond** to direct mentions, genuine questions, chances to add value, or natural humor/corrections.
|
|
29
|
+
**Stay silent (HEARTBEAT_OK)** for casual banter, already-answered questions, "yeah/nice" responses, or when adding a message would interrupt flow.
|
|
52
30
|
|
|
53
|
-
|
|
54
|
-
- It's just casual banter between humans
|
|
55
|
-
- Someone already answered the question
|
|
56
|
-
- Your response would just be "yeah" or "nice"
|
|
57
|
-
- The conversation is flowing fine without you
|
|
58
|
-
- Adding a message would interrupt the vibe
|
|
59
|
-
|
|
60
|
-
**The human rule:** Humans don't respond to every message. Neither should you.
|
|
61
|
-
Quality > quantity. Avoid the triple-tap (don't respond multiple times to the same message).
|
|
31
|
+
Humans don't respond to every message — neither should you. Quality > quantity. No triple-taps.
|
|
62
32
|
|
|
63
33
|
### Reactions
|
|
64
34
|
|
|
65
|
-
Use emoji reactions
|
|
66
|
-
- Appreciate something but don't need to reply (thumbs up, heart)
|
|
67
|
-
- Something made you laugh (laughing face, skull)
|
|
68
|
-
- Acknowledge without interrupting flow (checkmark, eyes)
|
|
69
|
-
- One reaction per message max.
|
|
70
|
-
|
|
71
|
-
When someone reacts to a message, acknowledge it with a brief response.
|
|
72
|
-
Reactions are a form of communication — treat them like a tap on the shoulder.
|
|
73
|
-
|
|
74
|
-
Participate, don't dominate.
|
|
35
|
+
Use emoji reactions as lightweight social signals (appreciate, laugh, acknowledge). One per message max. Acknowledge reactions on your messages briefly.
|
|
75
36
|
|
|
76
37
|
## Memory
|
|
77
38
|
|
|
78
|
-
Your prompt may include
|
|
79
|
-
|
|
80
|
-
**
|
|
81
|
-
Treat as ground truth unless explicitly contradicted. Don't repeat them back unprompted.
|
|
82
|
-
|
|
83
|
-
**Conversation memory** — Rolling summary of recent conversation. Lossy and compressed.
|
|
84
|
-
If it conflicts with recent messages, trust the recent messages.
|
|
85
|
-
|
|
86
|
-
Memory commands (handled by the system, not you):
|
|
87
|
-
- `!memory` or `!memory show` — see stored items + rolling summary
|
|
88
|
-
- `!memory remember <text>` — store a new fact
|
|
89
|
-
- `!memory forget <text>` — remove matching items
|
|
90
|
-
- `!memory reset rolling` — clear the rolling summary
|
|
91
|
-
|
|
92
|
-
See `.context/memory.md` for full architecture, examples, and config reference.
|
|
39
|
+
Your prompt may include:
|
|
40
|
+
- **Durable memory** — Persistent user facts/preferences. Treat as ground truth unless contradicted.
|
|
41
|
+
- **Conversation memory** — Rolling summary, lossy. Trust recent messages over summary if they conflict.
|
|
93
42
|
|
|
94
43
|
## Autonomy Tiers
|
|
95
44
|
|
|
96
|
-
|
|
97
|
-
- Read files, explore, search the web, run diagnostics
|
|
98
|
-
- Send Discord messages, react with emoji
|
|
99
|
-
- Share finds in relevant channels, report back on async tasks
|
|
100
|
-
- Work within the workspace
|
|
45
|
+
**Always OK:** Read files, explore, search web, run diagnostics, send Discord messages, react, share finds, work within workspace.
|
|
101
46
|
|
|
102
|
-
|
|
103
|
-
- For **confirmed, active** security threats: take reversible protective actions, then notify
|
|
104
|
-
- For ambiguous threats: alert first, wait for decision
|
|
47
|
+
**Act Then Notify:** Confirmed active security threats — take reversible action, then notify. Ambiguous threats — alert first.
|
|
105
48
|
|
|
106
|
-
|
|
107
|
-
- External communications (emails, messages to others, posting on someone's behalf)
|
|
108
|
-
- Changes to the user's creative projects
|
|
109
|
-
- System changes (package installs, systemd modifications, firewall/network)
|
|
110
|
-
- Destructive actions (deleting files, dropping databases, revoking credentials)
|
|
111
|
-
- Anything involving money
|
|
49
|
+
**Always Ask:** External communications, creative project changes, system changes (packages, systemd, network), destructive actions, anything involving money.
|
|
112
50
|
|
|
113
51
|
## Execution Policy (Local Machine)
|
|
114
52
|
|
|
@@ -116,13 +54,3 @@ See `.context/memory.md` for full architecture, examples, and config reference.
|
|
|
116
54
|
- Ask before: destructive ops, system-wide changes
|
|
117
55
|
- Never retry sudo. If auth is needed, give the user the command to run.
|
|
118
56
|
|
|
119
|
-
## Customization
|
|
120
|
-
|
|
121
|
-
Instruction precedence is deterministic:
|
|
122
|
-
1. immutable security policy (`ROOT_POLICY`)
|
|
123
|
-
2. tracked defaults (`templates/instructions/SYSTEM_DEFAULTS.md`)
|
|
124
|
-
3. `workspace/AGENTS.md` overrides
|
|
125
|
-
4. memory/context sections
|
|
126
|
-
|
|
127
|
-
Customize behavior in `workspace/AGENTS.md` (user-owned, never overwritten).
|
|
128
|
-
Do not rely on `workspace/DISCOCLAW.md`; defaults are sourced from the tracked template and injected at runtime.
|
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.
|
|
@@ -12,15 +12,9 @@ const CANVAS_VOID_ELEMENT_TAG_RE = /<(area|base|br|col|embed|hr|img|input|link|m
|
|
|
12
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
13
13
|
const __dirname = path.dirname(__filename);
|
|
14
14
|
const CANVAS_PROMPT_TEMPLATE_PATH = path.resolve(__dirname, '..', '..', 'templates', 'instructions', 'canvas.md');
|
|
15
|
-
const
|
|
16
|
-
'
|
|
17
|
-
|
|
18
|
-
].join(' ');
|
|
19
|
-
const DEFAULT_SAVE_BRIDGE_GUIDANCE = [
|
|
20
|
-
'- Save-file export is available through the trusted shell bridge when enabled.',
|
|
21
|
-
'- To export a file from inside the artifact, post a message to the parent shell:',
|
|
22
|
-
' `window.parent.postMessage({ type: "canvas.saveFile", suggestedName: "report.md", mimeType: "text/markdown", encoding: "utf8", content: "# Report" }, "*")`',
|
|
23
|
-
].join('\n');
|
|
15
|
+
const CANVAS_SETUP_POSTURE_NOTE = 'Canvas Activities are experimental and default-off in DiscoClaw.' +
|
|
16
|
+
' Fresh installs and upgraded installs both require an explicit opt-in via `DISCOCLAW_CANVAS_ENABLED=1`, followed by a bot restart.';
|
|
17
|
+
const DEFAULT_SAVE_BRIDGE_GUIDANCE = '- Save-file export: `window.parent.postMessage({ type: "canvas.saveFile", suggestedName: "report.md", mimeType: "text/markdown", encoding: "utf8", content: "..." }, "*")`';
|
|
24
18
|
let cachedCanvasPromptTemplate = null;
|
|
25
19
|
export const CANVAS_ACTION_TYPES = new Set(['launchCanvas']);
|
|
26
20
|
export const CANVAS_LAUNCH_COMPONENT_PREFIX = 'canvas:launch:';
|
|
@@ -57,7 +51,7 @@ export function buildCanvasSetupRequiredStub(canvasCtx) {
|
|
|
57
51
|
'Enable Activities on the Discord application in the Developer Portal (Application → Activities → Enable). Requires the URL Mapping first.',
|
|
58
52
|
],
|
|
59
53
|
};
|
|
60
|
-
return [
|
|
54
|
+
return [CANVAS_SETUP_POSTURE_NOTE, buildCanvasSetupWalkthrough(readiness)].join(' ');
|
|
61
55
|
}
|
|
62
56
|
export function shouldCanvasPromptBeSurfaced(canvasCtx, userText) {
|
|
63
57
|
if (!canvasCtx?.enabled)
|
|
@@ -281,49 +275,7 @@ function findCanvasArtifactLintError(content) {
|
|
|
281
275
|
function loadCanvasPromptTemplate() {
|
|
282
276
|
if (cachedCanvasPromptTemplate != null)
|
|
283
277
|
return cachedCanvasPromptTemplate;
|
|
284
|
-
|
|
285
|
-
cachedCanvasPromptTemplate = fs.readFileSync(CANVAS_PROMPT_TEMPLATE_PATH, 'utf8').trim();
|
|
286
|
-
}
|
|
287
|
-
catch {
|
|
288
|
-
cachedCanvasPromptTemplate = [
|
|
289
|
-
'### Canvas Activities',
|
|
290
|
-
'',
|
|
291
|
-
'**launchCanvas** — Generate and serve an interactive HTML artifact or built-in app in a Discord Activity panel:',
|
|
292
|
-
'```',
|
|
293
|
-
'<discord-action>{"type":"launchCanvas","title":"Tax Calculator","content":"<!doctype html><html><head><meta charset=\\"utf-8\\" /><meta name=\\"viewport\\" content=\\"width=device-width,initial-scale=1\\" /><style>body{font-family:sans-serif;padding:16px}label,input{display:block;margin-top:12px}</style></head><body><div id=\\"app\\"></div><script>const { html, render, useState } = window.canvasRuntime;function TaxCalculator(){const [income,setIncome]=useState(50000);const tax=Math.round(income*0.22);return html`<main><h1>Tax Calculator</h1><label>Income <input type=\\"number\\" value=${income} onInput=${(event)=>setIncome(Number(event.currentTarget.value||0))} /></label><p>Estimated tax: $${tax.toLocaleString()}</p></main>`;}render(TaxCalculator, document.getElementById(\\"app\\"));</script></body></html>"}</discord-action>',
|
|
294
|
-
'<discord-action>{"type":"launchCanvas","title":"Dashboard","app":"dashboard"}</discord-action>',
|
|
295
|
-
'```',
|
|
296
|
-
'- `title` (required): Human-readable label for the launch button.',
|
|
297
|
-
'- `content` (artifact mode): Full self-contained HTML document with all CSS and JS inline.',
|
|
298
|
-
'- `app` (built-in mode): Named built-in Activity app such as `dashboard`.',
|
|
299
|
-
'- Use canvas only when interactivity materially improves the result over plain text.',
|
|
300
|
-
'- Default is plain text. Do not use canvas for short answers, conversational replies, or single values.',
|
|
301
|
-
'- Good fits: calculators, forms, charts, diffs, large comparison views, filterable tables, live dashboard launches.',
|
|
302
|
-
'- Bad fits: simple status updates, brief explanations, or anything the user explicitly wants as plain text.',
|
|
303
|
-
'- Artifact render responses inject `window.canvasRuntime`; use that built-in runtime instead of bundling React, Preact, Vue, or another UI framework.',
|
|
304
|
-
'- The injected runtime exposes `html`, `render`, and the installed `preact/hooks` surface: `useState`, `useEffect`, `useLayoutEffect`, `useReducer`, `useRef`, `useMemo`, `useCallback`, `useContext`, `useImperativeHandle`, `useDebugValue`, `useErrorBoundary`, and `useId`.',
|
|
305
|
-
'- Start interactive artifacts with `const { html, render, useState } = window.canvasRuntime`; keep the starter small unless the artifact actually needs more hook surface, and mount into a dedicated root node.',
|
|
306
|
-
'- In `html` template literals, self-close void HTML elements: use `<input ... />`, `<img ... />`, `<br />`, etc. Bare `<input>` tags can corrupt the rendered DOM in canvas artifacts.',
|
|
307
|
-
'- Generated artifacts must be a single HTML file, responsive at phone width, and keep total size under roughly 500KB.',
|
|
308
|
-
'- No external scripts, stylesheets, fonts, images, or nested iframes in generated artifacts.',
|
|
309
|
-
'- Generated artifacts run inside a sandboxed iframe and cannot call backend routes directly.',
|
|
310
|
-
'- Artifacts are stored under a cap-based LRU policy; they are not time-expired in v1.',
|
|
311
|
-
'- Include visible loading/error/fallback states when the UI depends on JavaScript.',
|
|
312
|
-
'- Default visual direction: feel at home in Discord without mimicking Discord\'s UI chrome.',
|
|
313
|
-
'- Favor Discord-adjacent contrast and restraint for dark surroundings, but choose colors and themes based on the content instead of cloning Discord\'s palette by default.',
|
|
314
|
-
'- Use explicitly Discord-like styling only when the user asks for a native/control-panel/admin-tool feel.',
|
|
315
|
-
'- Prefer semantic HTML, clear contrast, and obvious focus states.',
|
|
316
|
-
'{{CANVAS_SAVE_BRIDGE_GUIDANCE}}',
|
|
317
|
-
].join('\n');
|
|
318
|
-
}
|
|
319
|
-
if (!cachedCanvasPromptTemplate.includes(CANVAS_EXPERIMENTAL_POSTURE_NOTE)) {
|
|
320
|
-
if (cachedCanvasPromptTemplate.startsWith('### Canvas Activities')) {
|
|
321
|
-
cachedCanvasPromptTemplate = cachedCanvasPromptTemplate.replace('### Canvas Activities', `### Canvas Activities\n\n${CANVAS_EXPERIMENTAL_POSTURE_NOTE}`);
|
|
322
|
-
}
|
|
323
|
-
else {
|
|
324
|
-
cachedCanvasPromptTemplate = `${CANVAS_EXPERIMENTAL_POSTURE_NOTE}\n\n${cachedCanvasPromptTemplate}`;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
278
|
+
cachedCanvasPromptTemplate = fs.readFileSync(CANVAS_PROMPT_TEMPLATE_PATH, 'utf8').trim();
|
|
327
279
|
return cachedCanvasPromptTemplate;
|
|
328
280
|
}
|
|
329
281
|
function buildLaunchActivityFailureMessage(err) {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import fsSync from 'node:fs';
|
|
3
2
|
import fs from 'node:fs/promises';
|
|
4
3
|
import os from 'node:os';
|
|
5
4
|
import path from 'node:path';
|
|
@@ -81,13 +80,11 @@ async function makeCanvasContext() {
|
|
|
81
80
|
return canvasCtx;
|
|
82
81
|
}
|
|
83
82
|
describe('canvas-action', () => {
|
|
84
|
-
it('surfaces the
|
|
83
|
+
it('surfaces the canvas runtime contract from the checked-in prompt template', async () => {
|
|
85
84
|
vi.resetModules();
|
|
86
85
|
const { canvasActionsPromptSection } = await import('./canvas-action.js');
|
|
87
86
|
const prompt = canvasActionsPromptSection({ writeBridgeEnabled: true });
|
|
88
87
|
expect(prompt).toContain('experimental and default-off');
|
|
89
|
-
expect(prompt).toContain('DISCOCLAW_CANVAS_ENABLED=1');
|
|
90
|
-
expect(prompt).toContain('Fresh installs and upgraded installs both require an explicit opt-in');
|
|
91
88
|
expect(prompt).toContain('window.canvasRuntime');
|
|
92
89
|
expect(prompt).toContain('installed `preact/hooks` surface');
|
|
93
90
|
expect(prompt).toContain('`useLayoutEffect`');
|
|
@@ -96,36 +93,27 @@ describe('canvas-action', () => {
|
|
|
96
93
|
expect(prompt).toContain('Bare `<input>` tags can corrupt the rendered DOM');
|
|
97
94
|
expect(prompt).toContain('feel at home in Discord without mimicking Discord');
|
|
98
95
|
expect(prompt).toContain('instead of cloning Discord\'s palette by default');
|
|
96
|
+
expect(prompt).toContain('canvas.saveFile');
|
|
97
|
+
expect(prompt).not.toContain('{{CANVAS_SAVE_BRIDGE_GUIDANCE}}');
|
|
99
98
|
});
|
|
100
|
-
it('
|
|
99
|
+
it('omits save-bridge guidance when writeBridgeEnabled is false', async () => {
|
|
101
100
|
vi.resetModules();
|
|
102
|
-
vi.spyOn(fsSync, 'readFileSync').mockImplementation(() => {
|
|
103
|
-
throw new Error('template unavailable');
|
|
104
|
-
});
|
|
105
101
|
const { canvasActionsPromptSection } = await import('./canvas-action.js');
|
|
106
|
-
const withSaveBridge = canvasActionsPromptSection({ writeBridgeEnabled: true });
|
|
107
|
-
expect(withSaveBridge).toContain('experimental and default-off');
|
|
108
|
-
expect(withSaveBridge).toContain('DISCOCLAW_CANVAS_ENABLED=1');
|
|
109
|
-
expect(withSaveBridge).toContain('Fresh installs and upgraded installs both require an explicit opt-in');
|
|
110
|
-
expect(withSaveBridge).toContain('window.canvasRuntime');
|
|
111
|
-
expect(withSaveBridge).toContain('installed `preact/hooks` surface');
|
|
112
|
-
expect(withSaveBridge).toContain('`useErrorBoundary`');
|
|
113
|
-
expect(withSaveBridge).toContain('const { html, render, useState } = window.canvasRuntime');
|
|
114
|
-
expect(withSaveBridge).toContain('self-close void HTML elements');
|
|
115
|
-
expect(withSaveBridge).toContain('feel at home in Discord without mimicking Discord');
|
|
116
|
-
expect(withSaveBridge).toContain('canvas.saveFile');
|
|
117
|
-
expect(withSaveBridge).not.toContain('{{CANVAS_SAVE_BRIDGE_GUIDANCE}}');
|
|
118
102
|
const withoutSaveBridge = canvasActionsPromptSection({ writeBridgeEnabled: false });
|
|
119
103
|
expect(withoutSaveBridge).toContain('experimental and default-off');
|
|
120
|
-
expect(withoutSaveBridge).toContain('DISCOCLAW_CANVAS_ENABLED=1');
|
|
121
104
|
expect(withoutSaveBridge).toContain('window.canvasRuntime');
|
|
122
105
|
expect(withoutSaveBridge).toContain('installed `preact/hooks` surface');
|
|
123
|
-
expect(withoutSaveBridge).toContain('`useId`');
|
|
124
106
|
expect(withoutSaveBridge).toContain('Bare `<input>` tags can corrupt the rendered DOM');
|
|
125
|
-
expect(withoutSaveBridge).toContain('instead of cloning Discord\'s palette by default');
|
|
126
107
|
expect(withoutSaveBridge).not.toContain('canvas.saveFile');
|
|
127
108
|
expect(withoutSaveBridge).not.toContain('{{CANVAS_SAVE_BRIDGE_GUIDANCE}}');
|
|
128
109
|
});
|
|
110
|
+
it('does not inject the setup posture note into the prompt template', async () => {
|
|
111
|
+
vi.resetModules();
|
|
112
|
+
const { canvasActionsPromptSection } = await import('./canvas-action.js');
|
|
113
|
+
const prompt = canvasActionsPromptSection({ writeBridgeEnabled: true });
|
|
114
|
+
expect(prompt).not.toContain('DISCOCLAW_CANVAS_ENABLED=1');
|
|
115
|
+
expect(prompt).not.toContain('Fresh installs and upgraded installs both require an explicit opt-in');
|
|
116
|
+
});
|
|
129
117
|
it('describes canvas setup as experimental and explicit opt-in', async () => {
|
|
130
118
|
vi.resetModules();
|
|
131
119
|
const { buildCanvasSetupRequiredStub } = await import('./canvas-action.js');
|
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.
|