discoclaw 0.2.2 → 0.2.4

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.
@@ -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]');
@@ -214,7 +214,8 @@ export async function executeCronAction(action, ctx, cronCtx) {
214
214
  }
215
215
  else {
216
216
  // 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.`;
217
+ const promptPreview = newPrompt.length > 200 ? `${newPrompt.slice(0, 200)}... (truncated)` : newPrompt;
218
+ 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
219
  await thread.send({ content: note, allowedMentions: { parse: [] } });
219
220
  }
220
221
  }
@@ -320,6 +321,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
320
321
  lines.push(`Tags: ${record.purposeTags.join(', ')}`);
321
322
  if (record.lastErrorMessage)
322
323
  lines.push(`Last error: ${record.lastErrorMessage}`);
324
+ if (job) {
325
+ const promptText = job.def.prompt;
326
+ const truncated = promptText.length > 500 ? `${promptText.slice(0, 500)}... (truncated)` : promptText;
327
+ lines.push(`Prompt: ${truncated}`);
328
+ }
323
329
  return { ok: true, summary: lines.join('\n') };
324
330
  }
325
331
  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). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discoclaw",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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