discoclaw 0.2.2 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.context/pa.md CHANGED
@@ -16,7 +16,6 @@ For architecture details, see `.context/architecture.md`.
16
16
  | `USER.md` | Who you're helping | Every prompt |
17
17
  | `AGENTS.md` | Your personal rules and conventions | Every prompt |
18
18
  | `TOOLS.md` | Available tools and integrations | Every prompt |
19
- | `HEARTBEAT.md` | Periodic self-check template | By cron |
20
19
  | `MEMORY.md` | Curated long-term memory | DM prompts |
21
20
  | `BOOTSTRAP.md` | First-run onboarding (deleted after) | Once |
22
21
 
@@ -27,6 +26,7 @@ Templates live in `templates/workspace/` and are scaffolded on first run (copy-i
27
26
  - **Never go silent.** Acknowledge before tool calls.
28
27
  - Narrate failures and pivots.
29
28
  - Summarize outcomes; don't assume the user saw tool output.
29
+ - **Never edit `tasks.jsonl`, cron store files, or other data files directly.** Always use the corresponding discord action (`taskUpdate`, `taskCreate`, `cronUpdate`, etc.). Direct file edits bypass Discord thread sync and leave the UI stale.
30
30
 
31
31
  ## Discord Formatting
32
32
 
@@ -68,6 +68,7 @@ export function renderSystemdUnit(packageRoot, cwd) {
68
68
  '',
69
69
  '[Service]',
70
70
  'Type=simple',
71
+ 'Environment=PATH=%h/.local/bin:%h/.npm-global/bin:/usr/local/bin:/usr/bin:/bin',
71
72
  `ExecStart=/usr/bin/node ${entryPoint}`,
72
73
  `WorkingDirectory=${cwd}`,
73
74
  `EnvironmentFile=${path.join(cwd, '.env')}`,
@@ -76,6 +76,12 @@ describe('renderSystemdUnit', () => {
76
76
  const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
77
77
  expect(unit).toContain(`EnvironmentFile=${CWD}/.env`);
78
78
  });
79
+ it('includes Environment=PATH with user-local bin directories', () => {
80
+ const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
81
+ expect(unit).toContain('Environment=PATH=');
82
+ expect(unit).toContain('%h/.local/bin');
83
+ expect(unit).toContain('%h/.npm-global/bin');
84
+ });
79
85
  it('includes standard unit and install sections', () => {
80
86
  const unit = renderSystemdUnit(PACKAGE_ROOT, CWD);
81
87
  expect(unit).toContain('[Unit]');
@@ -124,6 +124,10 @@ export async function executeCronJob(job, ctx) {
124
124
  if (preRunRecord) {
125
125
  effectiveModel = preRunRecord.modelOverride ?? preRunRecord.model ?? cronDefault;
126
126
  }
127
+ // Silent mode: instruct the AI to respond with HEARTBEAT_OK when idle.
128
+ if (preRunRecord?.silent) {
129
+ prompt += '\n\nIMPORTANT: If there is nothing actionable to report, respond with exactly `HEARTBEAT_OK` and nothing else.';
130
+ }
127
131
  ctx.log?.info({ jobId: job.id, name: job.name, channel: job.def.channel, model: effectiveModel, permissionTier: tools.permissionTier }, 'cron:exec start');
128
132
  // Best-effort: update pinned status message to show running indicator.
129
133
  if (preRunRecord && job.cronId) {
@@ -269,13 +273,44 @@ export async function executeCronJob(job, ctx) {
269
273
  }
270
274
  processedText = appendUnavailableActionTypesNotice(processedText, strippedUnrecognizedTypes);
271
275
  processedText = appendParseFailureNotice(processedText, parseFailuresCount);
276
+ // Suppress sentinel outputs (e.g. crons whose prompts say "output nothing if idle").
277
+ // Mirrors the reaction handler's logic at reaction-handler.ts:662-674.
278
+ const strippedText = processedText.replace(/\s+/g, ' ').trim();
279
+ const isSuppressible = strippedText === 'HEARTBEAT_OK' || strippedText === '(no output)';
280
+ if (isSuppressible && collectedImages.length === 0) {
281
+ ctx.log?.info({ jobId: job.id, name: job.name, sentinel: strippedText }, 'cron:exec sentinel output suppressed');
282
+ if (ctx.statsStore && job.cronId) {
283
+ try {
284
+ await ctx.statsStore.recordRun(job.cronId, 'success');
285
+ }
286
+ catch {
287
+ // Best-effort.
288
+ }
289
+ }
290
+ metrics.increment('cron.run.success');
291
+ return;
292
+ }
293
+ // Silent-mode short-response gate: suppress paraphrased "nothing to report" responses.
294
+ if (preRunRecord?.silent && collectedImages.length === 0 && strippedText.length <= 80) {
295
+ ctx.log?.info({ jobId: job.id, name: job.name, len: strippedText.length }, 'cron:exec silent short-response suppressed');
296
+ if (ctx.statsStore && job.cronId) {
297
+ try {
298
+ await ctx.statsStore.recordRun(job.cronId, 'success');
299
+ }
300
+ catch {
301
+ // Best-effort.
302
+ }
303
+ }
304
+ metrics.increment('cron.run.success');
305
+ return;
306
+ }
272
307
  await sendChunks(channelForSend, processedText, collectedImages);
273
308
  ctx.log?.info({ jobId: job.id, name: job.name, channel: job.def.channel }, 'cron:exec done');
309
+ metrics.increment('cron.run.success');
274
310
  // Record successful run.
275
311
  if (ctx.statsStore && job.cronId) {
276
312
  try {
277
313
  await ctx.statsStore.recordRun(job.cronId, 'success');
278
- metrics.increment('cron.run.success');
279
314
  }
280
315
  catch (statsErr) {
281
316
  ctx.log?.warn({ err: statsErr, jobId: job.id }, 'cron:exec stats record failed');
@@ -324,6 +324,55 @@ describe('executeCronJob', () => {
324
324
  expect(subsArg).toMatchObject({ imagegenCtx });
325
325
  executeDiscordActionsSpy.mockRestore();
326
326
  });
327
+ it('suppresses HEARTBEAT_OK output', async () => {
328
+ const ctx = makeCtx({ runtime: makeMockRuntime('HEARTBEAT_OK') });
329
+ const job = makeJob();
330
+ await executeCronJob(job, ctx);
331
+ const guild = ctx.client.guilds.cache.get('guild-1');
332
+ const channel = guild.channels.cache.get('general');
333
+ expect(channel.send).not.toHaveBeenCalled();
334
+ expect(ctx.log?.info).toHaveBeenCalledWith(expect.objectContaining({ jobId: job.id, sentinel: 'HEARTBEAT_OK' }), 'cron:exec sentinel output suppressed');
335
+ });
336
+ it('suppresses (no output) output', async () => {
337
+ const ctx = makeCtx({ runtime: makeMockRuntime('(no output)') });
338
+ const job = makeJob();
339
+ await executeCronJob(job, ctx);
340
+ const guild = ctx.client.guilds.cache.get('guild-1');
341
+ const channel = guild.channels.cache.get('general');
342
+ expect(channel.send).not.toHaveBeenCalled();
343
+ });
344
+ it('does not suppress HEARTBEAT_OK when images are present', async () => {
345
+ const runtime = {
346
+ id: 'claude_code',
347
+ capabilities: new Set(['streaming_text']),
348
+ async *invoke() {
349
+ yield { type: 'text_final', text: 'HEARTBEAT_OK' };
350
+ yield { type: 'image_data', image: { mediaType: 'image/png', base64: 'abc123' } };
351
+ yield { type: 'done' };
352
+ },
353
+ };
354
+ const ctx = makeCtx({ runtime });
355
+ const job = makeJob();
356
+ await executeCronJob(job, ctx);
357
+ const guild = ctx.client.guilds.cache.get('guild-1');
358
+ const channel = guild.channels.cache.get('general');
359
+ expect(channel.send).toHaveBeenCalled();
360
+ });
361
+ it('records success in statsStore when sentinel is suppressed', async () => {
362
+ const statsStore = {
363
+ recordRun: vi.fn().mockResolvedValue(undefined),
364
+ recordRunStart: vi.fn().mockResolvedValue(undefined),
365
+ getRecord: vi.fn().mockReturnValue(undefined),
366
+ upsertRecord: vi.fn().mockResolvedValue(undefined),
367
+ };
368
+ const ctx = makeCtx({ runtime: makeMockRuntime('HEARTBEAT_OK'), statsStore });
369
+ const job = makeJob();
370
+ await executeCronJob(job, ctx);
371
+ expect(statsStore.recordRun).toHaveBeenCalledWith('cron-test0001', 'success');
372
+ const guild = ctx.client.guilds.cache.get('guild-1');
373
+ const channel = guild.channels.cache.get('general');
374
+ expect(channel.send).not.toHaveBeenCalled();
375
+ });
327
376
  it('does not post if output is empty', async () => {
328
377
  const ctx = makeCtx({ runtime: makeMockRuntime('') });
329
378
  const job = makeJob();
@@ -730,3 +779,111 @@ describe('executeCronJob write-ahead status tracking', () => {
730
779
  expect(rec?.lastRunStatus).toBe('success');
731
780
  });
732
781
  });
782
+ // ---------------------------------------------------------------------------
783
+ // Silent mode suppression
784
+ // ---------------------------------------------------------------------------
785
+ describe('executeCronJob silent mode', () => {
786
+ let statsDir;
787
+ beforeEach(async () => {
788
+ statsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'executor-silent-'));
789
+ });
790
+ afterEach(async () => {
791
+ await fs.rm(statsDir, { recursive: true, force: true });
792
+ });
793
+ function makeCapturingRuntime(response) {
794
+ const invokeSpy = vi.fn();
795
+ return {
796
+ runtime: {
797
+ id: 'claude_code',
798
+ capabilities: new Set(['streaming_text']),
799
+ async *invoke(params) {
800
+ invokeSpy(params);
801
+ yield { type: 'text_final', text: response };
802
+ yield { type: 'done' };
803
+ },
804
+ },
805
+ invokeSpy,
806
+ };
807
+ }
808
+ it('injects HEARTBEAT_OK instruction into the prompt when silent is true', async () => {
809
+ const statsPath = path.join(statsDir, 'stats.json');
810
+ const statsStore = await loadRunStats(statsPath);
811
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
812
+ const { runtime, invokeSpy } = makeCapturingRuntime('Hello!');
813
+ const ctx = makeCtx({ statsStore, runtime });
814
+ const job = makeJob();
815
+ await executeCronJob(job, ctx);
816
+ expect(invokeSpy).toHaveBeenCalledOnce();
817
+ const prompt = invokeSpy.mock.calls[0][0].prompt;
818
+ expect(prompt).toContain('respond with exactly `HEARTBEAT_OK`');
819
+ });
820
+ it('suppresses short responses under the threshold when silent is true', async () => {
821
+ const statsPath = path.join(statsDir, 'stats.json');
822
+ const statsStore = await loadRunStats(statsPath);
823
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
824
+ const ctx = makeCtx({ statsStore, runtime: makeMockRuntime('No task-labeled emails found.') });
825
+ const job = makeJob();
826
+ await executeCronJob(job, ctx);
827
+ const guild = ctx.client.guilds.cache.get('guild-1');
828
+ const channel = guild.channels.cache.get('general');
829
+ expect(channel.send).not.toHaveBeenCalled();
830
+ expect(ctx.log?.info).toHaveBeenCalledWith(expect.objectContaining({ jobId: job.id, name: job.name }), 'cron:exec silent short-response suppressed');
831
+ });
832
+ it('does NOT suppress longer substantive responses when silent is true', async () => {
833
+ const statsPath = path.join(statsDir, 'stats.json');
834
+ const statsStore = await loadRunStats(statsPath);
835
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
836
+ const longResponse = 'Here is a detailed summary of the tasks completed today, including several important updates that need your attention.';
837
+ const ctx = makeCtx({ statsStore, runtime: makeMockRuntime(longResponse) });
838
+ const job = makeJob();
839
+ await executeCronJob(job, ctx);
840
+ const guild = ctx.client.guilds.cache.get('guild-1');
841
+ const channel = guild.channels.cache.get('general');
842
+ expect(channel.send).toHaveBeenCalledOnce();
843
+ expect(channel.send.mock.calls[0][0].content).toContain(longResponse);
844
+ });
845
+ it('does not apply short-response gate when silent is false', async () => {
846
+ const statsPath = path.join(statsDir, 'stats.json');
847
+ const statsStore = await loadRunStats(statsPath);
848
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: false });
849
+ const ctx = makeCtx({ statsStore, runtime: makeMockRuntime('No emails found.') });
850
+ const job = makeJob();
851
+ await executeCronJob(job, ctx);
852
+ const guild = ctx.client.guilds.cache.get('guild-1');
853
+ const channel = guild.channels.cache.get('general');
854
+ expect(channel.send).toHaveBeenCalledOnce();
855
+ });
856
+ it('does NOT suppress short text when images are present (silent mode)', async () => {
857
+ const statsPath = path.join(statsDir, 'stats.json');
858
+ const statsStore = await loadRunStats(statsPath);
859
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
860
+ const runtime = {
861
+ id: 'claude_code',
862
+ capabilities: new Set(['streaming_text']),
863
+ async *invoke() {
864
+ yield { type: 'text_final', text: 'Short.' };
865
+ yield { type: 'image_data', image: { mediaType: 'image/png', base64: 'abc123' } };
866
+ yield { type: 'done' };
867
+ },
868
+ };
869
+ const ctx = makeCtx({ statsStore, runtime });
870
+ const job = makeJob();
871
+ await executeCronJob(job, ctx);
872
+ const guild = ctx.client.guilds.cache.get('guild-1');
873
+ const channel = guild.channels.cache.get('general');
874
+ expect(channel.send).toHaveBeenCalled();
875
+ });
876
+ it('records success in statsStore when silent short-response is suppressed', async () => {
877
+ const statsPath = path.join(statsDir, 'stats.json');
878
+ const statsStore = await loadRunStats(statsPath);
879
+ await statsStore.upsertRecord('cron-test0001', 'thread-1', { silent: true });
880
+ const recordRunSpy = vi.spyOn(statsStore, 'recordRun');
881
+ const ctx = makeCtx({ statsStore, runtime: makeMockRuntime('Nothing to report.') });
882
+ const job = makeJob();
883
+ await executeCronJob(job, ctx);
884
+ expect(recordRunSpy).toHaveBeenCalledWith('cron-test0001', 'success');
885
+ const guild = ctx.client.guilds.cache.get('guild-1');
886
+ const channel = guild.channels.cache.get('general');
887
+ expect(channel.send).not.toHaveBeenCalled();
888
+ });
889
+ });
@@ -148,6 +148,12 @@ async function loadThreadAsCron(thread, guildId, scheduler, runtime, opts) {
148
148
  cadence,
149
149
  // Preserve existing disabled state.
150
150
  disabled: existingRecord?.disabled ?? false,
151
+ // Persist parsed definition so future boots skip AI re-parsing.
152
+ schedule: def.schedule,
153
+ timezone: def.timezone,
154
+ channel: def.channel,
155
+ prompt: def.prompt,
156
+ authorId: starterAuthorId,
151
157
  });
152
158
  // Restore disabled state from stats.
153
159
  if (existingRecord?.disabled) {
@@ -207,6 +213,47 @@ export async function initCronForum(opts) {
207
213
  for (const thread of activeThreads.values()) {
208
214
  if (thread.archived)
209
215
  continue;
216
+ // Fast path: if the stats store already has a parsed definition for this
217
+ // thread, reconstruct the ParsedCronDef and register directly — skipping
218
+ // the AI parser entirely. This avoids wasted AI calls on every boot and
219
+ // prevents parse-failure errors on threads whose starters aren't parseable
220
+ // (e.g. bot-formatted messages created by cronCreate).
221
+ if (statsStore) {
222
+ const record = statsStore.getRecordByThreadId(thread.id);
223
+ if (record && record.channel && record.prompt &&
224
+ (record.schedule || (record.triggerType !== undefined && record.triggerType !== 'schedule'))) {
225
+ // Authorization: verify stored author is still permitted.
226
+ const authorId = record.authorId ?? '';
227
+ const botUserId = client.user?.id ?? '';
228
+ const isBotAuthored = botUserId !== '' && authorId === botUserId;
229
+ if (!authorId || (!allowUserIds.has(authorId) && !isBotAuthored)) {
230
+ // Author not authorized — fall through to loadThreadAsCron which
231
+ // will reject and disable through its existing path.
232
+ }
233
+ else {
234
+ const def = {
235
+ triggerType: record.triggerType ?? 'schedule',
236
+ schedule: record.schedule,
237
+ timezone: record.timezone ?? 'UTC',
238
+ channel: record.channel,
239
+ prompt: record.prompt,
240
+ };
241
+ try {
242
+ scheduler.register(thread.id, thread.id, guildId, thread.name, def, record.cronId);
243
+ if (record.disabled) {
244
+ scheduler.disable(thread.id);
245
+ log?.info({ threadId: thread.id, cronId: record.cronId }, 'cron:forum fast-path restored disabled state');
246
+ }
247
+ loaded++;
248
+ log?.info({ threadId: thread.id, cronId: record.cronId }, 'cron:forum fast-path loaded from stored definition');
249
+ continue;
250
+ }
251
+ catch (err) {
252
+ log?.warn({ err, threadId: thread.id, cronId: record.cronId }, 'cron:forum fast-path register failed, falling through to AI parse');
253
+ }
254
+ }
255
+ }
256
+ }
210
257
  const ok = await loadThreadAsCron(thread, guildId, scheduler, runtime, { cronModel, cwd, log, isNew: false, allowUserIds, statsStore });
211
258
  if (ok)
212
259
  loaded++;
@@ -310,6 +310,240 @@ describe('initCronForum', () => {
310
310
  });
311
311
  expect(scheduler.register).toHaveBeenCalledWith('thread-1', 'thread-1', 'guild-1', 'Job 1', expect.objectContaining({ schedule: '0 7 * * *' }), 'cron-from-status-id');
312
312
  });
313
+ it('skips AI parse when stats store has stored definition (fast path)', async () => {
314
+ const thread = makeThread();
315
+ // fetchStarterMessage should NOT be called — fast path bypasses loadThreadAsCron entirely.
316
+ thread.fetchStarterMessage.mockResolvedValue(null);
317
+ const forum = makeForum([thread]);
318
+ const client = makeClient(forum);
319
+ const scheduler = makeScheduler();
320
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
321
+ const statsStore = {
322
+ getRecordByThreadId: vi.fn().mockReturnValue({
323
+ cronId: 'cron-stored',
324
+ threadId: 'thread-1',
325
+ disabled: false,
326
+ schedule: '0 7 * * *',
327
+ timezone: 'America/New_York',
328
+ channel: 'general',
329
+ prompt: 'Say good morning.',
330
+ authorId: 'u-allowed',
331
+ triggerType: 'schedule',
332
+ }),
333
+ getRecord: vi.fn(),
334
+ upsertRecord: vi.fn(async () => ({})),
335
+ };
336
+ await initCronForum({
337
+ client: client,
338
+ forumChannelNameOrId: 'forum-1',
339
+ allowUserIds: new Set(['u-allowed']),
340
+ scheduler: scheduler,
341
+ runtime: {},
342
+ cronModel: 'haiku',
343
+ cwd: '/tmp',
344
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
345
+ statsStore: statsStore,
346
+ });
347
+ // AI parser should never be called.
348
+ expect(parseCronDefinition).not.toHaveBeenCalled();
349
+ // scheduler.register should receive the stored values.
350
+ expect(scheduler.register).toHaveBeenCalledWith('thread-1', 'thread-1', 'guild-1', 'Job 1', expect.objectContaining({
351
+ triggerType: 'schedule',
352
+ schedule: '0 7 * * *',
353
+ timezone: 'America/New_York',
354
+ channel: 'general',
355
+ prompt: 'Say good morning.',
356
+ }), 'cron-stored');
357
+ // fetchStarterMessage should not have been called (fast path skips loadThreadAsCron).
358
+ expect(thread.fetchStarterMessage).not.toHaveBeenCalled();
359
+ });
360
+ it('falls through to AI parse when stats store record lacks definition fields', async () => {
361
+ const thread = makeThread();
362
+ thread.fetchStarterMessage.mockResolvedValue({
363
+ id: 'm1',
364
+ content: 'every day at 7am post to #general say hello',
365
+ author: { id: 'u-allowed' },
366
+ react: vi.fn().mockResolvedValue(undefined),
367
+ });
368
+ thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
369
+ const forum = makeForum([thread]);
370
+ const client = makeClient(forum);
371
+ const scheduler = makeScheduler();
372
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
373
+ vi.mocked(parseCronDefinition).mockResolvedValue({
374
+ triggerType: 'schedule',
375
+ schedule: '0 7 * * *',
376
+ timezone: 'UTC',
377
+ channel: 'general',
378
+ prompt: 'Say hello.',
379
+ });
380
+ // Record exists but has no stored definition fields (pre-upgrade record).
381
+ const statsStore = {
382
+ getRecordByThreadId: vi.fn().mockReturnValue({
383
+ cronId: 'cron-old',
384
+ threadId: 'thread-1',
385
+ disabled: false,
386
+ }),
387
+ getRecord: vi.fn().mockReturnValue({
388
+ cronId: 'cron-old',
389
+ threadId: 'thread-1',
390
+ disabled: false,
391
+ }),
392
+ upsertRecord: vi.fn(async () => ({})),
393
+ };
394
+ await initCronForum({
395
+ client: client,
396
+ forumChannelNameOrId: 'forum-1',
397
+ allowUserIds: new Set(['u-allowed']),
398
+ scheduler: scheduler,
399
+ runtime: {},
400
+ cronModel: 'haiku',
401
+ cwd: '/tmp',
402
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
403
+ statsStore: statsStore,
404
+ });
405
+ // Should fall through to AI parse since record lacks definition fields.
406
+ expect(parseCronDefinition).toHaveBeenCalled();
407
+ });
408
+ it('falls through to AI parse when fast-path register throws', async () => {
409
+ const thread = makeThread();
410
+ thread.fetchStarterMessage.mockResolvedValue({
411
+ id: 'm1',
412
+ content: 'every day at 7am post to #general say hello',
413
+ author: { id: 'u-allowed' },
414
+ react: vi.fn().mockResolvedValue(undefined),
415
+ });
416
+ thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
417
+ const forum = makeForum([thread]);
418
+ const client = makeClient(forum);
419
+ const scheduler = makeScheduler();
420
+ // First call (fast path) throws; second call (loadThreadAsCron fallback) succeeds.
421
+ scheduler.register
422
+ .mockImplementationOnce(() => { throw new Error('corrupt schedule'); })
423
+ .mockReturnValueOnce({ cron: { nextRun: () => new Date() } });
424
+ vi.mocked(parseCronDefinition).mockResolvedValue({
425
+ triggerType: 'schedule',
426
+ schedule: '0 7 * * *',
427
+ timezone: 'UTC',
428
+ channel: 'general',
429
+ prompt: 'Say hello.',
430
+ });
431
+ const statsStore = {
432
+ getRecordByThreadId: vi.fn().mockReturnValue({
433
+ cronId: 'cron-corrupt',
434
+ threadId: 'thread-1',
435
+ disabled: false,
436
+ schedule: 'BAD SCHEDULE',
437
+ timezone: 'UTC',
438
+ channel: 'general',
439
+ prompt: 'Say hello.',
440
+ authorId: 'u-allowed',
441
+ }),
442
+ getRecord: vi.fn().mockReturnValue({
443
+ cronId: 'cron-corrupt',
444
+ threadId: 'thread-1',
445
+ disabled: false,
446
+ }),
447
+ upsertRecord: vi.fn(async () => ({})),
448
+ };
449
+ await initCronForum({
450
+ client: client,
451
+ forumChannelNameOrId: 'forum-1',
452
+ allowUserIds: new Set(['u-allowed']),
453
+ scheduler: scheduler,
454
+ runtime: {},
455
+ cronModel: 'haiku',
456
+ cwd: '/tmp',
457
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
458
+ statsStore: statsStore,
459
+ });
460
+ // Fast path failed, should fall through to AI parse.
461
+ expect(parseCronDefinition).toHaveBeenCalled();
462
+ // scheduler.register should have been called twice (fast path + fallback).
463
+ expect(scheduler.register).toHaveBeenCalledTimes(2);
464
+ });
465
+ it('disabled state restored via fast path', async () => {
466
+ const thread = makeThread();
467
+ thread.fetchStarterMessage.mockResolvedValue(null);
468
+ const forum = makeForum([thread]);
469
+ const client = makeClient(forum);
470
+ const scheduler = makeScheduler();
471
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
472
+ const statsStore = {
473
+ getRecordByThreadId: vi.fn().mockReturnValue({
474
+ cronId: 'cron-disabled-fp',
475
+ threadId: 'thread-1',
476
+ disabled: true,
477
+ schedule: '0 7 * * *',
478
+ timezone: 'UTC',
479
+ channel: 'general',
480
+ prompt: 'Say hello.',
481
+ authorId: 'u-allowed',
482
+ }),
483
+ getRecord: vi.fn(),
484
+ upsertRecord: vi.fn(async () => ({})),
485
+ };
486
+ await initCronForum({
487
+ client: client,
488
+ forumChannelNameOrId: 'forum-1',
489
+ allowUserIds: new Set(['u-allowed']),
490
+ scheduler: scheduler,
491
+ runtime: {},
492
+ cronModel: 'haiku',
493
+ cwd: '/tmp',
494
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
495
+ statsStore: statsStore,
496
+ });
497
+ // Should register then immediately disable.
498
+ expect(scheduler.register).toHaveBeenCalledOnce();
499
+ expect(scheduler.disable).toHaveBeenCalledWith('thread-1');
500
+ // AI parser should NOT be called.
501
+ expect(parseCronDefinition).not.toHaveBeenCalled();
502
+ });
503
+ it('falls through when stored authorId is not in allowlist', async () => {
504
+ const thread = makeThread();
505
+ thread.fetchStarterMessage.mockResolvedValue({
506
+ id: 'm1',
507
+ content: 'every day at 7am post to #general say hello',
508
+ author: { id: 'u-not-allowed' },
509
+ react: vi.fn().mockResolvedValue(undefined),
510
+ });
511
+ thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
512
+ const forum = makeForum([thread]);
513
+ const client = makeClient(forum);
514
+ const scheduler = makeScheduler();
515
+ const statsStore = {
516
+ getRecordByThreadId: vi.fn().mockReturnValue({
517
+ cronId: 'cron-unauth',
518
+ threadId: 'thread-1',
519
+ disabled: false,
520
+ schedule: '0 7 * * *',
521
+ timezone: 'UTC',
522
+ channel: 'general',
523
+ prompt: 'Say hello.',
524
+ authorId: 'u-not-allowed',
525
+ }),
526
+ getRecord: vi.fn(),
527
+ upsertRecord: vi.fn(async () => ({})),
528
+ };
529
+ await initCronForum({
530
+ client: client,
531
+ forumChannelNameOrId: 'forum-1',
532
+ allowUserIds: new Set(['u-allowed']),
533
+ scheduler: scheduler,
534
+ runtime: {},
535
+ cronModel: 'haiku',
536
+ cwd: '/tmp',
537
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
538
+ statsStore: statsStore,
539
+ });
540
+ // Should fall through to loadThreadAsCron which will reject and disable.
541
+ // fetchStarterMessage is called by loadThreadAsCron.
542
+ expect(thread.fetchStarterMessage).toHaveBeenCalled();
543
+ // scheduler.register should NOT be called (auth fails in loadThreadAsCron too).
544
+ expect(scheduler.register).not.toHaveBeenCalled();
545
+ expect(scheduler.disable).toHaveBeenCalledWith('thread-1');
546
+ });
313
547
  it('restores disabled state from stats store', async () => {
314
548
  const thread = makeThread();
315
549
  thread.fetchStarterMessage.mockResolvedValue({
@@ -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 = 4;
8
+ export const CURRENT_VERSION = 6;
9
9
  // ---------------------------------------------------------------------------
10
10
  // Stable Cron ID generation
11
11
  // ---------------------------------------------------------------------------
@@ -292,7 +292,14 @@ export async function loadRunStats(filePath) {
292
292
  if (store.version === 3) {
293
293
  store.version = 4;
294
294
  }
295
- // Add future migration blocks here:
296
- // if (store.version === 4) { /* transform fields */; store.version = 5; }
295
+ // Migrate v4 v5: no-op — new field (silent) is optional and defaults falsy.
296
+ if (store.version === 4) {
297
+ store.version = 5;
298
+ }
299
+ // Migrate v5 → v6: no-op — new persisted definition fields (schedule, timezone, channel, prompt, authorId) are optional.
300
+ // Absent records fall through to AI parsing on first boot after upgrade.
301
+ if (store.version === 5) {
302
+ store.version = 6;
303
+ }
297
304
  return new CronRunStats(store, filePath);
298
305
  }
@@ -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(4);
41
+ expect(store.version).toBe(6);
42
42
  expect(Object.keys(store.jobs)).toHaveLength(0);
43
43
  });
44
44
  it('upserts and retrieves records by cronId', async () => {
@@ -210,7 +210,7 @@ describe('CronRunStats', () => {
210
210
  describe('emptyStore', () => {
211
211
  it('returns valid initial structure', () => {
212
212
  const store = emptyStore();
213
- expect(store.version).toBe(4);
213
+ expect(store.version).toBe(6);
214
214
  expect(store.updatedAt).toBeGreaterThan(0);
215
215
  expect(Object.keys(store.jobs)).toHaveLength(0);
216
216
  });
@@ -237,7 +237,7 @@ describe('loadRunStats version migration', () => {
237
237
  };
238
238
  await fs.writeFile(statsPath, JSON.stringify(v3Store), 'utf-8');
239
239
  const stats = await loadRunStats(statsPath);
240
- expect(stats.getStore().version).toBe(4);
240
+ expect(stats.getStore().version).toBe(6);
241
241
  const rec = stats.getRecord('cron-migrated');
242
242
  expect(rec).toBeDefined();
243
243
  expect(rec.cronId).toBe('cron-migrated');
@@ -246,4 +246,68 @@ describe('loadRunStats version migration', () => {
246
246
  expect(rec.cadence).toBe('daily');
247
247
  expect(rec.purposeTags).toEqual(['monitoring']);
248
248
  });
249
+ it('migrates a v4 store to v5 with silent undefined on existing records', async () => {
250
+ const v4Store = {
251
+ version: 4,
252
+ updatedAt: Date.now(),
253
+ jobs: {
254
+ 'cron-v4': {
255
+ cronId: 'cron-v4',
256
+ threadId: 'thread-v4',
257
+ runCount: 3,
258
+ lastRunAt: '2025-06-01T00:00:00.000Z',
259
+ lastRunStatus: 'success',
260
+ cadence: 'hourly',
261
+ purposeTags: ['email'],
262
+ disabled: false,
263
+ model: 'sonnet',
264
+ triggerType: 'schedule',
265
+ },
266
+ },
267
+ };
268
+ await fs.writeFile(statsPath, JSON.stringify(v4Store), 'utf-8');
269
+ const stats = await loadRunStats(statsPath);
270
+ expect(stats.getStore().version).toBe(6);
271
+ const rec = stats.getRecord('cron-v4');
272
+ expect(rec).toBeDefined();
273
+ expect(rec.cronId).toBe('cron-v4');
274
+ expect(rec.runCount).toBe(3);
275
+ expect(rec.cadence).toBe('hourly');
276
+ expect(rec.silent).toBeUndefined();
277
+ });
278
+ it('migrates a v5 store to v6 with definition fields undefined on existing records', async () => {
279
+ const v5Store = {
280
+ version: 5,
281
+ updatedAt: Date.now(),
282
+ jobs: {
283
+ 'cron-v5': {
284
+ cronId: 'cron-v5',
285
+ threadId: 'thread-v5',
286
+ runCount: 7,
287
+ lastRunAt: '2025-08-01T00:00:00.000Z',
288
+ lastRunStatus: 'success',
289
+ cadence: 'daily',
290
+ purposeTags: ['greeting'],
291
+ disabled: false,
292
+ model: 'haiku',
293
+ triggerType: 'schedule',
294
+ silent: true,
295
+ },
296
+ },
297
+ };
298
+ await fs.writeFile(statsPath, JSON.stringify(v5Store), 'utf-8');
299
+ const stats = await loadRunStats(statsPath);
300
+ expect(stats.getStore().version).toBe(6);
301
+ const rec = stats.getRecord('cron-v5');
302
+ expect(rec).toBeDefined();
303
+ expect(rec.cronId).toBe('cron-v5');
304
+ expect(rec.runCount).toBe(7);
305
+ expect(rec.cadence).toBe('daily');
306
+ expect(rec.silent).toBe(true);
307
+ expect(rec.schedule).toBeUndefined();
308
+ expect(rec.timezone).toBeUndefined();
309
+ expect(rec.channel).toBeUndefined();
310
+ expect(rec.prompt).toBeUndefined();
311
+ expect(rec.authorId).toBeUndefined();
312
+ });
249
313
  });
@@ -147,6 +147,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
147
147
  cadence,
148
148
  purposeTags,
149
149
  model,
150
+ schedule: action.schedule,
151
+ timezone,
152
+ channel: action.channel,
153
+ prompt: action.prompt,
154
+ authorId: cronCtx.client.user?.id,
150
155
  });
151
156
  // Create status message.
152
157
  try {
@@ -214,7 +219,8 @@ export async function executeCronAction(action, ctx, cronCtx) {
214
219
  }
215
220
  else {
216
221
  // Can't edit user's message — post update note.
217
- const note = `**Cron Updated**\n**Schedule:** \`${newSchedule}\` (${newTimezone})\n**Channel:** #${newChannel}\n\nPlease update the starter message to reflect these changes.`;
222
+ const promptPreview = newPrompt.length > 200 ? `${newPrompt.slice(0, 200)}... (truncated)` : newPrompt;
223
+ const note = `**Cron Updated**\n**Schedule:** \`${newSchedule}\` (${newTimezone})\n**Channel:** #${newChannel}\n**Prompt:** ${promptPreview}\n\nPlease update the starter message to reflect these changes.`;
218
224
  await thread.send({ content: note, allowedMentions: { parse: [] } });
219
225
  }
220
226
  }
@@ -230,6 +236,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
230
236
  const msg = err instanceof Error ? err.message : String(err);
231
237
  return { ok: false, error: `Invalid cron definition: ${msg}` };
232
238
  }
239
+ // Persist updated definition fields.
240
+ updates.schedule = newSchedule;
241
+ updates.timezone = newTimezone;
242
+ updates.channel = newChannel;
243
+ updates.prompt = newPrompt;
233
244
  }
234
245
  await cronCtx.statsStore.upsertRecord(action.cronId, record.threadId, updates);
235
246
  // Update status message.
@@ -320,6 +331,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
320
331
  lines.push(`Tags: ${record.purposeTags.join(', ')}`);
321
332
  if (record.lastErrorMessage)
322
333
  lines.push(`Last error: ${record.lastErrorMessage}`);
334
+ if (job) {
335
+ const promptText = job.def.prompt;
336
+ const truncated = promptText.length > 500 ? `${promptText.slice(0, 500)}... (truncated)` : promptText;
337
+ lines.push(`Prompt: ${truncated}`);
338
+ }
323
339
  return { ok: true, summary: lines.join('\n') };
324
340
  }
325
341
  case 'cronPause': {
@@ -484,6 +484,48 @@ describe('executeCronAction', () => {
484
484
  expect(result.summary).toContain('no sync coordinator configured');
485
485
  }
486
486
  });
487
+ it('cronShow includes Prompt line with job prompt text', async () => {
488
+ const cronCtx = makeCronCtx();
489
+ const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
490
+ expect(result.ok).toBe(true);
491
+ if (result.ok) {
492
+ expect(result.summary).toContain('Prompt: Test');
493
+ }
494
+ });
495
+ it('cronShow truncates prompt longer than 500 chars', async () => {
496
+ const cronCtx = makeCronCtx();
497
+ const job = cronCtx.scheduler.getJob('thread-1');
498
+ job.def.prompt = 'x'.repeat(600);
499
+ const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
500
+ expect(result.ok).toBe(true);
501
+ if (result.ok) {
502
+ expect(result.summary).toContain('... (truncated)');
503
+ expect(result.summary).not.toContain('x'.repeat(600));
504
+ }
505
+ });
506
+ it('cronShow omits Prompt line when scheduler job is missing', async () => {
507
+ const cronCtx = makeCronCtx({ scheduler: makeScheduler([]) });
508
+ const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
509
+ expect(result.ok).toBe(true);
510
+ if (result.ok) {
511
+ expect(result.summary).not.toContain('Prompt:');
512
+ }
513
+ });
514
+ it('cronUpdate fallback note includes prompt when starter is not bot-owned', async () => {
515
+ const cronCtx = makeCronCtx();
516
+ const threadSend = vi.fn(async () => ({}));
517
+ const mockThread = {
518
+ id: 'thread-1',
519
+ isThread: () => true,
520
+ send: threadSend,
521
+ fetchStarterMessage: vi.fn(async () => ({ author: { id: 'other-user' }, edit: vi.fn() })),
522
+ setArchived: vi.fn(),
523
+ };
524
+ cronCtx.client.channels.cache.get.mockImplementation((id) => id === 'thread-1' ? mockThread : undefined);
525
+ const result = await executeCronAction({ type: 'cronUpdate', cronId: 'cron-test0001', prompt: 'New prompt text' }, makeActionCtx(), cronCtx);
526
+ expect(result.ok).toBe(true);
527
+ expect(threadSend).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('New prompt text') }));
528
+ });
487
529
  it('cronTagMapReload failure returns error', async () => {
488
530
  const { reloadCronTagMapInPlace } = await import('../cron/tag-map.js');
489
531
  vi.mocked(reloadCronTagMapInPlace).mockRejectedValue(new Error('bad json'));
@@ -43,6 +43,9 @@ export function mapRuntimeErrorToUserMessage(raw) {
43
43
  return ('This channel is missing required context. Create/index the channel context file under content/discord/channels ' +
44
44
  'or disable DISCORD_REQUIRE_CHANNEL_CONTEXT.');
45
45
  }
46
+ if (lc.includes('prompt is too long') || lc.includes('context length exceeded') || lc.includes('context_length_exceeded')) {
47
+ return 'The conversation context exceeded the model\'s limit. Try a shorter message or start a new conversation.';
48
+ }
46
49
  if (!msg) {
47
50
  return 'An unexpected runtime error occurred with no additional detail.';
48
51
  }
@@ -36,4 +36,12 @@ describe('mapRuntimeErrorToUserMessage', () => {
36
36
  expect(msg).toContain('DISCOCLAW_STREAM_STALL_TIMEOUT_MS');
37
37
  expect(msg).not.toContain('ms /');
38
38
  });
39
+ it('maps "Prompt is too long" to context overflow user message', () => {
40
+ const msg = mapRuntimeErrorToUserMessage('Prompt is too long');
41
+ expect(msg).toBe('The conversation context exceeded the model\'s limit. Try a shorter message or start a new conversation.');
42
+ });
43
+ it('maps "context_length_exceeded" to context overflow user message', () => {
44
+ const msg = mapRuntimeErrorToUserMessage('context_length_exceeded');
45
+ expect(msg).toBe('The conversation context exceeded the model\'s limit. Try a shorter message or start a new conversation.');
46
+ });
39
47
  });
@@ -10,6 +10,16 @@ import { STDIN_THRESHOLD, tryParseJsonLine, createEventQueue, SubprocessTracker,
10
10
  import { extractTextFromUnknownEvent, extractResultText, extractImageFromUnknownEvent, extractResultContentBlocks, imageDedupeKey, stripToolUseBlocks, } from './cli-output-parsers.js';
11
11
  // Global subprocess tracker shared across all CLI adapters.
12
12
  const globalTracker = new SubprocessTracker();
13
+ const CONTEXT_OVERFLOW_PHRASES = [
14
+ 'prompt is too long',
15
+ 'context length exceeded',
16
+ 'context_length_exceeded',
17
+ 'context overflow',
18
+ ];
19
+ function isContextOverflowMessage(text) {
20
+ const lower = text.toLowerCase();
21
+ return CONTEXT_OVERFLOW_PHRASES.some((phrase) => lower.includes(phrase));
22
+ }
13
23
  function asCliLogLike(log) {
14
24
  if (!log || typeof log !== 'object')
15
25
  return undefined;
@@ -95,10 +105,19 @@ export function createCliRuntime(strategy, opts) {
95
105
  const onPoolAbort = () => { proc.kill?.(); };
96
106
  params.signal?.addEventListener('abort', onPoolAbort, { once: true });
97
107
  let fallback = false;
108
+ let contextOverflow = false;
98
109
  try {
99
110
  for await (const evt of proc.sendTurn(params.prompt, params.images)) {
100
111
  if (evt.type === 'error' && (evt.message.startsWith('long-running:') || evt.message.includes('hang detected'))) {
101
- pool.remove(params.sessionKey);
112
+ if (evt.message.includes('context overflow'))
113
+ contextOverflow = true;
114
+ pool.remove(params.sessionKey, contextOverflow ? 'context-overflow' : undefined);
115
+ fallback = true;
116
+ break;
117
+ }
118
+ if ((evt.type === 'text_delta' || evt.type === 'text_final') && isContextOverflowMessage(evt.text)) {
119
+ pool.remove(params.sessionKey, 'context-overflow');
120
+ contextOverflow = true;
102
121
  fallback = true;
103
122
  break;
104
123
  }
@@ -112,6 +131,10 @@ export function createCliRuntime(strategy, opts) {
112
131
  globalTracker.delete(sub);
113
132
  if (!fallback)
114
133
  return;
134
+ if (contextOverflow) {
135
+ cliLog?.info?.({ sessionKey: params.sessionKey }, 'multi-turn: context overflow, resetting session and retrying');
136
+ yield { type: 'text_delta', text: '*(Session reset — conversation context limit reached. Starting fresh.)*\n\n' };
137
+ }
115
138
  cliLog?.info?.('multi-turn: process failed, falling back to one-shot');
116
139
  }
117
140
  }
@@ -288,11 +288,23 @@ export class LongRunningProcess {
288
288
  }
289
289
  }
290
290
  }
291
+ isContextOverflow(text) {
292
+ const lower = text.toLowerCase();
293
+ return (lower.includes('prompt is too long') ||
294
+ lower.includes('context length exceeded') ||
295
+ lower.includes('context_length_exceeded'));
296
+ }
291
297
  finalizeTurn() {
292
298
  const raw = this.turnResultText.trim() || (this.turnMerged.trim() ? this.turnMerged.trimEnd() : '');
293
299
  const final = stripToolUseBlocks(raw);
294
- if (final)
295
- this.pushEvent({ type: 'text_final', text: final });
300
+ if (final) {
301
+ if (this.isContextOverflow(final)) {
302
+ this.pushEvent({ type: 'error', message: 'long-running: context overflow' });
303
+ }
304
+ else {
305
+ this.pushEvent({ type: 'text_final', text: final });
306
+ }
307
+ }
296
308
  this.pushDoneOnce();
297
309
  }
298
310
  handleExit() {
@@ -299,6 +299,23 @@ describe('LongRunningProcess', () => {
299
299
  expect(callArgs).not.toContain('--max-budget-usd');
300
300
  expect(callArgs).not.toContain('--append-system-prompt');
301
301
  });
302
+ it('context overflow result emits error event instead of text_final', async () => {
303
+ const mock = createMockSubprocess();
304
+ execa.mockReturnValue(mock.proc);
305
+ const proc = new LongRunningProcess(baseOpts);
306
+ proc.spawn();
307
+ queueMicrotask(() => {
308
+ mock.stdout.emit('data', JSON.stringify({ type: 'result', result: 'Prompt is too long' }) + '\n');
309
+ });
310
+ const events = [];
311
+ for await (const evt of proc.sendTurn('tell me everything')) {
312
+ events.push(evt);
313
+ }
314
+ expect(events.find((e) => e.type === 'error')?.message).toBe('long-running: context overflow');
315
+ expect(events.find((e) => e.type === 'done')).toBeTruthy();
316
+ expect(events.find((e) => e.type === 'text_final')).toBeUndefined();
317
+ expect(proc.state).toBe('idle');
318
+ });
302
319
  it('sendTurn without images writes plain string content (no regression)', async () => {
303
320
  const mock = createMockSubprocess();
304
321
  execa.mockReturnValue(mock.proc);
@@ -59,12 +59,12 @@ export class ProcessPool {
59
59
  return proc;
60
60
  }
61
61
  /** Kill and remove a specific session's process. */
62
- remove(sessionKey) {
62
+ remove(sessionKey, reason) {
63
63
  const proc = this.pool.get(sessionKey);
64
64
  if (proc) {
65
65
  this.pool.delete(sessionKey);
66
66
  proc.kill();
67
- this.log?.info({ sessionKey }, 'process-pool: removed process');
67
+ this.log?.info({ sessionKey, reason }, 'process-pool: removed process');
68
68
  }
69
69
  }
70
70
  /** Kill all processes (shutdown cleanup). */
@@ -11,7 +11,6 @@ const TEMPLATE_FILES = [
11
11
  'USER.md',
12
12
  'AGENTS.md',
13
13
  'TOOLS.md',
14
- 'HEARTBEAT.md',
15
14
  'MEMORY.md',
16
15
  ];
17
16
  /** Marker text present in the template IDENTITY.md but removed during onboarding. */
@@ -16,7 +16,6 @@ const ALL_TEMPLATE_FILES = [
16
16
  'USER.md',
17
17
  'AGENTS.md',
18
18
  'TOOLS.md',
19
- 'HEARTBEAT.md',
20
19
  'MEMORY.md',
21
20
  ];
22
21
  /** Real IDENTITY.md content that passes onboarding check (no template marker). */
@@ -199,7 +198,6 @@ describe('ensureWorkspaceBootstrapFiles', () => {
199
198
  'SOUL.md': 'My soul',
200
199
  'TOOLS.md': 'My tools',
201
200
  'USER.md': 'My user',
202
- 'HEARTBEAT.md': 'My heartbeat',
203
201
  'MEMORY.md': 'My memory',
204
202
  };
205
203
  for (const [file, content] of Object.entries(customFiles)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "description": "Minimal Discord bridge routing messages to AI runtimes",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -5,6 +5,7 @@ Wants=network-online.target
5
5
 
6
6
  [Service]
7
7
  Type=simple
8
+ Environment=PATH=%h/.local/bin:%h/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
8
9
  WorkingDirectory=%h/code/discoclaw
9
10
  # Keep secrets local; this file should exist on the host (not committed).
10
11
  EnvironmentFile=%h/code/discoclaw/.env
@@ -1,10 +0,0 @@
1
- # HEARTBEAT.md
2
-
3
- # Keep this file empty (or with only comments) to skip heartbeat tasks.
4
-
5
- # Add tasks below when you want the agent to check something periodically.
6
-
7
- # --- Memory maintenance (uncomment to enable) ---
8
- # - Review daily logs older than 7 days in memory/. Distill anything worth keeping into MEMORY.md, then delete the old daily file.
9
- # - Check MEMORY.md for stale or superseded entries. Remove anything no longer relevant.
10
- # - If MEMORY.md exceeds ~2 KB, prune aggressively — keep only what matters for future sessions.