kimaki 0.24.0 → 0.26.0
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/agent-model.e2e.test.js +135 -3
- package/dist/analytics.test.js +30 -1
- package/dist/cli-commands/send.js +13 -3
- package/dist/cli-commands/task.js +26 -7
- package/dist/cli-runner.js +66 -70
- package/dist/cli-runner.test.js +10 -1
- package/dist/cli.js +5 -0
- package/dist/cloudflare-transcription.js +56 -0
- package/dist/commands/agent.js +87 -74
- package/dist/commands/model-variant.js +17 -1
- package/dist/commands/model.js +18 -0
- package/dist/commands/multioauth.js +169 -1
- package/dist/commands/queue.js +64 -0
- package/dist/commands/tasks.js +6 -0
- package/dist/commands/unset-model.js +5 -20
- package/dist/config.js +9 -0
- package/dist/database.js +65 -0
- package/dist/discord-bot.js +8 -6
- package/dist/discord-command-registration.js +17 -12
- package/dist/external-directory-permissions.e2e.test.js +82 -0
- package/dist/external-directory-project-deny.e2e.test.js +68 -0
- package/dist/format-tables.js +53 -5
- package/dist/format-tables.test.js +121 -0
- package/dist/interaction-handler.js +8 -6
- package/dist/ipc-tools-plugin.js +4 -3
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/message-formatting.js +84 -5
- package/dist/message-formatting.test.js +160 -2
- package/dist/message-preprocessing.js +2 -25
- package/dist/oauth-rotation-shared.js +7 -1
- package/dist/openai-auth-state.js +20 -0
- package/dist/opencode.js +126 -96
- package/dist/queue-advanced-e2e-setup.js +63 -27
- package/dist/queue-advanced-permissions-typing.e2e.test.js +4 -0
- package/dist/schema.js +19 -0
- package/dist/session-handler/event-stream-state.js +147 -0
- package/dist/session-handler/event-stream-state.test.js +437 -1
- package/dist/session-handler/thread-session-runtime.js +95 -57
- package/dist/store.js +1 -0
- package/dist/subagent-rate-limit-plugin.js +13 -2
- package/dist/system-message.js +18 -4
- package/dist/system-message.test.js +28 -4
- package/dist/task-runner.js +143 -12
- package/dist/task-schedule.js +12 -0
- package/dist/task-schedule.test.js +149 -2
- package/dist/voice-handler.js +37 -11
- package/dist/xai-auth-plugin.js +237 -0
- package/dist/xai-auth-state.js +233 -0
- package/dist/xai-auth-state.test.js +183 -0
- package/package.json +6 -6
- package/skills/profano/SKILL.md +5 -3
- package/skills/spiceflow/SKILL.md +24 -1
- package/src/agent-model.e2e.test.ts +172 -4
- package/src/analytics.test.ts +30 -1
- package/src/analytics.ts +1 -0
- package/src/cli-commands/send.ts +22 -3
- package/src/cli-commands/task.ts +32 -19
- package/src/cli-runner.test.ts +16 -1
- package/src/cli-runner.ts +88 -89
- package/src/cli.ts +11 -0
- package/src/cloudflare-transcription.ts +76 -0
- package/src/commands/agent.ts +120 -84
- package/src/commands/model-variant.ts +26 -0
- package/src/commands/model.ts +25 -0
- package/src/commands/multioauth.ts +195 -2
- package/src/commands/queue.ts +83 -0
- package/src/commands/tasks.ts +6 -0
- package/src/commands/unset-model.ts +5 -21
- package/src/config.ts +10 -0
- package/src/database.ts +124 -0
- package/src/discord-bot.ts +7 -6
- package/src/discord-command-registration.ts +22 -15
- package/src/external-directory-permissions.e2e.test.ts +99 -0
- package/src/external-directory-project-deny.e2e.test.ts +82 -0
- package/src/format-tables.test.ts +126 -0
- package/src/format-tables.ts +68 -5
- package/src/interaction-handler.ts +10 -5
- package/src/ipc-tools-plugin.ts +4 -3
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/message-formatting.test.ts +197 -2
- package/src/message-formatting.ts +98 -5
- package/src/message-preprocessing.ts +2 -37
- package/src/oauth-rotation-shared.ts +6 -1
- package/src/openai-auth-state.ts +19 -0
- package/src/opencode.ts +132 -111
- package/src/queue-advanced-e2e-setup.ts +78 -29
- package/src/queue-advanced-permissions-typing.e2e.test.ts +4 -0
- package/src/schema.sql +15 -0
- package/src/schema.ts +20 -0
- package/src/session-handler/event-stream-state.test.ts +490 -0
- package/src/session-handler/event-stream-state.ts +210 -0
- package/src/session-handler/model-utils.ts +1 -0
- package/src/session-handler/thread-runtime-state.ts +0 -4
- package/src/session-handler/thread-session-runtime.ts +108 -69
- package/src/store.ts +10 -0
- package/src/subagent-rate-limit-plugin.ts +12 -2
- package/src/system-message.test.ts +29 -4
- package/src/system-message.ts +20 -4
- package/src/task-runner.ts +168 -11
- package/src/task-schedule.test.ts +172 -2
- package/src/task-schedule.ts +23 -2
- package/src/voice-handler.ts +37 -10
- package/src/xai-auth-plugin.ts +302 -0
- package/src/xai-auth-state.test.ts +224 -0
- package/src/xai-auth-state.ts +293 -0
- package/dist/channel-reference-permissions.e2e.test.js +0 -87
- package/dist/commands/add-dir.js +0 -169
- package/dist/commands/add-dir.test.js +0 -126
- package/src/channel-reference-permissions.e2e.test.ts +0 -104
- package/src/commands/add-dir.test.ts +0 -154
- package/src/commands/add-dir.ts +0 -244
package/dist/ipc-tools-plugin.js
CHANGED
|
@@ -56,7 +56,7 @@ const ipcToolsPlugin = async () => {
|
|
|
56
56
|
'The user sees a button, clicks it, and gets a file upload dialog. ' +
|
|
57
57
|
'Returns the local file paths of downloaded files in the project directory. ' +
|
|
58
58
|
'Use this when you need the user to provide files (images, documents, configs, etc.). ' +
|
|
59
|
-
'
|
|
59
|
+
'You MUST call kimaki_file_upload LAST, after ALL text. NEVER call it before your text.',
|
|
60
60
|
args: {
|
|
61
61
|
prompt: z
|
|
62
62
|
.string()
|
|
@@ -115,8 +115,9 @@ const ipcToolsPlugin = async () => {
|
|
|
115
115
|
Use this when the user can respond by clicking one of up to 3 buttons.
|
|
116
116
|
Prefer a single button whenever possible.
|
|
117
117
|
Default color is white (same visual style as permission deny button).
|
|
118
|
-
If you need more than 3 options, use
|
|
119
|
-
|
|
118
|
+
If you need more than 3 options, use \`question\` instead.
|
|
119
|
+
You MUST call kimaki_action_buttons LAST, after ALL text.
|
|
120
|
+
NEVER call kimaki_action_buttons before your text.
|
|
120
121
|
|
|
121
122
|
Examples:
|
|
122
123
|
- buttons: [{"label":"Yes, proceed"}]
|
|
@@ -15,6 +15,8 @@ export { contextAwarenessPlugin } from './context-awareness-plugin.js';
|
|
|
15
15
|
export { memoryOverviewPlugin } from './memory-overview-plugin.js';
|
|
16
16
|
export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
|
|
17
17
|
export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
|
|
18
|
+
export { openaiRotationPlugin } from './openai-auth-plugin.js';
|
|
19
|
+
export { xaiRotationPlugin } from './xai-auth-plugin.js';
|
|
18
20
|
export { imageOptimizerPlugin } from './image-optimizer-plugin.js';
|
|
19
21
|
export { subagentRateLimitPlugin } from './subagent-rate-limit-plugin.js';
|
|
20
22
|
export { cacheDriftPlugin } from './cache-drift-plugin.js';
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// OpenCode message part formatting for Discord.
|
|
2
2
|
// Converts SDK message parts (text, tools, reasoning) to Discord-friendly format,
|
|
3
3
|
// handles file attachments, and provides tool summary generation.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
4
6
|
import { createLogger, LogPrefix } from './logger.js';
|
|
5
|
-
import { FetchError } from './errors.js';
|
|
7
|
+
import { FetchError, FilesystemOperationError } from './errors.js';
|
|
6
8
|
import { processImage } from './image-utils.js';
|
|
7
9
|
import { parsePatchFileCounts } from './patch-text-parser.js';
|
|
10
|
+
import { getDataDir } from './config.js';
|
|
8
11
|
const logger = createLogger(LogPrefix.FORMATTING);
|
|
9
12
|
/**
|
|
10
13
|
* Serialize Discord embeds into plain text so the AI model can read them.
|
|
@@ -210,22 +213,77 @@ export function isTextMimeType(contentType) {
|
|
|
210
213
|
}
|
|
211
214
|
return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix));
|
|
212
215
|
}
|
|
216
|
+
// Small Discord "send as file" prompts stay inlined. Bigger dumps only get a URL.
|
|
217
|
+
export const TEXT_ATTACHMENT_INLINE_LIMIT_BYTES = 64 * 1024;
|
|
218
|
+
const KIMAKI_SEND_PROMPT_ATTACHMENT_NAME = 'prompt.md';
|
|
219
|
+
function formatAttachmentSize(bytes) {
|
|
220
|
+
if (bytes < 1024)
|
|
221
|
+
return `${bytes} B`;
|
|
222
|
+
if (bytes < 1024 * 1024) {
|
|
223
|
+
const kb = bytes / 1024;
|
|
224
|
+
return `${kb < 10 ? kb.toFixed(1) : kb.toFixed(0)} KB`;
|
|
225
|
+
}
|
|
226
|
+
const mb = bytes / (1024 * 1024);
|
|
227
|
+
return `${mb < 10 ? mb.toFixed(1) : mb.toFixed(0)} MB`;
|
|
228
|
+
}
|
|
229
|
+
function shouldInlineTextAttachment(attachment) {
|
|
230
|
+
if (attachment.name === KIMAKI_SEND_PROMPT_ATTACHMENT_NAME)
|
|
231
|
+
return true;
|
|
232
|
+
return attachment.size <= TEXT_ATTACHMENT_INLINE_LIMIT_BYTES;
|
|
233
|
+
}
|
|
234
|
+
function textAttachmentAttrs(attachment, extra = []) {
|
|
235
|
+
return [
|
|
236
|
+
`filename="${attachment.name}"`,
|
|
237
|
+
`mime="${attachment.contentType}"`,
|
|
238
|
+
`size="${attachment.size}"`,
|
|
239
|
+
`url="${attachment.url}"`,
|
|
240
|
+
...extra,
|
|
241
|
+
].join(' ');
|
|
242
|
+
}
|
|
243
|
+
function safeAttachmentBasename(name) {
|
|
244
|
+
const sanitized = path.basename(name).replace(/\0/g, '');
|
|
245
|
+
if (!sanitized || sanitized === '.' || sanitized === '..')
|
|
246
|
+
return 'attachment';
|
|
247
|
+
return sanitized;
|
|
248
|
+
}
|
|
249
|
+
function localAttachmentPath(attachment) {
|
|
250
|
+
const id = attachment.id || attachment.url.slice(-12).replace(/[^a-zA-Z0-9]/g, '');
|
|
251
|
+
return path.join(getDataDir(), 'attachments', `${id}-${safeAttachmentBasename(attachment.name)}`);
|
|
252
|
+
}
|
|
213
253
|
export async function getTextAttachments(message) {
|
|
214
254
|
const textAttachments = Array.from(message.attachments.values()).filter((attachment) => isTextMimeType(attachment.contentType));
|
|
215
255
|
if (textAttachments.length === 0) {
|
|
216
256
|
return '';
|
|
217
257
|
}
|
|
258
|
+
const attachmentsDir = path.join(getDataDir(), 'attachments');
|
|
259
|
+
fs.mkdirSync(attachmentsDir, { recursive: true });
|
|
218
260
|
const textContents = await Promise.all(textAttachments.map(async (attachment) => {
|
|
219
261
|
const response = await fetch(attachment.url)
|
|
220
262
|
.catch((e) => new FetchError({ url: attachment.url, cause: e }));
|
|
221
263
|
if (response instanceof Error) {
|
|
222
|
-
return `<attachment
|
|
264
|
+
return `<attachment ${textAttachmentAttrs(attachment)} error="${response.message}" />`;
|
|
223
265
|
}
|
|
224
266
|
if (!response.ok) {
|
|
225
|
-
return `<attachment
|
|
267
|
+
return `<attachment ${textAttachmentAttrs(attachment)} error="Failed to fetch: ${response.status}" />`;
|
|
268
|
+
}
|
|
269
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
270
|
+
const savedPath = localAttachmentPath(attachment);
|
|
271
|
+
const written = await fs.promises
|
|
272
|
+
.writeFile(savedPath, buffer)
|
|
273
|
+
.catch((e) => new FilesystemOperationError({ operation: 'write attachment', cause: e }));
|
|
274
|
+
if (written instanceof Error) {
|
|
275
|
+
logger.error(`Failed to save attachment ${attachment.name}:`, written.message);
|
|
226
276
|
}
|
|
227
|
-
const
|
|
228
|
-
|
|
277
|
+
const pathAttr = written instanceof Error ? [] : [`path="${savedPath}"`];
|
|
278
|
+
if (!shouldInlineTextAttachment(attachment)) {
|
|
279
|
+
const sizeLabel = formatAttachmentSize(attachment.size);
|
|
280
|
+
return [
|
|
281
|
+
`<attachment ${textAttachmentAttrs(attachment, [...pathAttr, 'large="true"'])}>`,
|
|
282
|
+
`This file is large (${sizeLabel}, ${attachment.contentType || 'unknown type'}). Contents were not inlined to save context. Read the local path.`,
|
|
283
|
+
`</attachment>`,
|
|
284
|
+
].join('\n');
|
|
285
|
+
}
|
|
286
|
+
return `<attachment ${textAttachmentAttrs(attachment, pathAttr)}>\n${buffer.toString('utf8')}\n</attachment>`;
|
|
229
287
|
}));
|
|
230
288
|
return textContents.join('\n\n');
|
|
231
289
|
}
|
|
@@ -419,6 +477,27 @@ export function formatTodoList(part) {
|
|
|
419
477
|
const content = activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1);
|
|
420
478
|
return `${num} **${escapeInlineMarkdown(content)}**`;
|
|
421
479
|
}
|
|
480
|
+
export function formatTaskToolTitle(part) {
|
|
481
|
+
if (part.tool !== 'task' || part.state.status === 'pending')
|
|
482
|
+
return '';
|
|
483
|
+
const childSessionId = part.state.metadata?.sessionId;
|
|
484
|
+
if (typeof childSessionId !== 'string' || !childSessionId)
|
|
485
|
+
return '';
|
|
486
|
+
const description = part.state.input?.description;
|
|
487
|
+
const stateTitle = part.state.status === 'running' || part.state.status === 'completed'
|
|
488
|
+
? part.state.title
|
|
489
|
+
: undefined;
|
|
490
|
+
const title = typeof description === 'string' && description
|
|
491
|
+
? description
|
|
492
|
+
: typeof stateTitle === 'string'
|
|
493
|
+
? stateTitle
|
|
494
|
+
: '';
|
|
495
|
+
if (!title)
|
|
496
|
+
return '';
|
|
497
|
+
const subagentType = part.state.input?.subagent_type;
|
|
498
|
+
const agent = typeof subagentType === 'string' ? subagentType : 'task';
|
|
499
|
+
return `┣ ${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`;
|
|
500
|
+
}
|
|
422
501
|
export function formatPart(part, prefix) {
|
|
423
502
|
const pfx = prefix ? `${prefix} ⋅ ` : '';
|
|
424
503
|
if (part.type === 'text') {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { afterEach, describe, test, expect } from 'vitest';
|
|
4
|
+
import { formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, serializeEmbeds, serializePoll, serializeMessageSnapshots, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js';
|
|
5
|
+
import { getDataDir } from './config.js';
|
|
3
6
|
describe('formatPart', () => {
|
|
4
7
|
test('callout text does not get ⬥ prefix', () => {
|
|
5
8
|
const part = {
|
|
@@ -42,6 +45,27 @@ describe('formatPart', () => {
|
|
|
42
45
|
`);
|
|
43
46
|
});
|
|
44
47
|
});
|
|
48
|
+
describe('formatTaskToolTitle', () => {
|
|
49
|
+
test('uses the completed task title when OpenAI omits the description', () => {
|
|
50
|
+
const part = {
|
|
51
|
+
id: 'prt_task',
|
|
52
|
+
type: 'tool',
|
|
53
|
+
tool: 'task',
|
|
54
|
+
callID: 'call_task',
|
|
55
|
+
sessionID: 'ses_parent',
|
|
56
|
+
messageID: 'msg_assistant',
|
|
57
|
+
state: {
|
|
58
|
+
status: 'completed',
|
|
59
|
+
input: { subagent_type: 'general' },
|
|
60
|
+
output: '',
|
|
61
|
+
title: 'Classify pending changes',
|
|
62
|
+
metadata: { sessionId: 'ses_child' },
|
|
63
|
+
time: { start: 1, end: 2 },
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
expect(formatTaskToolTitle(part)).toMatchInlineSnapshot(`"┣ general **Classify pending changes**"`);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
45
69
|
describe('formatBashToolTitle', () => {
|
|
46
70
|
test('short single-line command shown in full', () => {
|
|
47
71
|
expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`);
|
|
@@ -356,3 +380,137 @@ describe('serializeMessageSnapshots', () => {
|
|
|
356
380
|
`);
|
|
357
381
|
});
|
|
358
382
|
});
|
|
383
|
+
describe('getTextAttachments', () => {
|
|
384
|
+
const originalFetch = globalThis.fetch;
|
|
385
|
+
afterEach(() => {
|
|
386
|
+
globalThis.fetch = originalFetch;
|
|
387
|
+
});
|
|
388
|
+
function stubFetch(impl) {
|
|
389
|
+
const fetchFn = async (input) => impl(String(input));
|
|
390
|
+
fetchFn.preconnect = () => { };
|
|
391
|
+
globalThis.fetch = fetchFn;
|
|
392
|
+
}
|
|
393
|
+
function messageWithAttachments(attachments) {
|
|
394
|
+
return {
|
|
395
|
+
attachments: new Map(attachments.map((attachment) => {
|
|
396
|
+
return [attachment.id, attachment];
|
|
397
|
+
})),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function snapshotAttachments(result) {
|
|
401
|
+
return result.replaceAll(getDataDir(), '<dataDir>');
|
|
402
|
+
}
|
|
403
|
+
test('inlines small text files and saves them locally', async () => {
|
|
404
|
+
stubFetch(async () => {
|
|
405
|
+
return new Response('hello from file', { status: 200 });
|
|
406
|
+
});
|
|
407
|
+
const result = await getTextAttachments(messageWithAttachments([
|
|
408
|
+
{
|
|
409
|
+
id: 'att1',
|
|
410
|
+
name: 'notes.txt',
|
|
411
|
+
contentType: 'text/plain',
|
|
412
|
+
url: 'https://cdn.example/notes.txt',
|
|
413
|
+
size: 16,
|
|
414
|
+
},
|
|
415
|
+
]));
|
|
416
|
+
const savedPath = path.join(getDataDir(), 'attachments', 'att1-notes.txt');
|
|
417
|
+
expect(fs.readFileSync(savedPath, 'utf8')).toBe('hello from file');
|
|
418
|
+
expect(result).toContain('https://cdn.example/notes.txt');
|
|
419
|
+
expect(result).toContain(savedPath);
|
|
420
|
+
expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
|
|
421
|
+
"<attachment filename="notes.txt" mime="text/plain" size="16" url="https://cdn.example/notes.txt" path="<dataDir>/attachments/att1-notes.txt">
|
|
422
|
+
hello from file
|
|
423
|
+
</attachment>"
|
|
424
|
+
`);
|
|
425
|
+
});
|
|
426
|
+
test('saves large text files locally without inlining contents', async () => {
|
|
427
|
+
stubFetch(async () => {
|
|
428
|
+
return new Response('SHOULD NOT BE INLINED', { status: 200 });
|
|
429
|
+
});
|
|
430
|
+
const result = await getTextAttachments(messageWithAttachments([
|
|
431
|
+
{
|
|
432
|
+
id: 'att2',
|
|
433
|
+
name: 'huge.log',
|
|
434
|
+
contentType: 'text/plain',
|
|
435
|
+
url: 'https://cdn.discordapp.com/attachments/1/2/huge.log',
|
|
436
|
+
size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1,
|
|
437
|
+
},
|
|
438
|
+
]));
|
|
439
|
+
const savedPath = path.join(getDataDir(), 'attachments', 'att2-huge.log');
|
|
440
|
+
expect(fs.readFileSync(savedPath, 'utf8')).toBe('SHOULD NOT BE INLINED');
|
|
441
|
+
expect(result).not.toContain('SHOULD NOT BE INLINED');
|
|
442
|
+
expect(result).toContain('https://cdn.discordapp.com/attachments/1/2/huge.log');
|
|
443
|
+
expect(result).toContain(savedPath);
|
|
444
|
+
expect(result).toContain('text/plain');
|
|
445
|
+
expect(result).toContain(String(TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1));
|
|
446
|
+
expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
|
|
447
|
+
"<attachment filename="huge.log" mime="text/plain" size="65537" url="https://cdn.discordapp.com/attachments/1/2/huge.log" path="<dataDir>/attachments/att2-huge.log" large="true">
|
|
448
|
+
This file is large (64 KB, text/plain). Contents were not inlined to save context. Read the local path.
|
|
449
|
+
</attachment>"
|
|
450
|
+
`);
|
|
451
|
+
});
|
|
452
|
+
test('still inlines large prompt.md attachments from kimaki send', async () => {
|
|
453
|
+
stubFetch(async () => {
|
|
454
|
+
return new Response('the actual long prompt', { status: 200 });
|
|
455
|
+
});
|
|
456
|
+
const result = await getTextAttachments(messageWithAttachments([
|
|
457
|
+
{
|
|
458
|
+
id: 'att3',
|
|
459
|
+
name: 'prompt.md',
|
|
460
|
+
contentType: 'text/markdown',
|
|
461
|
+
url: 'https://cdn.example/prompt.md',
|
|
462
|
+
size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1,
|
|
463
|
+
},
|
|
464
|
+
]));
|
|
465
|
+
const savedPath = path.join(getDataDir(), 'attachments', 'att3-prompt.md');
|
|
466
|
+
expect(fs.readFileSync(savedPath, 'utf8')).toBe('the actual long prompt');
|
|
467
|
+
expect(result).toContain('the actual long prompt');
|
|
468
|
+
expect(result).toContain('https://cdn.example/prompt.md');
|
|
469
|
+
expect(result).toContain(savedPath);
|
|
470
|
+
expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
|
|
471
|
+
"<attachment filename="prompt.md" mime="text/markdown" size="65537" url="https://cdn.example/prompt.md" path="<dataDir>/attachments/att3-prompt.md">
|
|
472
|
+
the actual long prompt
|
|
473
|
+
</attachment>"
|
|
474
|
+
`);
|
|
475
|
+
});
|
|
476
|
+
test('keeps small files inlined next to large file references', async () => {
|
|
477
|
+
stubFetch(async (url) => {
|
|
478
|
+
if (url.includes('small.txt')) {
|
|
479
|
+
return new Response('tiny', { status: 200 });
|
|
480
|
+
}
|
|
481
|
+
return new Response('SHOULD NOT BE INLINED', { status: 200 });
|
|
482
|
+
});
|
|
483
|
+
const result = await getTextAttachments(messageWithAttachments([
|
|
484
|
+
{
|
|
485
|
+
id: 'att4',
|
|
486
|
+
name: 'small.txt',
|
|
487
|
+
contentType: 'text/plain',
|
|
488
|
+
url: 'https://cdn.example/small.txt',
|
|
489
|
+
size: 4,
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
id: 'att5',
|
|
493
|
+
name: 'dump.json',
|
|
494
|
+
contentType: 'application/json',
|
|
495
|
+
url: 'https://cdn.example/dump.json',
|
|
496
|
+
size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 50,
|
|
497
|
+
},
|
|
498
|
+
]));
|
|
499
|
+
expect(result).toContain('tiny');
|
|
500
|
+
expect(result).not.toContain('SHOULD NOT BE INLINED');
|
|
501
|
+
expect(result).toContain('https://cdn.example/small.txt');
|
|
502
|
+
expect(result).toContain('https://cdn.example/dump.json');
|
|
503
|
+
expect(result).toContain(path.join(getDataDir(), 'attachments', 'att4-small.txt'));
|
|
504
|
+
expect(result).toContain(path.join(getDataDir(), 'attachments', 'att5-dump.json'));
|
|
505
|
+
expect(result).toContain('application/json');
|
|
506
|
+
expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
|
|
507
|
+
"<attachment filename="small.txt" mime="text/plain" size="4" url="https://cdn.example/small.txt" path="<dataDir>/attachments/att4-small.txt">
|
|
508
|
+
tiny
|
|
509
|
+
</attachment>
|
|
510
|
+
|
|
511
|
+
<attachment filename="dump.json" mime="application/json" size="65586" url="https://cdn.example/dump.json" path="<dataDir>/attachments/att5-dump.json" large="true">
|
|
512
|
+
This file is large (64 KB, application/json). Contents were not inlined to save context. Read the local path.
|
|
513
|
+
</attachment>"
|
|
514
|
+
`);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
@@ -10,9 +10,9 @@ import { DiscordOperationError, OpenCodeSdkError } from './errors.js';
|
|
|
10
10
|
import { resolveMentions, getFileAttachments, getTextAttachments, } from './message-formatting.js';
|
|
11
11
|
import { processVoiceAttachment } from './voice-handler.js';
|
|
12
12
|
import { isVoiceAttachment } from './voice-attachment.js';
|
|
13
|
-
import {
|
|
13
|
+
import { initializeOpencodeForDirectory } from './opencode.js';
|
|
14
14
|
import { getCompactSessionContext, getLastSessionId } from './markdown.js';
|
|
15
|
-
import { getThreadSession
|
|
15
|
+
import { getThreadSession } from './database.js';
|
|
16
16
|
import * as errore from 'errore';
|
|
17
17
|
import { createLogger, LogPrefix } from './logger.js';
|
|
18
18
|
import { notifyError } from './sentry.js';
|
|
@@ -88,23 +88,6 @@ async function getRepliedMessageContext({ message, }) {
|
|
|
88
88
|
text: repliedText,
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
-
export async function getChannelReferencePermissionRules({ message, }) {
|
|
92
|
-
const channelIds = [...message.mentions.channels.keys()];
|
|
93
|
-
if (channelIds.length === 0) {
|
|
94
|
-
return [];
|
|
95
|
-
}
|
|
96
|
-
const mentionedChannelIds = new Set(channelIds);
|
|
97
|
-
const referencedChannels = (await listTrackedTextChannels()).filter((channel) => {
|
|
98
|
-
return mentionedChannelIds.has(channel.channel_id);
|
|
99
|
-
});
|
|
100
|
-
const resolvedDirectories = new Set(referencedChannels.map((channel) => channel.directory.replaceAll('\\', '/')));
|
|
101
|
-
return [...resolvedDirectories].flatMap((directory) => {
|
|
102
|
-
return buildExternalDirectoryPermissionRules({
|
|
103
|
-
resolvedPattern: directory,
|
|
104
|
-
action: 'allow',
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
91
|
/**
|
|
109
92
|
* Pre-process a message in an existing thread (thread already has a session or
|
|
110
93
|
* needs a new one). Handles voice transcription, text/file attachments, and
|
|
@@ -188,7 +171,6 @@ export async function preprocessExistingThreadMessage({ message, thread, project
|
|
|
188
171
|
lastSessionContext,
|
|
189
172
|
agents,
|
|
190
173
|
});
|
|
191
|
-
const permissionRules = await getChannelReferencePermissionRules({ message });
|
|
192
174
|
if (voiceResult) {
|
|
193
175
|
messageContent = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`;
|
|
194
176
|
}
|
|
@@ -217,7 +199,6 @@ export async function preprocessExistingThreadMessage({ message, thread, project
|
|
|
217
199
|
prompt,
|
|
218
200
|
images: fileAttachments.length > 0 ? fileAttachments : undefined,
|
|
219
201
|
repliedMessage,
|
|
220
|
-
permissionRules,
|
|
221
202
|
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
|
222
203
|
agent: voiceResult?.agent,
|
|
223
204
|
};
|
|
@@ -243,7 +224,6 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
|
|
|
243
224
|
}
|
|
244
225
|
let prompt = resolveMentions(message);
|
|
245
226
|
const repliedMessage = await getRepliedMessageContext({ message });
|
|
246
|
-
const permissionRules = await getChannelReferencePermissionRules({ message });
|
|
247
227
|
const voiceResult = await processVoiceAttachment({
|
|
248
228
|
message,
|
|
249
229
|
thread,
|
|
@@ -293,7 +273,6 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
|
|
|
293
273
|
prompt: finalPrompt,
|
|
294
274
|
images: fileAttachments.length > 0 ? fileAttachments : undefined,
|
|
295
275
|
repliedMessage,
|
|
296
|
-
permissionRules,
|
|
297
276
|
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
|
298
277
|
agent: voiceResult?.agent,
|
|
299
278
|
};
|
|
@@ -317,7 +296,6 @@ export async function preprocessNewThreadMessage({ message, thread, projectDirec
|
|
|
317
296
|
}
|
|
318
297
|
let messageContent = resolveMentions(message);
|
|
319
298
|
const repliedMessage = await getRepliedMessageContext({ message });
|
|
320
|
-
const permissionRules = await getChannelReferencePermissionRules({ message });
|
|
321
299
|
const voiceResult = await processVoiceAttachment({
|
|
322
300
|
message,
|
|
323
301
|
thread,
|
|
@@ -353,7 +331,6 @@ export async function preprocessNewThreadMessage({ message, thread, projectDirec
|
|
|
353
331
|
prompt,
|
|
354
332
|
images: fileAttachments.length > 0 ? fileAttachments : undefined,
|
|
355
333
|
repliedMessage,
|
|
356
|
-
permissionRules,
|
|
357
334
|
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
|
358
335
|
agent: voiceResult?.agent,
|
|
359
336
|
};
|
|
@@ -178,12 +178,16 @@ export function shouldRotateAuth(status, bodyText) {
|
|
|
178
178
|
return true;
|
|
179
179
|
if (status === 401 || status === 403)
|
|
180
180
|
return true;
|
|
181
|
+
// xAI returns 402 Payment Required when Grok Build usage balance is exhausted
|
|
182
|
+
if (status === 402)
|
|
183
|
+
return true;
|
|
181
184
|
return (haystack.includes('rate_limit') ||
|
|
182
185
|
haystack.includes('rate limit') ||
|
|
183
186
|
haystack.includes('usage limit') ||
|
|
184
187
|
haystack.includes('usage_limit') ||
|
|
185
188
|
haystack.includes('usage_limit_reached') ||
|
|
186
189
|
haystack.includes('usage_not_included') ||
|
|
190
|
+
haystack.includes('balance exhausted') ||
|
|
187
191
|
haystack.includes('invalid api key') ||
|
|
188
192
|
haystack.includes('authentication_error') ||
|
|
189
193
|
haystack.includes('permission_error'));
|
|
@@ -191,11 +195,13 @@ export function shouldRotateAuth(status, bodyText) {
|
|
|
191
195
|
export function isRateLimitRetryMessage(message) {
|
|
192
196
|
const haystack = message.toLowerCase();
|
|
193
197
|
return (haystack.includes('429') ||
|
|
198
|
+
haystack.includes('402') ||
|
|
194
199
|
haystack.includes('usage limit') ||
|
|
195
200
|
haystack.includes('rate limit') ||
|
|
196
201
|
haystack.includes('rate_limit') ||
|
|
197
202
|
haystack.includes('usage_limit_reached') ||
|
|
198
|
-
haystack.includes('usage_not_included')
|
|
203
|
+
haystack.includes('usage_not_included') ||
|
|
204
|
+
haystack.includes('balance exhausted'));
|
|
199
205
|
}
|
|
200
206
|
export function isTokenRefreshError(message) {
|
|
201
207
|
const haystack = message.toLowerCase();
|
|
@@ -134,6 +134,26 @@ export async function detectAndRememberNewOpenAIAccount() {
|
|
|
134
134
|
}
|
|
135
135
|
return undefined;
|
|
136
136
|
}
|
|
137
|
+
// Check if the identity matches an existing account even though tokens
|
|
138
|
+
// don't match (happens after token refresh rotates both tokens).
|
|
139
|
+
// Without this check we'd show a false "account added" toast.
|
|
140
|
+
if (identity.email || identity.accountId) {
|
|
141
|
+
const matchesByIdentity = store.accounts.some((account) => {
|
|
142
|
+
if (identity.accountId && account.accountId === identity.accountId)
|
|
143
|
+
return true;
|
|
144
|
+
if (identity.email && account.email?.toLowerCase() === identity.email.toLowerCase())
|
|
145
|
+
return true;
|
|
146
|
+
return false;
|
|
147
|
+
});
|
|
148
|
+
if (matchesByIdentity) {
|
|
149
|
+
await withAuthStateLock(async () => {
|
|
150
|
+
const freshStore = await loadOpenAIAccountStore();
|
|
151
|
+
upsertAccount(freshStore, { ...auth, ...identity });
|
|
152
|
+
await saveOpenAIAccountStore(freshStore);
|
|
153
|
+
});
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
137
157
|
// New account: upsert with identity
|
|
138
158
|
await withAuthStateLock(async () => {
|
|
139
159
|
const freshStore = await loadOpenAIAccountStore();
|