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.
Files changed (70) hide show
  1. package/dist/agent-model.e2e.test.js +135 -3
  2. package/dist/analytics.test.js +30 -1
  3. package/dist/cli-commands/send.js +13 -3
  4. package/dist/cli-commands/task.js +26 -7
  5. package/dist/cli-runner.js +66 -70
  6. package/dist/cli-runner.test.js +10 -1
  7. package/dist/commands/agent.js +87 -74
  8. package/dist/commands/model.js +18 -0
  9. package/dist/commands/multioauth.js +169 -1
  10. package/dist/commands/tasks.js +6 -0
  11. package/dist/commands/unset-model.js +5 -20
  12. package/dist/database.js +65 -0
  13. package/dist/discord-bot.js +7 -1
  14. package/dist/ipc-tools-plugin.js +4 -3
  15. package/dist/kimaki-opencode-plugin.js +2 -0
  16. package/dist/message-formatting.js +84 -5
  17. package/dist/message-formatting.test.js +160 -2
  18. package/dist/oauth-rotation-shared.js +7 -1
  19. package/dist/openai-auth-state.js +20 -0
  20. package/dist/schema.js +19 -0
  21. package/dist/session-handler/event-stream-state.js +147 -0
  22. package/dist/session-handler/event-stream-state.test.js +437 -1
  23. package/dist/session-handler/thread-session-runtime.js +90 -37
  24. package/dist/subagent-rate-limit-plugin.js +13 -2
  25. package/dist/system-message.js +18 -4
  26. package/dist/system-message.test.js +28 -4
  27. package/dist/task-runner.js +143 -12
  28. package/dist/task-schedule.js +12 -0
  29. package/dist/task-schedule.test.js +149 -2
  30. package/dist/xai-auth-plugin.js +237 -0
  31. package/dist/xai-auth-state.js +233 -0
  32. package/dist/xai-auth-state.test.js +183 -0
  33. package/package.json +6 -6
  34. package/skills/profano/SKILL.md +5 -3
  35. package/skills/spiceflow/SKILL.md +24 -1
  36. package/src/agent-model.e2e.test.ts +172 -4
  37. package/src/analytics.test.ts +30 -1
  38. package/src/analytics.ts +1 -0
  39. package/src/cli-commands/send.ts +22 -3
  40. package/src/cli-commands/task.ts +32 -19
  41. package/src/cli-runner.test.ts +16 -1
  42. package/src/cli-runner.ts +88 -89
  43. package/src/commands/agent.ts +120 -84
  44. package/src/commands/model.ts +25 -0
  45. package/src/commands/multioauth.ts +195 -2
  46. package/src/commands/tasks.ts +6 -0
  47. package/src/commands/unset-model.ts +5 -21
  48. package/src/database.ts +124 -0
  49. package/src/discord-bot.ts +7 -1
  50. package/src/ipc-tools-plugin.ts +4 -3
  51. package/src/kimaki-opencode-plugin.ts +2 -0
  52. package/src/message-formatting.test.ts +197 -2
  53. package/src/message-formatting.ts +98 -5
  54. package/src/oauth-rotation-shared.ts +6 -1
  55. package/src/openai-auth-state.ts +19 -0
  56. package/src/schema.sql +15 -0
  57. package/src/schema.ts +20 -0
  58. package/src/session-handler/event-stream-state.test.ts +490 -0
  59. package/src/session-handler/event-stream-state.ts +210 -0
  60. package/src/session-handler/model-utils.ts +1 -0
  61. package/src/session-handler/thread-session-runtime.ts +105 -44
  62. package/src/subagent-rate-limit-plugin.ts +12 -2
  63. package/src/system-message.test.ts +29 -4
  64. package/src/system-message.ts +20 -4
  65. package/src/task-runner.ts +168 -11
  66. package/src/task-schedule.test.ts +172 -2
  67. package/src/task-schedule.ts +23 -2
  68. package/src/xai-auth-plugin.ts +302 -0
  69. package/src/xai-auth-state.test.ts +224 -0
  70. 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 filename="${attachment.name}" error="${response.message}" />`;
264
+ return `<attachment ${textAttachmentAttrs(attachment)} error="${response.message}" />`;
223
265
  }
224
266
  if (!response.ok) {
225
- return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`;
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 text = await response.text();
228
- return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`;
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 { describe, test, expect } from 'vitest';
2
- import { formatBashToolTitle, formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js';
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, }) {