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.
- package/dist/cli/daemon-installer.js +1 -0
- package/dist/cli/daemon-installer.test.js +6 -0
- package/dist/discord/actions-crons.js +7 -1
- package/dist/discord/actions-crons.test.js +42 -0
- package/dist/discord/user-errors.js +3 -0
- package/dist/discord/user-errors.test.js +8 -0
- package/dist/runtime/cli-adapter.js +24 -1
- package/dist/runtime/long-running-process.js +14 -2
- package/dist/runtime/long-running-process.test.js +17 -0
- package/dist/runtime/process-pool.js +2 -2
- package/package.json +1 -1
- package/systemd/discoclaw.service +1 -0
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
@@ -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
|