discoclaw 0.5.0 → 0.5.2

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 (49) hide show
  1. package/.context/memory.md +11 -6
  2. package/.context/pa.md +1 -0
  3. package/.context/tasks.md +7 -0
  4. package/.context/voice.md +1 -1
  5. package/.env.example +2 -2
  6. package/.env.example.full +13 -9
  7. package/dist/config.js +1 -1
  8. package/dist/cron/auto-tag.js +9 -6
  9. package/dist/cron/cron-sync-coordinator.test.js +5 -4
  10. package/dist/cron/cron-sync.js +50 -3
  11. package/dist/cron/cron-tag-map-watcher.test.js +1 -1
  12. package/dist/cron/executor.js +3 -4
  13. package/dist/cron/run-stats.js +5 -1
  14. package/dist/cron/run-stats.test.js +6 -6
  15. package/dist/discord/actions-config.js +1 -1
  16. package/dist/discord/actions-crons.js +73 -8
  17. package/dist/discord/actions-crons.test.js +13 -10
  18. package/dist/discord/actions-memory.js +2 -2
  19. package/dist/discord/actions-messaging.js +3 -2
  20. package/dist/discord/actions-messaging.test.js +21 -0
  21. package/dist/discord/actions.js +12 -0
  22. package/dist/discord/deferred-runner.js +9 -7
  23. package/dist/discord/deferred-runner.test.js +30 -0
  24. package/dist/discord/durable-memory.js +41 -8
  25. package/dist/discord/durable-memory.test.js +106 -7
  26. package/dist/discord/inflight-replies.js +31 -1
  27. package/dist/discord/inflight-replies.test.js +93 -0
  28. package/dist/discord/message-coordinator.js +54 -16
  29. package/dist/discord/message-coordinator.reaction-action-ordering.test.js +188 -0
  30. package/dist/discord/message-coordinator.reaction-cleanup.test.js +12 -6
  31. package/dist/discord/output-common.js +3 -3
  32. package/dist/discord/output-utils.js +57 -2
  33. package/dist/discord/prompt-common.js +48 -1
  34. package/dist/discord/prompt-common.test.js +66 -1
  35. package/dist/discord/reaction-handler.js +7 -6
  36. package/dist/discord/reaction-handler.test.js +30 -1
  37. package/dist/discord/transport-client.js +87 -0
  38. package/dist/discord/transport-client.test.js +273 -0
  39. package/dist/discord/user-turn-to-durable.js +2 -2
  40. package/dist/discord.js +1 -1
  41. package/dist/discord.render.test.js +51 -1
  42. package/dist/index.js +5 -1
  43. package/dist/runtime/model-tiers.js +10 -6
  44. package/dist/tasks/task-action-executor.test.js +21 -0
  45. package/dist/tasks/task-action-mutations.js +4 -0
  46. package/dist/voice/voice-sanitize.js +42 -0
  47. package/dist/voice/voice-sanitize.test.js +117 -0
  48. package/dist/voice/voice-style-prompt.js +3 -1
  49. package/package.json +1 -1
@@ -33,7 +33,10 @@ Bot: We've been working through your Express → Fastify migration.
33
33
 
34
34
  Structured store of user facts. Each item has a kind (fact, preference, project,
35
35
  constraint, person, tool, workflow), deduplication by content hash, and a 200-item
36
- cap per user. Injected into every prompt.
36
+ cap per user. Items track `hitCount` (incremented each time the item is selected
37
+ for prompt injection) and `lastHitAt` (timestamp of most recent selection).
38
+ Injected into every prompt using a blended score of recency and usage frequency
39
+ rather than raw `updatedAt` alone.
37
40
 
38
41
  **What the user sees:**
39
42
  - The bot knows your preferences, projects, and key facts across all conversations.
@@ -53,7 +56,7 @@ Bot: Given your preference for Rust in systems work, I'd lean that way —
53
56
 
54
57
  #### Consolidation
55
58
 
56
- When the active item count for a user crosses a threshold (`DISCOCLAW_DURABLE_CONSOLIDATION_THRESHOLD`, default `100`), consolidation can be triggered to prune and merge the list. A single `fast`-tier model call receives all active items and is asked to return a revised list — removing exact duplicates, merging near-duplicates, dropping clearly stale items, and preserving everything that is still plausibly useful. The model must not invent new facts or change the meaning of existing ones.
59
+ When the active item count for a user crosses a threshold (`DISCOCLAW_DURABLE_CONSOLIDATION_THRESHOLD`, default `100`), consolidation can be triggered to prune and merge the list. A single `fast`-tier model call receives all active items and is asked to return a revised list — removing exact duplicates, merging near-duplicates, dropping clearly stale items, and preserving everything that is still plausibly useful. The model must not invent new facts or change the meaning of existing ones. Items with low `hitCount` and stale `lastHitAt` are natural eviction candidates — the blended score surfaces which items the AI actually uses versus those that were added once and never referenced again.
57
60
 
58
61
  The revised list is applied atomically: items absent from the model's output are deprecated via `deprecateItems()`; new or rewritten items are written via `addItem()`. Items present verbatim in the output are left untouched (no unnecessary writes).
59
62
 
@@ -183,17 +186,18 @@ no separator). The three memory builders run in `Promise.all` so they add no lat
183
186
 
184
187
  | Layer | Default budget | Default state | How it stays within budget |
185
188
  |-------|---------------|---------------|---------------------------|
186
- | Durable memory | 2000 chars | on | Sorts active items by recency, adds one at a time, stops when next line would exceed budget. Older facts silently excluded. |
189
+ | Durable memory | 2000 chars | on | Ranks active items by blended score (recency + hit frequency), adds one at a time, stops when next line would exceed budget. Low-scoring items silently excluded. |
187
190
  | Rolling summary | 2000 chars | on | The `fast`-tier model is prompted with `"Keep the summary under {maxChars} characters"`. Replaces itself each update rather than growing. |
188
191
  | Message history | 3000 chars | on | Fetches up to 10 messages, walks backward from newest. Bot messages truncated to fit; user messages that don't fit cause a hard stop. |
189
192
  | Short-term memory | 1000 chars | **on** | Filters by max age (default 6h), sorts newest-first, accumulates lines until budget hit. |
193
+ | Open tasks | 600 chars | **on** | Queries TaskStore for non-closed tasks at invocation time, formats as a compact list. |
190
194
  | Auto-extraction | n/a | **off** | Write-side only — extracts facts for future prompts, adds nothing to the current turn. |
191
195
  | Workspace files | no budget | on (DMs only) | Loaded as file paths, not inlined. The runtime reads them on demand. |
192
196
 
193
197
  ### Default prompt overhead
194
198
 
195
- With the three enabled layers at default settings, worst-case memory overhead is
196
- **~7000 chars (~1750 tokens)**. With all layers enabled, ~8000 chars (~2000 tokens).
199
+ With the four enabled layers at default settings, worst-case memory overhead is
200
+ **~8600 chars (~2150 tokens)**.
197
201
  This is modest against typical `capable`-tier context windows.
198
202
 
199
203
  In practice most prompts use far less — a user with 5 durable items and a short summary
@@ -201,7 +205,7 @@ might add ~500 chars total. Sections with no data produce zero overhead.
201
205
 
202
206
  ### Where the budgets are enforced
203
207
 
204
- - **Durable**: `selectItemsForInjection()` in `durable-memory.ts:152`
208
+ - **Durable**: `selectItemsForInjection()` in `durable-memory.ts:152` — scores items using `hitCount`, `lastHitAt`, and `updatedAt`; increments hit counters on selected items
205
209
  - **Short-term**: `selectEntriesForInjection()` in `shortterm-memory.ts:113`
206
210
  - **Summary**: `fast`-tier prompt constraint in `summarizer.ts:63`
207
211
  - **History**: `fetchMessageHistory()` in `message-history.ts:38`
@@ -216,6 +220,7 @@ Memory sections are injected into every prompt in this order:
216
220
  Context files (PA + MEMORY.md + daily logs + channel context)
217
221
  → Durable memory section (up to 2000 chars)
218
222
  → Short-term memory section (up to 1000 chars)
223
+ → Open tasks section (up to 600 chars)
219
224
  → Rolling summary section (up to 2000 chars)
220
225
  → Message history (up to 3000 chars)
221
226
  → Discord actions
package/.context/pa.md CHANGED
@@ -26,6 +26,7 @@ Templates live in `templates/workspace/` and are scaffolded on first run (copy-i
26
26
  - **Never go silent.** Acknowledge before tool calls.
27
27
  - Narrate failures and pivots.
28
28
  - Summarize outcomes; don't assume the user saw tool output.
29
+ - **Close the loop.** When query actions return data, answer the user's original question in the same response. Don't chain query after query without delivering a conclusion. If you need more data, say what you're checking and why — but every auto-follow-up response should either deliver the answer or visibly advance toward one.
29
30
  - **Never edit `tasks.jsonl`, cron store files, or other data files directly.** Always use the corresponding discord action (`taskUpdate`, `taskCreate`, `cronUpdate`, etc.). Direct file edits bypass Discord thread sync and leave the UI stale.
30
31
 
31
32
  ## Discord Formatting
package/.context/tasks.md CHANGED
@@ -5,6 +5,13 @@ Tasks are backed by an in-process `TaskStore` and synced to Discord forum thread
5
5
  Ground-zero post-hard-cut refactor tracker: `docs/tasks-ground-zero-post-hard-cut-plan.md`
6
6
  Ground-zero task architecture refactor status: COMPLETE (FROZEN), verified on 2026-02-21.
7
7
 
8
+ ## Prompt Injection
9
+
10
+ Open task statuses are injected into prompts at invocation time, sourced directly
11
+ from the TaskStore. This ensures every new session starts with accurate task state
12
+ regardless of rolling summary freshness. See `.context/memory.md` for assembly order
13
+ and token budget.
14
+
8
15
  ## Data Model
9
16
 
10
17
  Canonical type: `TaskData` in `src/tasks/types.ts`.
package/.context/voice.md CHANGED
@@ -83,6 +83,6 @@ When `voiceEnabled=true`, the post-connect block in `src/index.ts` initializes t
83
83
  | `DEEPGRAM_API_KEY` | — | Required for deepgram STT and TTS |
84
84
  | `DEEPGRAM_STT_MODEL` | `nova-3-conversationalai` | Deepgram STT model name |
85
85
  | `DEEPGRAM_TTS_VOICE` | `aura-2-asteria-en` | Deepgram TTS voice name |
86
- | `DEEPGRAM_TTS_SPEED` | `1.0` | Deepgram TTS playback speed (range 0.5–1.5) |
86
+ | `DEEPGRAM_TTS_SPEED` | `1.3` | Deepgram TTS playback speed (range 0.5–1.5) |
87
87
  | `CARTESIA_API_KEY` | — | Required for cartesia TTS |
88
88
  | *(built-in)* | — | Telegraphic style instruction hardcoded into every voice AI invocation — front-loads the answer, strips preambles/markdown/filler, keeps responses short for TTS latency. Not an env var; not overridable by `DISCOCLAW_VOICE_SYSTEM_PROMPT`. |
package/.env.example CHANGED
@@ -32,7 +32,7 @@ DISCORD_ALLOW_USER_IDS=
32
32
  # connect and persisted to system-scaffold.json. Only set this to override the auto-created channel.
33
33
  #DISCOCLAW_CRON_FORUM=
34
34
 
35
- # Default model for cron job execution: fast | capable (or concrete model names).
35
+ # Default model for cron job execution: fast | capable | deep (or concrete model names).
36
36
  # Defaults to capable (resolves to Sonnet on Claude Code) — avoids using Opus on routine cron work.
37
37
  # Override at runtime via `!models set cron-exec <model>`.
38
38
  #DISCOCLAW_CRON_EXEC_MODEL=capable
@@ -62,7 +62,7 @@ DISCORD_GUILD_ID=
62
62
  # Disable Codex session persistence/resume (workaround for session DB issues):
63
63
  #CODEX_DISABLE_SESSIONS=1
64
64
 
65
- # Model tier: fast | capable (provider-agnostic).
65
+ # Model tier: fast | capable | deep (provider-agnostic).
66
66
  # Concrete model names (e.g. opus, sonnet, gpt-4o) are still accepted as passthrough.
67
67
  #RUNTIME_MODEL=capable
68
68
 
package/.env.example.full CHANGED
@@ -49,36 +49,40 @@ DISCORD_ALLOW_USER_IDS=
49
49
  #PRIMARY_RUNTIME=claude
50
50
 
51
51
  # --- Primary models ---
52
- # Model tier: fast | capable (provider-agnostic).
52
+ # Model tier: fast | capable | deep (provider-agnostic).
53
53
  # Concrete model names (e.g. opus, sonnet, gpt-4o) are still accepted as passthrough.
54
54
  #RUNTIME_MODEL=capable
55
55
 
56
56
  # --- Fast-tier default ---
57
57
  # Sets the default model for all "small" tasks (summary, cron, cron auto-tag, task auto-tag).
58
- # Individual overrides (DISCOCLAW_SUMMARY_MODEL, etc.) still win when set.
58
+ # Valid tiers: fast | capable | deep. Individual overrides (DISCOCLAW_SUMMARY_MODEL, etc.) still win when set.
59
59
  #DISCOCLAW_FAST_MODEL=fast
60
60
 
61
61
  # --- Tier model overrides ---
62
62
  # Override the concrete model resolved for any runtime × tier combination.
63
- # Format: DISCOCLAW_TIER_<RUNTIME>_<FAST|CAPABLE>=<model>
63
+ # Format: DISCOCLAW_TIER_<RUNTIME>_<FAST|CAPABLE|DEEP>=<model>
64
64
  # Unset = use the built-in default shown in the comments.
65
65
  # Concrete model names (e.g. sonnet, gpt-4o-mini) are passed through unchanged.
66
66
  #
67
67
  # Claude Code adapter (default: fast=haiku, capable=sonnet):
68
68
  #DISCOCLAW_TIER_CLAUDE_CODE_FAST=haiku
69
69
  #DISCOCLAW_TIER_CLAUDE_CODE_CAPABLE=sonnet
70
+ #DISCOCLAW_TIER_CLAUDE_CODE_DEEP=claude-opus-4-6
70
71
  #
71
72
  # Gemini CLI adapter (default: fast=gemini-2.5-flash, capable=gemini-2.5-pro):
72
73
  #DISCOCLAW_TIER_GEMINI_FAST=gemini-2.5-flash
73
74
  #DISCOCLAW_TIER_GEMINI_CAPABLE=gemini-2.5-pro
75
+ #DISCOCLAW_TIER_GEMINI_DEEP=gemini-2.5-pro
74
76
  #
75
77
  # OpenAI-compatible adapter (default: adapter-default for both tiers):
76
78
  #DISCOCLAW_TIER_OPENAI_FAST=gpt-4o-mini
77
79
  #DISCOCLAW_TIER_OPENAI_CAPABLE=gpt-4o
80
+ #DISCOCLAW_TIER_OPENAI_DEEP=
78
81
  #
79
82
  # Codex CLI adapter (default: adapter-default for both tiers):
80
83
  #DISCOCLAW_TIER_CODEX_FAST=
81
84
  #DISCOCLAW_TIER_CODEX_CAPABLE=
85
+ #DISCOCLAW_TIER_CODEX_DEEP=
82
86
  #
83
87
  # Tool-tier map — override which tool tier a model resolves to.
84
88
  # Comma-separated model=tier pairs. Tiers: basic, standard, full.
@@ -111,9 +115,9 @@ DISCORD_ALLOW_USER_IDS=
111
115
  # Cron — forum-based scheduled tasks (enabled by default)
112
116
  # ----------------------------------------------------------
113
117
  # Forum channel ID is auto-created on first connect (see AUTO-DETECTED above).
114
- # Model tier for cron execution: fast | capable (concrete names accepted as passthrough).
118
+ # Model tier for cron execution: fast | capable | deep (concrete names accepted as passthrough).
115
119
  #DISCOCLAW_CRON_MODEL=fast
116
- # Default model tier for cron job execution (fast | capable, concrete names accepted as passthrough).
120
+ # Default model tier for cron job execution (fast | capable | deep, concrete names accepted as passthrough).
117
121
  # Defaults to capable (resolves to Sonnet on Claude Code) — avoids using Opus on routine cron work.
118
122
  # Per-job overrides and AI-classified model still win when set. Override at runtime via
119
123
  # `!models set cron-exec <model>`.
@@ -331,7 +335,7 @@ DISCOCLAW_DISCORD_ACTIONS_DEFER=1
331
335
  #USE_GROUP_DIR_CWD=0
332
336
  # Tools available to the runtime (Glob, Grep, Write now included by default).
333
337
  #RUNTIME_TOOLS=Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch
334
- # Fallback model tier when primary is overloaded (concrete names accepted as passthrough).
338
+ # Fallback model tier when primary is overloaded: fast | capable | deep (concrete names accepted as passthrough).
335
339
  #RUNTIME_FALLBACK_MODEL=fast
336
340
  # Max USD spend per Claude CLI process. One-shot: per invocation.
337
341
  # Multi-turn: per session lifetime (accumulates across turns). Recommend $5-10.
@@ -529,7 +533,7 @@ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
529
533
  # in system-scaffold.json). Only set this to override the auto-discovered channel.
530
534
  # Leave unset to disable transcript mirroring.
531
535
  #DISCOCLAW_VOICE_LOG_CHANNEL= # e.g. "voice-log" if using the default scaffold
532
- # Model for voice AI responses: tier (fast | capable) or concrete name (sonnet, opus, haiku).
536
+ # Model for voice AI responses: tier (fast | capable | deep) or concrete name (sonnet, opus, haiku).
533
537
  # Independent of RUNTIME_MODEL — allows tuning voice latency vs quality separately from chat.
534
538
  # Switchable at runtime via `modelSet voice <model>`.
535
539
  # Default: follows DISCOCLAW_FAST_MODEL (override here for voice-specific tuning).
@@ -545,8 +549,8 @@ DISCOCLAW_DISCORD_ACTIONS_IMAGEGEN=0
545
549
  # Deepgram TTS voice for speech synthesis (default: aura-2-asteria-en).
546
550
  # See https://developers.deepgram.com/docs/tts-models for available voices.
547
551
  #DEEPGRAM_TTS_VOICE=aura-2-asteria-en
548
- # Deepgram TTS playback speed (range: 0.5–1.5, default: 1.0).
552
+ # Deepgram TTS playback speed (range: 0.5–1.5, default: 1.3).
549
553
  # Values below 1.0 slow down speech; values above 1.0 speed it up.
550
- #DEEPGRAM_TTS_SPEED=1.0
554
+ #DEEPGRAM_TTS_SPEED=1.3
551
555
  # API key for Cartesia Sonic-3 TTS. Required when DISCOCLAW_TTS_PROVIDER=cartesia.
552
556
  #CARTESIA_API_KEY=
package/dist/config.js CHANGED
@@ -249,7 +249,7 @@ export function parseConfig(env) {
249
249
  const deepgramTtsSpeed = (() => {
250
250
  const raw = parseTrimmedString(env, 'DEEPGRAM_TTS_SPEED');
251
251
  if (raw == null)
252
- return undefined;
252
+ return 1.3;
253
253
  const n = parseFloat(raw);
254
254
  if (!Number.isFinite(n) || n < 0.5 || n > 1.5) {
255
255
  throw new Error(`DEEPGRAM_TTS_SPEED must be a number between 0.5 and 1.5, got "${raw}"`);
@@ -59,7 +59,7 @@ export async function autoTagCron(runtime, name, prompt, availableTags, opts) {
59
59
  // Model tier classification
60
60
  // ---------------------------------------------------------------------------
61
61
  /**
62
- * Classify whether a cron job needs capable-tier or can run on fast.
62
+ * Classify whether a cron job needs deep/capable-tier or can run on fast.
63
63
  *
64
64
  * Two-step logic:
65
65
  * 1. Cadence default: frequent/hourly (>1x/day) → fast immediately (cost optimization).
@@ -70,10 +70,11 @@ export async function classifyCronModel(runtime, name, prompt, cadence, opts) {
70
70
  if (cadence === 'frequent' || cadence === 'hourly') {
71
71
  return 'fast';
72
72
  }
73
- const classifyPrompt = `Does this scheduled task require advanced reasoning (complex analysis, ` +
74
- `multi-step planning, nuanced writing) or can it be handled with basic ` +
75
- `capabilities (simple lookups, templated responses, data formatting)?\n\n` +
76
- `Reply with ONLY one word: "capable" or "fast"\n\n` +
73
+ const classifyPrompt = `Does this scheduled task require heavy reasoning (complex analysis, ` +
74
+ `multi-step planning, deep code review), standard advanced capabilities ` +
75
+ `(nuanced writing, moderate analysis), or basic capabilities ` +
76
+ `(simple lookups, templated responses, data formatting)?\n\n` +
77
+ `Reply with ONLY one word: "deep", "capable", or "fast"\n\n` +
77
78
  `Job name: ${name}\n` +
78
79
  `Instruction: ${prompt.slice(0, 500)}`;
79
80
  let finalText = '';
@@ -96,5 +97,7 @@ export async function classifyCronModel(runtime, name, prompt, cadence, opts) {
96
97
  }
97
98
  }
98
99
  const output = (finalText || deltaText).trim().toLowerCase();
99
- return output === 'capable' ? 'capable' : 'fast';
100
+ if (output === 'capable' || output === 'deep')
101
+ return output;
102
+ return 'fast';
100
103
  }
@@ -9,6 +9,7 @@ vi.mock('./cron-sync.js', () => ({
9
9
  tagsApplied: 1,
10
10
  namesUpdated: 0,
11
11
  statusMessagesUpdated: 1,
12
+ promptMessagesCreated: 0,
12
13
  orphansDetected: 0,
13
14
  })),
14
15
  }));
@@ -40,7 +41,7 @@ describe('CronSyncCoordinator', () => {
40
41
  vi.resetAllMocks();
41
42
  mockReload.mockResolvedValue(2);
42
43
  mockRunCronSync.mockResolvedValue({
43
- tagsApplied: 1, namesUpdated: 0, statusMessagesUpdated: 1, orphansDetected: 0,
44
+ tagsApplied: 1, namesUpdated: 0, statusMessagesUpdated: 1, promptMessagesCreated: 0, orphansDetected: 0,
44
45
  });
45
46
  });
46
47
  it('reloads tag map before sync when tagMapPath is set', async () => {
@@ -69,7 +70,7 @@ describe('CronSyncCoordinator', () => {
69
70
  it('coalesced concurrent sync returns null', async () => {
70
71
  let resolveSync;
71
72
  mockRunCronSync.mockImplementation(() => new Promise((resolve) => {
72
- resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 });
73
+ resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
73
74
  }));
74
75
  const coordinator = new CronSyncCoordinator(makeOpts());
75
76
  const first = coordinator.sync();
@@ -85,10 +86,10 @@ describe('CronSyncCoordinator', () => {
85
86
  mockRunCronSync.mockImplementation(() => new Promise((resolve) => {
86
87
  callCount++;
87
88
  if (callCount === 1) {
88
- resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 });
89
+ resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
89
90
  }
90
91
  else {
91
- resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 });
92
+ resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 });
92
93
  }
93
94
  }));
94
95
  const coordinator = new CronSyncCoordinator(makeOpts());
@@ -1,3 +1,4 @@
1
+ import { EmbedBuilder } from 'discord.js';
1
2
  import { CADENCE_TAGS } from './run-stats.js';
2
3
  import { detectCadence } from './cadence.js';
3
4
  import { autoTagCron, classifyCronModel } from './auto-tag.js';
@@ -24,13 +25,14 @@ export async function runCronSync(opts) {
24
25
  const forum = await resolveForumChannel(client, forumId);
25
26
  if (!forum) {
26
27
  log?.warn({ forumId }, 'cron-sync: forum not found');
27
- return { tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 };
28
+ return { tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 };
28
29
  }
29
30
  const tagMap = opts.tagMap;
30
31
  const purposeTags = purposeTagNames(tagMap);
31
32
  let tagsApplied = 0;
32
33
  let namesUpdated = 0;
33
34
  let statusMessagesUpdated = 0;
35
+ let promptMessagesCreated = 0;
34
36
  let orphansDetected = 0;
35
37
  const asEditableCronThread = (value) => {
36
38
  if (!value || typeof value !== 'object')
@@ -172,6 +174,51 @@ export async function runCronSync(opts) {
172
174
  }
173
175
  await sleep(throttleMs);
174
176
  }
177
+ // Phase 3.5: Prompt message backfill.
178
+ for (const job of jobs) {
179
+ const fullJob = scheduler.getJob(job.id);
180
+ if (!fullJob?.cronId)
181
+ continue;
182
+ const record = statsStore.getRecord(fullJob.cronId);
183
+ if (!record?.prompt || record.promptMessageId)
184
+ continue;
185
+ try {
186
+ let thread = null;
187
+ const cached = client.channels.cache.get(fullJob.threadId);
188
+ if (cached && cached.isThread()) {
189
+ thread = cached;
190
+ }
191
+ else {
192
+ try {
193
+ const fetched = await client.channels.fetch(fullJob.threadId);
194
+ if (fetched && fetched.isThread())
195
+ thread = fetched;
196
+ }
197
+ catch {
198
+ // Thread may have been deleted.
199
+ }
200
+ }
201
+ if (!thread)
202
+ continue;
203
+ const embed = new EmbedBuilder()
204
+ .setTitle('\uD83D\uDCCB Cron Prompt')
205
+ .setDescription(record.prompt.slice(0, 4096))
206
+ .setColor(0x5865F2);
207
+ const msg = await thread.send({ embeds: [embed], allowedMentions: { parse: [] } });
208
+ try {
209
+ await msg.pin();
210
+ }
211
+ catch {
212
+ // Non-fatal if pin fails.
213
+ }
214
+ await statsStore.upsertRecord(record.cronId, record.threadId, { promptMessageId: msg.id });
215
+ promptMessagesCreated++;
216
+ }
217
+ catch (err) {
218
+ log?.warn({ err, cronId: fullJob.cronId }, 'cron-sync:phase3.5 prompt message failed');
219
+ }
220
+ await sleep(throttleMs);
221
+ }
175
222
  // Phase 4: Orphan detection (non-destructive, log only).
176
223
  for (const thread of threads.values()) {
177
224
  const editableThread = asEditableCronThread(thread);
@@ -184,6 +231,6 @@ export async function runCronSync(opts) {
184
231
  log?.warn({ threadId: editableThread.id, name: editableThread.name }, 'cron-sync:phase4 orphan thread (no registered job)');
185
232
  }
186
233
  }
187
- log?.info({ tagsApplied, namesUpdated, statusMessagesUpdated, orphansDetected }, 'cron-sync: complete');
188
- return { tagsApplied, namesUpdated, statusMessagesUpdated, orphansDetected };
234
+ log?.info({ tagsApplied, namesUpdated, statusMessagesUpdated, promptMessagesCreated, orphansDetected }, 'cron-sync: complete');
235
+ return { tagsApplied, namesUpdated, statusMessagesUpdated, promptMessagesCreated, orphansDetected };
189
236
  }
@@ -12,7 +12,7 @@ function mockLog() {
12
12
  }
13
13
  function makeCoordinator() {
14
14
  return {
15
- sync: vi.fn(async () => ({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 })),
15
+ sync: vi.fn(async () => ({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, promptMessagesCreated: 0, orphansDetected: 0 })),
16
16
  };
17
17
  }
18
18
  describe('startCronTagMapWatcher', () => {
@@ -1,5 +1,6 @@
1
1
  import { acquireCronLock, releaseCronLock } from './job-lock.js';
2
2
  import { resolveChannel } from '../discord/action-utils.js';
3
+ import { DiscordTransportClient } from '../discord/transport-client.js';
3
4
  import * as discordActions from '../discord/actions.js';
4
5
  import { sendChunks, appendUnavailableActionTypesNotice, appendParseFailureNotice } from '../discord/output-common.js';
5
6
  import { buildPromptPreamble, loadWorkspacePaFiles, inlineContextFiles, resolveEffectiveTools } from '../discord/prompt-common.js';
@@ -253,6 +254,7 @@ export async function executeCronJob(job, ctx) {
253
254
  channelId: targetChannel.id,
254
255
  messageId: '',
255
256
  deferScheduler: ctx.deferScheduler,
257
+ transport: new DiscordTransportClient(guild, ctx.client),
256
258
  confirmation: {
257
259
  mode: 'automated',
258
260
  },
@@ -270,11 +272,8 @@ export async function executeCronJob(job, ctx) {
270
272
  metrics.recordActionResult(result.ok);
271
273
  ctx.log?.info({ flow: 'cron', jobId: job.id, ok: result.ok }, 'obs.action.result');
272
274
  }
273
- const displayLines = discordActions.buildDisplayResultLines(actions, results);
274
275
  const anyActionSucceeded = results.some((r) => r.ok);
275
- processedText = displayLines.length > 0
276
- ? cleanText.trimEnd() + '\n\n' + displayLines.join('\n')
277
- : cleanText.trimEnd();
276
+ processedText = discordActions.appendActionResults(cleanText.trimEnd(), actions, results);
278
277
  // When all display lines were suppressed and there's no prose, skip posting.
279
278
  if (!processedText.trim() && anyActionSucceeded && strippedUnrecognizedTypes.length === 0 && parseFailuresCount === 0) {
280
279
  ctx.log?.info({ jobId: job.id }, 'cron:reply suppressed (actions-only, no display text)');
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
5
5
  // Types
6
6
  // ---------------------------------------------------------------------------
7
7
  export const CADENCE_TAGS = ['yearly', 'frequent', 'hourly', 'daily', 'weekly', 'monthly'];
8
- export const CURRENT_VERSION = 7;
8
+ export const CURRENT_VERSION = 8;
9
9
  // ---------------------------------------------------------------------------
10
10
  // Stable Cron ID generation
11
11
  // ---------------------------------------------------------------------------
@@ -313,5 +313,9 @@ export async function loadRunStats(filePath) {
313
313
  if (store.version === 6) {
314
314
  store.version = 7;
315
315
  }
316
+ // Migrate v7 → v8: no-op — new field (promptMessageId) is optional and defaults to absent.
317
+ if (store.version === 7) {
318
+ store.version = 8;
319
+ }
316
320
  return new CronRunStats(store, filePath);
317
321
  }
@@ -38,7 +38,7 @@ describe('CronRunStats', () => {
38
38
  it('creates empty store on missing file', async () => {
39
39
  const stats = await loadRunStats(statsPath);
40
40
  const store = stats.getStore();
41
- expect(store.version).toBe(7);
41
+ expect(store.version).toBe(8);
42
42
  expect(Object.keys(store.jobs)).toHaveLength(0);
43
43
  });
44
44
  it('upserts and retrieves records by cronId', async () => {
@@ -244,7 +244,7 @@ describe('CronRunStats', () => {
244
244
  describe('emptyStore', () => {
245
245
  it('returns valid initial structure', () => {
246
246
  const store = emptyStore();
247
- expect(store.version).toBe(7);
247
+ expect(store.version).toBe(8);
248
248
  expect(store.updatedAt).toBeGreaterThan(0);
249
249
  expect(Object.keys(store.jobs)).toHaveLength(0);
250
250
  });
@@ -271,7 +271,7 @@ describe('loadRunStats version migration', () => {
271
271
  };
272
272
  await fs.writeFile(statsPath, JSON.stringify(v3Store), 'utf-8');
273
273
  const stats = await loadRunStats(statsPath);
274
- expect(stats.getStore().version).toBe(7);
274
+ expect(stats.getStore().version).toBe(8);
275
275
  const rec = stats.getRecord('cron-migrated');
276
276
  expect(rec).toBeDefined();
277
277
  expect(rec.cronId).toBe('cron-migrated');
@@ -301,7 +301,7 @@ describe('loadRunStats version migration', () => {
301
301
  };
302
302
  await fs.writeFile(statsPath, JSON.stringify(v4Store), 'utf-8');
303
303
  const stats = await loadRunStats(statsPath);
304
- expect(stats.getStore().version).toBe(7);
304
+ expect(stats.getStore().version).toBe(8);
305
305
  const rec = stats.getRecord('cron-v4');
306
306
  expect(rec).toBeDefined();
307
307
  expect(rec.cronId).toBe('cron-v4');
@@ -331,7 +331,7 @@ describe('loadRunStats version migration', () => {
331
331
  };
332
332
  await fs.writeFile(statsPath, JSON.stringify(v5Store), 'utf-8');
333
333
  const stats = await loadRunStats(statsPath);
334
- expect(stats.getStore().version).toBe(7);
334
+ expect(stats.getStore().version).toBe(8);
335
335
  const rec = stats.getRecord('cron-v5');
336
336
  expect(rec).toBeDefined();
337
337
  expect(rec.cronId).toBe('cron-v5');
@@ -367,7 +367,7 @@ describe('loadRunStats version migration', () => {
367
367
  };
368
368
  await fs.writeFile(statsPath, JSON.stringify(v6Store), 'utf-8');
369
369
  const stats = await loadRunStats(statsPath);
370
- expect(stats.getStore().version).toBe(7);
370
+ expect(stats.getStore().version).toBe(8);
371
371
  const rec = stats.getRecord('cron-v6');
372
372
  expect(rec).toBeDefined();
373
373
  expect(rec.cronId).toBe('cron-v6');
@@ -297,7 +297,7 @@ export function configActionsPromptSection() {
297
297
  <discord-action>{"type":"modelSet","role":"fast","model":"haiku"}</discord-action>
298
298
  \`\`\`
299
299
  - \`role\` (required): One of \`chat\`, \`fast\`, \`forge-drafter\`, \`forge-auditor\`, \`summary\`, \`cron\`, \`cron-exec\`, \`voice\`.
300
- - \`model\` (required): Model tier (\`fast\`, \`capable\`), concrete model name (\`haiku\`, \`sonnet\`, \`opus\`), runtime name (\`openrouter\`, \`gemini\` — for \`chat\` role, swaps the active runtime adapter), or \`default\` (for cron-exec only, to revert to the env-configured default (Sonnet by default)).
300
+ - \`model\` (required): Model tier (\`fast\`, \`capable\`, \`deep\`), concrete model name (\`haiku\`, \`sonnet\`, \`opus\`), runtime name (\`openrouter\`, \`gemini\` — for \`chat\` role, swaps the active runtime adapter), or \`default\` (for cron-exec only, to revert to the env-configured default (Sonnet by default)).
301
301
 
302
302
  **Roles:**
303
303
  | Role | What it controls |
@@ -1,3 +1,4 @@
1
+ import { EmbedBuilder } from 'discord.js';
1
2
  import { Cron } from 'croner';
2
3
  import { CADENCE_TAGS, generateCronId } from '../cron/run-stats.js';
3
4
  import { detectCadence } from '../cron/cadence.js';
@@ -56,7 +57,10 @@ const ALL_KNOWN_ACTION_TYPES = new Set([
56
57
  // Helpers
57
58
  // ---------------------------------------------------------------------------
58
59
  function buildStarterContent(schedule, timezone, channel, prompt) {
59
- return `**Schedule:** \`${schedule}\` (${timezone})\n**Channel:** #${channel}\n\n${prompt}`;
60
+ const truncatedPrompt = prompt.length > 200
61
+ ? `${prompt.slice(0, 200)}… *(full prompt pinned below)*`
62
+ : prompt;
63
+ return `**Schedule:** \`${schedule}\` (${timezone})\n**Channel:** #${channel}\n\n${truncatedPrompt}`;
60
64
  }
61
65
  function validateCronDefinition(def) {
62
66
  const timezone = String(def.timezone ?? '').trim();
@@ -211,6 +215,22 @@ export async function executeCronAction(action, ctx, cronCtx) {
211
215
  await ensureStatusMessage(cronCtx.client, thread.id, cronId, record, cronCtx.statsStore, { log: cronCtx.log });
212
216
  }
213
217
  catch { }
218
+ // Post pinned prompt message (embed) so the full prompt is always retrievable.
219
+ try {
220
+ const embed = new EmbedBuilder()
221
+ .setTitle('\uD83D\uDCCB Cron Prompt')
222
+ .setDescription(action.prompt.slice(0, 4096))
223
+ .setColor(0x5865F2);
224
+ const promptMsg = await thread.send({ embeds: [embed], allowedMentions: { parse: [] } });
225
+ try {
226
+ await promptMsg.pin();
227
+ }
228
+ catch { /* non-fatal */ }
229
+ await cronCtx.statsStore.upsertRecord(cronId, thread.id, { promptMessageId: promptMsg.id });
230
+ }
231
+ catch (err) {
232
+ cronCtx.log?.warn({ err, cronId }, 'cron:action:create prompt message failed');
233
+ }
214
234
  cronCtx.forumCountSync?.requestUpdate();
215
235
  return { ok: true, summary: `Cron "${action.name}" created (${cronId}), schedule: ${action.schedule}, model: ${model}${action.routingMode ? `, routing: ${action.routingMode}` : ''}` };
216
236
  }
@@ -336,6 +356,50 @@ export async function executeCronAction(action, ctx, cronCtx) {
336
356
  }
337
357
  }
338
358
  catch { }
359
+ // Update or create the pinned prompt message when prompt changes.
360
+ if (action.prompt !== undefined) {
361
+ try {
362
+ const updatedRecord = cronCtx.statsStore.getRecord(action.cronId);
363
+ const embed = new EmbedBuilder()
364
+ .setTitle('\uD83D\uDCCB Cron Prompt')
365
+ .setDescription(newPrompt.slice(0, 4096))
366
+ .setColor(0x5865F2);
367
+ if (updatedRecord?.promptMessageId) {
368
+ // Try to edit the existing prompt message.
369
+ const thread = cronCtx.client.channels.cache.get(record.threadId);
370
+ if (thread && thread.isThread()) {
371
+ try {
372
+ const existing = await thread.messages.fetch(updatedRecord.promptMessageId);
373
+ await existing.edit({ embeds: [embed], allowedMentions: { parse: [] } });
374
+ }
375
+ catch {
376
+ // Message may have been deleted; create a new one.
377
+ const msg = await thread.send({ embeds: [embed], allowedMentions: { parse: [] } });
378
+ try {
379
+ await msg.pin();
380
+ }
381
+ catch { /* non-fatal */ }
382
+ await cronCtx.statsStore.upsertRecord(action.cronId, record.threadId, { promptMessageId: msg.id });
383
+ }
384
+ }
385
+ }
386
+ else {
387
+ // No existing prompt message — create one.
388
+ const thread = cronCtx.client.channels.cache.get(record.threadId);
389
+ if (thread && thread.isThread()) {
390
+ const msg = await thread.send({ embeds: [embed], allowedMentions: { parse: [] } });
391
+ try {
392
+ await msg.pin();
393
+ }
394
+ catch { /* non-fatal */ }
395
+ await cronCtx.statsStore.upsertRecord(action.cronId, record.threadId, { promptMessageId: msg.id });
396
+ }
397
+ }
398
+ }
399
+ catch (err) {
400
+ cronCtx.log?.warn({ err, cronId: action.cronId }, 'cron:action:update prompt message failed');
401
+ }
402
+ }
339
403
  // Update thread tags if needed.
340
404
  if (action.tags !== undefined || action.schedule !== undefined) {
341
405
  try {
@@ -422,10 +486,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
422
486
  lines.push(`Allowed actions: ${record.allowedActions.join(', ')}`);
423
487
  if (record.lastErrorMessage)
424
488
  lines.push(`Last error: ${record.lastErrorMessage}`);
425
- if (job) {
426
- const promptText = job.def.prompt;
427
- const truncated = promptText.length > 500 ? `${promptText.slice(0, 500)}... (truncated)` : promptText;
428
- lines.push(`Prompt: ${truncated}`);
489
+ // Return full prompt text — prefer the persisted record prompt (always full),
490
+ // falling back to the scheduler def (also full).
491
+ const promptText = record.prompt ?? job?.def.prompt;
492
+ if (promptText) {
493
+ lines.push(`Prompt: ${promptText}`);
429
494
  }
430
495
  return { ok: true, summary: lines.join('\n') };
431
496
  }
@@ -580,7 +645,7 @@ export async function executeCronAction(action, ctx, cronCtx) {
580
645
  }
581
646
  return {
582
647
  ok: true,
583
- summary: `Cron sync complete: ${result.tagsApplied} tags, ${result.namesUpdated} names, ${result.statusMessagesUpdated} status msgs, ${result.orphansDetected} orphans`,
648
+ summary: `Cron sync complete: ${result.tagsApplied} tags, ${result.namesUpdated} names, ${result.statusMessagesUpdated} status msgs, ${result.promptMessagesCreated} prompt msgs, ${result.orphansDetected} orphans`,
584
649
  };
585
650
  }
586
651
  else {
@@ -605,7 +670,7 @@ export async function executeCronAction(action, ctx, cronCtx) {
605
670
  cronCtx.forumCountSync?.requestUpdate();
606
671
  return {
607
672
  ok: true,
608
- summary: `Cron sync complete: ${result.tagsApplied} tags, ${result.namesUpdated} names, ${result.statusMessagesUpdated} status msgs, ${result.orphansDetected} orphans`,
673
+ summary: `Cron sync complete: ${result.tagsApplied} tags, ${result.namesUpdated} names, ${result.statusMessagesUpdated} status msgs, ${result.promptMessagesCreated} prompt msgs, ${result.orphansDetected} orphans`,
609
674
  };
610
675
  }
611
676
  }
@@ -655,7 +720,7 @@ export function cronActionsPromptSection() {
655
720
  - \`prompt\` (required): The instruction text.
656
721
  - \`timezone\` (optional, default: system timezone, or DEFAULT_TIMEZONE env if set): IANA timezone.
657
722
  - \`tags\` (optional): Comma-separated purpose tags.
658
- - \`model\` (optional): "fast" or "capable" (auto-classified if omitted).
723
+ - \`model\` (optional): "fast", "capable", or "deep" (auto-classified if omitted).
659
724
  - \`routingMode\` (optional): Set to \`"json"\` to enable JSON routing mode. In this mode the executor uses the JSON router to dispatch structured responses. The prompt may contain \`{{channel}}\` and \`{{channelId}}\` placeholders which are expanded to the target channel name and ID at runtime.
660
725
  - \`allowedActions\` (optional): Comma-separated list of Discord action types this job may emit (e.g., "cronList,cronShow"). Restricts the AI to only these action types during execution. Rejects unrecognized type names. Requires at least one entry if provided.
661
726