kimaki 0.25.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/commands/agent.js +87 -74
- package/dist/commands/model.js +18 -0
- package/dist/commands/multioauth.js +169 -1
- package/dist/commands/tasks.js +6 -0
- package/dist/commands/unset-model.js +5 -20
- package/dist/database.js +65 -0
- package/dist/discord-bot.js +7 -1
- 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/oauth-rotation-shared.js +7 -1
- package/dist/openai-auth-state.js +20 -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 +90 -37
- 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/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/commands/agent.ts +120 -84
- package/src/commands/model.ts +25 -0
- package/src/commands/multioauth.ts +195 -2
- package/src/commands/tasks.ts +6 -0
- package/src/commands/unset-model.ts +5 -21
- package/src/database.ts +124 -0
- package/src/discord-bot.ts +7 -1
- 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/oauth-rotation-shared.ts +6 -1
- package/src/openai-auth-state.ts +19 -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-session-runtime.ts +105 -44
- 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/xai-auth-plugin.ts +302 -0
- package/src/xai-auth-state.test.ts +224 -0
- package/src/xai-auth-state.ts +293 -0
|
@@ -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
|
+
});
|
|
@@ -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();
|
package/dist/schema.js
CHANGED
|
@@ -164,6 +164,20 @@ export const scheduled_tasks = sqliteCore.sqliteTable('scheduled_tasks', {
|
|
|
164
164
|
sqliteCore.index('scheduled_tasks_channel_id_status_idx').on(table.channel_id, table.status),
|
|
165
165
|
sqliteCore.index('scheduled_tasks_thread_id_status_idx').on(table.thread_id, table.status),
|
|
166
166
|
]);
|
|
167
|
+
export const scheduled_task_runs = sqliteCore.sqliteTable('scheduled_task_runs', {
|
|
168
|
+
id: sqliteCore.integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }).notNull(),
|
|
169
|
+
scheduled_task_id: sqliteCore.integer('scheduled_task_id', { mode: 'number' }).notNull().references(() => scheduled_tasks.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
|
170
|
+
status: sqliteCore.text('status', { enum: ['pending', 'running', 'completed', 'skipped', 'failed'] }).notNull().default('pending'),
|
|
171
|
+
thread_id: sqliteCore.text('thread_id'),
|
|
172
|
+
session_id: sqliteCore.text('session_id'),
|
|
173
|
+
project_directory: sqliteCore.text('project_directory'),
|
|
174
|
+
started_at: datetime('started_at').notNull(),
|
|
175
|
+
completed_at: datetime('completed_at'),
|
|
176
|
+
error: sqliteCore.text('error'),
|
|
177
|
+
}, (table) => [
|
|
178
|
+
sqliteCore.index('scheduled_task_runs_task_status_idx').on(table.scheduled_task_id, table.status),
|
|
179
|
+
sqliteCore.index('scheduled_task_runs_session_status_idx').on(table.session_id, table.status),
|
|
180
|
+
]);
|
|
167
181
|
export const session_start_sources = sqliteCore.sqliteTable('session_start_sources', {
|
|
168
182
|
session_id: sqliteCore.text('session_id').primaryKey().notNull(),
|
|
169
183
|
schedule_kind: sqliteCore.text('schedule_kind', { enum: ['at', 'cron'] }).notNull(),
|
|
@@ -215,6 +229,7 @@ export const relations = defineRelations({
|
|
|
215
229
|
channel_mention_mode,
|
|
216
230
|
global_models,
|
|
217
231
|
scheduled_tasks,
|
|
232
|
+
scheduled_task_runs,
|
|
218
233
|
session_start_sources,
|
|
219
234
|
forum_sync_configs,
|
|
220
235
|
ipc_requests,
|
|
@@ -278,8 +293,12 @@ export const relations = defineRelations({
|
|
|
278
293
|
scheduled_tasks: {
|
|
279
294
|
channel: r.one.channel_directories({ from: r.scheduled_tasks.channel_id, to: r.channel_directories.channel_id }),
|
|
280
295
|
thread: r.one.thread_sessions({ from: r.scheduled_tasks.thread_id, to: r.thread_sessions.thread_id }),
|
|
296
|
+
runs: r.many.scheduled_task_runs(),
|
|
281
297
|
session_start_sources: r.many.session_start_sources(),
|
|
282
298
|
},
|
|
299
|
+
scheduled_task_runs: {
|
|
300
|
+
scheduled_task: r.one.scheduled_tasks({ from: r.scheduled_task_runs.scheduled_task_id, to: r.scheduled_tasks.id }),
|
|
301
|
+
},
|
|
283
302
|
session_start_sources: {
|
|
284
303
|
scheduled_task: r.one.scheduled_tasks({ from: r.session_start_sources.scheduled_task_id, to: r.scheduled_tasks.id }),
|
|
285
304
|
},
|
|
@@ -184,6 +184,153 @@ export function getCurrentTurnStartTime({ events, sessionId, upToIndex, }) {
|
|
|
184
184
|
function getTokenTotal(tokens) {
|
|
185
185
|
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write;
|
|
186
186
|
}
|
|
187
|
+
function emptyTurnTokenUsage() {
|
|
188
|
+
return {
|
|
189
|
+
input: 0,
|
|
190
|
+
output: 0,
|
|
191
|
+
reasoning: 0,
|
|
192
|
+
cacheRead: 0,
|
|
193
|
+
cacheWrite: 0,
|
|
194
|
+
total: 0,
|
|
195
|
+
cost: 0,
|
|
196
|
+
model: undefined,
|
|
197
|
+
providerID: undefined,
|
|
198
|
+
assistantMessageCount: 0,
|
|
199
|
+
userMessageId: undefined,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// Latest billed token snapshot for the current user turn.
|
|
203
|
+
// Sums the last message.updated tokens per assistant message id so streaming
|
|
204
|
+
// updates are not double-counted. Scoped to sessionId so subagent idles
|
|
205
|
+
// report their own usage.
|
|
206
|
+
export function getLatestTurnTokenUsage({ events, sessionId, upToIndex, }) {
|
|
207
|
+
const latestUserMessage = getLatestUserMessage({
|
|
208
|
+
events,
|
|
209
|
+
sessionId,
|
|
210
|
+
upToIndex,
|
|
211
|
+
});
|
|
212
|
+
if (!latestUserMessage) {
|
|
213
|
+
return emptyTurnTokenUsage();
|
|
214
|
+
}
|
|
215
|
+
const end = upToIndex ?? events.length - 1;
|
|
216
|
+
const latestByMessageId = new Map();
|
|
217
|
+
for (let i = 0; i <= end; i++) {
|
|
218
|
+
const entry = events[i];
|
|
219
|
+
if (!entry) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const event = entry.event;
|
|
223
|
+
if (event.type !== 'message.updated') {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const info = event.properties.info;
|
|
227
|
+
if (info.sessionID !== sessionId || info.role !== 'assistant') {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (info.parentID !== latestUserMessage.id) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
latestByMessageId.set(info.id, info);
|
|
234
|
+
}
|
|
235
|
+
if (latestByMessageId.size === 0) {
|
|
236
|
+
return {
|
|
237
|
+
...emptyTurnTokenUsage(),
|
|
238
|
+
userMessageId: latestUserMessage?.id,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const usage = emptyTurnTokenUsage();
|
|
242
|
+
usage.userMessageId = latestUserMessage.id;
|
|
243
|
+
usage.assistantMessageCount = latestByMessageId.size;
|
|
244
|
+
for (const message of latestByMessageId.values()) {
|
|
245
|
+
if (message.tokens) {
|
|
246
|
+
usage.input += message.tokens.input;
|
|
247
|
+
usage.output += message.tokens.output;
|
|
248
|
+
usage.reasoning += message.tokens.reasoning;
|
|
249
|
+
usage.cacheRead += message.tokens.cache.read;
|
|
250
|
+
usage.cacheWrite += message.tokens.cache.write;
|
|
251
|
+
usage.total += message.tokens.total ?? getTokenTotal(message.tokens);
|
|
252
|
+
}
|
|
253
|
+
usage.cost += message.cost;
|
|
254
|
+
usage.model = message.modelID;
|
|
255
|
+
usage.providerID = message.providerID;
|
|
256
|
+
}
|
|
257
|
+
return usage;
|
|
258
|
+
}
|
|
259
|
+
function findFirstUserMessageIndex({ events, userMessageId, upToIndex, }) {
|
|
260
|
+
for (let i = 0; i <= upToIndex; i++) {
|
|
261
|
+
const event = events[i]?.event;
|
|
262
|
+
if (event?.type !== 'message.updated') {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (event.properties.info.id === userMessageId) {
|
|
266
|
+
return i;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
function findPreviousIdleIndexInTurn({ events, sessionId, firstUserMessageIndex, beforeIndex, }) {
|
|
272
|
+
for (let i = beforeIndex - 1; i > firstUserMessageIndex; i--) {
|
|
273
|
+
const event = events[i]?.event;
|
|
274
|
+
if (event?.type === 'session.idle' && event.properties.sessionID === sessionId) {
|
|
275
|
+
return i;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
function subtractTokenUsage({ current, previous, }) {
|
|
281
|
+
return {
|
|
282
|
+
input: current.input - previous.input,
|
|
283
|
+
output: current.output - previous.output,
|
|
284
|
+
reasoning: current.reasoning - previous.reasoning,
|
|
285
|
+
cacheRead: current.cacheRead - previous.cacheRead,
|
|
286
|
+
cacheWrite: current.cacheWrite - previous.cacheWrite,
|
|
287
|
+
total: current.total - previous.total,
|
|
288
|
+
cost: current.cost - previous.cost,
|
|
289
|
+
model: current.model,
|
|
290
|
+
providerID: current.providerID,
|
|
291
|
+
assistantMessageCount: current.assistantMessageCount,
|
|
292
|
+
userMessageId: current.userMessageId,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
// Tokens billed since the previous session.idle in this user turn.
|
|
296
|
+
// Survives process restart because both idles stay in the event buffer.
|
|
297
|
+
export function getIdleTokenUsageDelta({ events, sessionId, idleEventIndex, }) {
|
|
298
|
+
const current = getLatestTurnTokenUsage({
|
|
299
|
+
events,
|
|
300
|
+
sessionId,
|
|
301
|
+
upToIndex: idleEventIndex,
|
|
302
|
+
});
|
|
303
|
+
if (!current.userMessageId) {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
const firstUserMessageIndex = findFirstUserMessageIndex({
|
|
307
|
+
events,
|
|
308
|
+
userMessageId: current.userMessageId,
|
|
309
|
+
upToIndex: idleEventIndex,
|
|
310
|
+
});
|
|
311
|
+
if (firstUserMessageIndex === undefined) {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
const previousIdleIndex = findPreviousIdleIndexInTurn({
|
|
315
|
+
events,
|
|
316
|
+
sessionId,
|
|
317
|
+
firstUserMessageIndex,
|
|
318
|
+
beforeIndex: idleEventIndex,
|
|
319
|
+
});
|
|
320
|
+
if (previousIdleIndex === undefined) {
|
|
321
|
+
return current.total > 0 ? current : undefined;
|
|
322
|
+
}
|
|
323
|
+
const previous = getLatestTurnTokenUsage({
|
|
324
|
+
events,
|
|
325
|
+
sessionId,
|
|
326
|
+
upToIndex: previousIdleIndex,
|
|
327
|
+
});
|
|
328
|
+
const delta = subtractTokenUsage({ current, previous });
|
|
329
|
+
if (delta.total <= 0) {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
return delta;
|
|
333
|
+
}
|
|
187
334
|
// Scans backward for most recent message.updated with role=assistant for sessionId.
|
|
188
335
|
// Extracts model, providerID, agent, tokensUsed.
|
|
189
336
|
export function getLatestRunInfo({ events, sessionId, upToIndex, }) {
|