kimaki 0.30.0 → 0.31.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 (124) hide show
  1. package/dist/agent-model.e2e.test.js +27 -19
  2. package/dist/analytics.js +3 -1
  3. package/dist/btw-prefix-detection.js +9 -0
  4. package/dist/btw-prefix-detection.test.js +38 -1
  5. package/dist/cli-commands/send.js +5 -11
  6. package/dist/cli-runner.js +22 -10
  7. package/dist/cli-runner.test.js +34 -1
  8. package/dist/cli-send-thread.e2e.test.js +1 -1
  9. package/dist/commands/abort.js +16 -4
  10. package/dist/commands/btw.js +147 -39
  11. package/dist/commands/context-usage.js +47 -0
  12. package/dist/commands/context-usage.test.js +49 -0
  13. package/dist/commands/fork.js +32 -1
  14. package/dist/commands/model.js +69 -14
  15. package/dist/commands/new-worktree.js +5 -2
  16. package/dist/commands/queue.js +37 -30
  17. package/dist/commands/session.js +7 -2
  18. package/dist/db.test.js +17 -7
  19. package/dist/discord-bot.js +70 -47
  20. package/dist/discord-utils.js +15 -1
  21. package/dist/errors.js +10 -5
  22. package/dist/external-directory-permissions.e2e.test.js +4 -4
  23. package/dist/external-directory-project-deny.e2e.test.js +2 -2
  24. package/dist/gateway-proxy.e2e.test.js +5 -5
  25. package/dist/message-formatting.js +50 -15
  26. package/dist/message-formatting.test.js +12 -11
  27. package/dist/message-preprocessing.js +53 -75
  28. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  29. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  30. package/dist/queue-advanced-abort.e2e.test.js +62 -6
  31. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  32. package/dist/queue-advanced-footer.e2e.test.js +8 -8
  33. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  34. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  35. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  36. package/dist/queue-delete-message.e2e.test.js +146 -5
  37. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  38. package/dist/queue-interrupt-drain.e2e.test.js +24 -14
  39. package/dist/queue-question-select-drain.e2e.test.js +27 -27
  40. package/dist/runtime-lifecycle.e2e.test.js +6 -6
  41. package/dist/session-handler/agent-utils.js +16 -17
  42. package/dist/session-handler/event-stream-state.js +24 -16
  43. package/dist/session-handler/event-stream-state.test.js +281 -14
  44. package/dist/session-handler/thread-session-runtime.js +209 -128
  45. package/dist/sleep.e2e.test.js +6 -6
  46. package/dist/system-message.js +214 -392
  47. package/dist/system-message.test.js +204 -380
  48. package/dist/task-runner.js +3 -3
  49. package/dist/task-schedule.js +2 -2
  50. package/dist/test-utils.js +32 -6
  51. package/dist/thread-message-queue.e2e.test.js +132 -60
  52. package/dist/undo-redo.e2e.test.js +3 -3
  53. package/dist/voice-handler.js +2 -1
  54. package/dist/voice-message.e2e.test.js +22 -31
  55. package/dist/voice-routing-input.e2e.test.js +2 -2
  56. package/dist/voice-routing-question.e2e.test.js +59 -2
  57. package/dist/voice.js +306 -206
  58. package/dist/voice.test.js +161 -13
  59. package/dist/worktree-lifecycle.e2e.test.js +7 -7
  60. package/package.json +6 -9
  61. package/skills/critique/SKILL.md +4 -0
  62. package/skills/holocron/SKILL.md +0 -28
  63. package/skills/opensrc/SKILL.md +20 -13
  64. package/skills/playwriter/SKILL.md +1 -1
  65. package/src/agent-model.e2e.test.ts +28 -19
  66. package/src/analytics.ts +3 -1
  67. package/src/btw-prefix-detection.test.ts +39 -1
  68. package/src/btw-prefix-detection.ts +11 -0
  69. package/src/cli-commands/send.ts +11 -11
  70. package/src/cli-runner.test.ts +37 -0
  71. package/src/cli-runner.ts +26 -10
  72. package/src/cli-send-thread.e2e.test.ts +1 -1
  73. package/src/commands/abort.ts +20 -4
  74. package/src/commands/btw.ts +151 -41
  75. package/src/commands/context-usage.test.ts +57 -0
  76. package/src/commands/context-usage.ts +59 -0
  77. package/src/commands/fork.ts +34 -1
  78. package/src/commands/model.ts +81 -14
  79. package/src/commands/new-worktree.ts +5 -2
  80. package/src/commands/queue.ts +46 -30
  81. package/src/commands/session.ts +10 -2
  82. package/src/db.test.ts +18 -6
  83. package/src/discord-bot.ts +72 -56
  84. package/src/discord-utils.ts +17 -1
  85. package/src/errors.ts +12 -6
  86. package/src/external-directory-permissions.e2e.test.ts +4 -4
  87. package/src/external-directory-project-deny.e2e.test.ts +2 -2
  88. package/src/gateway-proxy.e2e.test.ts +5 -5
  89. package/src/message-formatting.test.ts +12 -11
  90. package/src/message-formatting.ts +52 -16
  91. package/src/message-preprocessing.ts +69 -91
  92. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  93. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  94. package/src/queue-advanced-abort.e2e.test.ts +73 -6
  95. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  96. package/src/queue-advanced-footer.e2e.test.ts +8 -8
  97. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  98. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  99. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  100. package/src/queue-delete-message.e2e.test.ts +164 -4
  101. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  102. package/src/queue-interrupt-drain.e2e.test.ts +24 -13
  103. package/src/queue-question-select-drain.e2e.test.ts +31 -28
  104. package/src/runtime-lifecycle.e2e.test.ts +6 -6
  105. package/src/session-handler/agent-utils.ts +25 -20
  106. package/src/session-handler/event-stream-state.test.ts +284 -13
  107. package/src/session-handler/event-stream-state.ts +37 -21
  108. package/src/session-handler/thread-runtime-state.ts +7 -0
  109. package/src/session-handler/thread-session-runtime.ts +255 -155
  110. package/src/sleep.e2e.test.ts +6 -6
  111. package/src/system-message.test.ts +216 -379
  112. package/src/system-message.ts +253 -389
  113. package/src/task-runner.ts +3 -3
  114. package/src/task-schedule.ts +2 -2
  115. package/src/test-utils.ts +55 -12
  116. package/src/thread-message-queue.e2e.test.ts +136 -63
  117. package/src/undo-redo.e2e.test.ts +3 -3
  118. package/src/voice-handler.ts +2 -1
  119. package/src/voice-message.e2e.test.ts +22 -33
  120. package/src/voice-routing-input.e2e.test.ts +2 -2
  121. package/src/voice-routing-question.e2e.test.ts +59 -2
  122. package/src/voice.test.ts +164 -14
  123. package/src/voice.ts +400 -279
  124. package/src/worktree-lifecycle.e2e.test.ts +7 -7
@@ -5,7 +5,8 @@ import { deleteThreadQueueItem, getThreadSession } from '../database.js';
5
5
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
6
6
  import { getOrCreateRuntime, getRuntime, } from '../session-handler/thread-session-runtime.js';
7
7
  import { createLogger, LogPrefix } from '../logger.js';
8
- import { asSystemLine, QUEUE_PREFIX } from '../message-formatting.js';
8
+ import { asSubtext, QUEUE_PREFIX } from '../message-formatting.js';
9
+ import { extractBtwQueueSuffix } from '../btw-prefix-detection.js';
9
10
  import { store } from '../store.js';
10
11
  const logger = createLogger(LogPrefix.QUEUE);
11
12
  export const QUEUE_REMOVE_CUSTOM_ID_PREFIX = 'queue_remove:';
@@ -52,6 +53,19 @@ function buildQueueRemoveRow({ threadId, queueId, }) {
52
53
  .setLabel('Remove from queue')
53
54
  .setStyle(ButtonStyle.Secondary));
54
55
  }
56
+ // Replies "Queued message" and returns the ack id, so the drain indicator can
57
+ // reply to it. The runtime calls this inside the serialized enqueue.
58
+ function replyQueuedAck({ command, threadId, }) {
59
+ return async ({ queueId, position }) => {
60
+ await command.reply({
61
+ content: asSubtext(`Queued message (position ${position})`),
62
+ components: [buildQueueRemoveRow({ threadId, queueId })],
63
+ flags: SILENT_MESSAGE_FLAGS,
64
+ });
65
+ const ack = await command.fetchReply();
66
+ return ack.id;
67
+ };
68
+ }
55
69
  export async function handleQueueRemoveButton(interaction) {
56
70
  const parsed = parseQueueRemoveCustomId(interaction.customId);
57
71
  if (!parsed) {
@@ -75,7 +89,7 @@ export async function handleQueueRemoveButton(interaction) {
75
89
  : await deleteThreadQueueItemWithoutRuntime(parsed.queueId);
76
90
  if (!removed) {
77
91
  await interaction.editReply({
78
- content: asSystemLine('Queued message is no longer in the queue'),
92
+ content: asSubtext('Queued message is no longer in the queue'),
79
93
  components: [],
80
94
  });
81
95
  return;
@@ -84,14 +98,24 @@ export async function handleQueueRemoveButton(interaction) {
84
98
  ? `/${removed.command.name}`
85
99
  : removed.prompt.slice(0, 120);
86
100
  await interaction.editReply({
87
- content: asSystemLine(`Removed queued message: ${label}`),
101
+ content: asSubtext(`Removed queued message: ${label}`),
88
102
  components: [],
89
103
  });
90
104
  logger.log(`[QUEUE] User ${interaction.user.displayName} removed queued item ${parsed.queueId} in thread ${parsed.threadId}`);
91
105
  }
92
106
  export async function handleQueueCommand({ command, appId, }) {
93
- const message = command.options.getString('message', true);
107
+ // /queue always queues; a typed suffix is redundant, so only strip it.
108
+ const rawMessage = command.options.getString('message', true);
109
+ const btw = extractBtwQueueSuffix(rawMessage);
110
+ const message = btw.prompt;
94
111
  const channel = command.channel;
112
+ if (!message.trim()) {
113
+ await command.reply({
114
+ content: 'Write a message to queue',
115
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
116
+ });
117
+ return;
118
+ }
95
119
  if (!channel) {
96
120
  await command.reply({
97
121
  content: 'This command can only be used in a channel',
@@ -139,23 +163,15 @@ export async function handleQueueCommand({ command, appId, }) {
139
163
  // /queue explicitly uses kimaki local queue mode.
140
164
  const enqueueResult = await runtime.enqueueIncoming({
141
165
  prompt: message,
166
+ queuedAction: btw.forceBtw ? 'btw' : undefined,
142
167
  userId: command.user.id,
143
168
  username: command.user.displayName,
144
169
  appId,
145
170
  mode: 'local-queue',
171
+ onLocalQueued: replyQueuedAck({ command, threadId: thread.id }),
146
172
  });
147
- if (enqueueResult.queued && enqueueResult.queueId) {
148
- const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
149
- await command.reply({
150
- content: responseText,
151
- components: [
152
- buildQueueRemoveRow({
153
- threadId: thread.id,
154
- queueId: enqueueResult.queueId,
155
- }),
156
- ],
157
- flags: SILENT_MESSAGE_FLAGS,
158
- });
173
+ // onLocalQueued already posted the ack.
174
+ if (enqueueResult.queued) {
159
175
  return;
160
176
  }
161
177
  const responseText = `${QUEUE_PREFIX}**${command.user.displayName}:** ${message.slice(0, 1000)}${message.length > 1000 ? '...' : ''}`;
@@ -205,7 +221,7 @@ export async function handleClearQueueCommand({ command, }) {
205
221
  return;
206
222
  }
207
223
  await command.reply({
208
- content: asSystemLine(`Cleared queued message at position ${position}`),
224
+ content: asSubtext(`Cleared queued message at position ${position}`),
209
225
  flags: SILENT_MESSAGE_FLAGS,
210
226
  });
211
227
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
@@ -223,7 +239,7 @@ export async function handleClearQueueCommand({ command, }) {
223
239
  list = list.slice(0, 597) + '...';
224
240
  }
225
241
  await command.reply({
226
- content: asSystemLine(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
242
+ content: asSubtext(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
227
243
  flags: SILENT_MESSAGE_FLAGS,
228
244
  });
229
245
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
@@ -297,19 +313,10 @@ export async function handleQueueCommandCommand({ command, appId, }) {
297
313
  appId,
298
314
  command: commandPayload,
299
315
  mode: 'local-queue',
316
+ onLocalQueued: replyQueuedAck({ command, threadId: thread.id }),
300
317
  });
301
- if (enqueueResult.queued && enqueueResult.queueId) {
302
- const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
303
- await command.reply({
304
- content: responseText,
305
- components: [
306
- buildQueueRemoveRow({
307
- threadId: thread.id,
308
- queueId: enqueueResult.queueId,
309
- }),
310
- ],
311
- flags: SILENT_MESSAGE_FLAGS,
312
- });
318
+ // onLocalQueued already posted the ack.
319
+ if (enqueueResult.queued) {
313
320
  logger.log(`[QUEUE] User ${command.user.displayName} queued command /${commandName} in thread ${channel.id}`);
314
321
  return;
315
322
  }
@@ -12,6 +12,7 @@ import { sendThreadMessage, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, resol
12
12
  import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
13
13
  import { createLogger, LogPrefix } from '../logger.js';
14
14
  import { DiscordOperationError } from '../errors.js';
15
+ import { extractQueueSuffix } from '../message-formatting.js';
15
16
  const logger = createLogger(LogPrefix.SESSION);
16
17
  // Create only: callers confirm the destination before dispatching the full prompt.
17
18
  export async function createNewSessionThread({ textChannel, projectDirectory, sourceWorkspace, prompt, files = [], userId, }) {
@@ -54,10 +55,14 @@ export async function createNewSessionThread({ textChannel, projectDirectory, so
54
55
  }
55
56
  export async function handleSessionCommand({ command, appId, }) {
56
57
  await command.deferReply();
57
- const prompt = command.options.getString('prompt', true);
58
+ const { prompt, forceQueue } = extractQueueSuffix(command.options.getString('prompt', true));
58
59
  const filesString = command.options.getString('files') || '';
59
60
  const agent = command.options.getString('agent') || undefined;
60
61
  const channel = command.channel;
62
+ if (!prompt.trim()) {
63
+ await command.editReply('Write a prompt for the new session');
64
+ return;
65
+ }
61
66
  const isThread = channel && [
62
67
  ChannelType.PublicThread,
63
68
  ChannelType.PrivateThread,
@@ -139,7 +144,7 @@ export async function handleSessionCommand({ command, appId, }) {
139
144
  username: command.user.displayName,
140
145
  agent,
141
146
  appId,
142
- mode: 'opencode',
147
+ mode: forceQueue ? 'local-queue' : 'opencode',
143
148
  }).catch(async (error) => {
144
149
  logger.error('New session dispatch failed:', error);
145
150
  await sendThreadMessage(thread, 'Could not send the request to OpenCode. Send your request again in this thread.');
package/dist/db.test.js CHANGED
@@ -7,11 +7,11 @@ import { afterAll, describe, expect, test } from 'vitest';
7
7
  import { closeDb, getDb } from './db.js';
8
8
  import * as orm from 'drizzle-orm';
9
9
  import * as schema from './schema.js';
10
- import { appendSessionEventsSinceLastTimestamp, cleanupDeletedThread, completeIpcRequest, createIpcRequest, createScheduledTask, createPendingWorkspace, deleteChannelDirectoryById, deleteThreadQueueItem, getChannelDirectory, getDueSessionSleeps, getIpcRequestById, getScheduledTask, getSessionEventSnapshot, getSessionModel, getSessionSleep, getThreadSession, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setChannelDirectory, setChannelVerbosity, setSessionModel, setThreadSession, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
10
+ import { appendSessionEventsSinceLastTimestamp, cleanupDeletedThread, completeIpcRequest, createIpcRequest, createScheduledTask, createPendingWorkspace, deleteChannelDirectoryById, deleteThreadQueueItem, getChannelDirectory, getDueSessionSleeps, getIpcRequestById, getScheduledTask, getSessionEventSnapshot, getSessionAgent, getSessionModel, getSessionSleep, getThreadSession, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setChannelDirectory, setChannelVerbosity, setSessionAgent, setSessionModel, setThreadSession, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
11
11
  import { createClient } from '@libsql/client';
12
12
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
13
13
  import { chooseLockPort } from './test-utils.js';
14
- import { copyCurrentSessionModel } from './commands/model.js';
14
+ import { copySessionPreferences } from './commands/model.js';
15
15
  afterAll(async () => {
16
16
  await closeDb();
17
17
  });
@@ -566,7 +566,9 @@ describe('getDb', () => {
566
566
  await db.delete(schema.thread_workspaces).where(orm.eq(schema.thread_workspaces.thread_id, threadId));
567
567
  await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId));
568
568
  });
569
- test('copyCurrentSessionModel snapshots source session model to forked session', async () => {
569
+ // Regression: btw forks lost the agent and ran `build` instead of the source
570
+ // agent, which changed tools and busted the whole prompt cache.
571
+ test('copySessionPreferences snapshots source session agent and model to forked session', async () => {
570
572
  const db = await getDb();
571
573
  const sourceSessionId = `test-source-session-${crypto.randomUUID()}`;
572
574
  const targetSessionId = `test-target-session-${crypto.randomUUID()}`;
@@ -578,18 +580,26 @@ describe('getDb', () => {
578
580
  modelId: 'anthropic/claude-opus-4-6',
579
581
  variant: 'thinking',
580
582
  });
581
- await copyCurrentSessionModel({
583
+ await setSessionAgent(sourceSessionId, 'opus');
584
+ await copySessionPreferences({
582
585
  sourceSessionId,
583
586
  targetSessionId,
584
587
  getClient,
585
588
  });
586
- await expect(getSessionModel(targetSessionId)).resolves.toMatchInlineSnapshot(`
589
+ expect({
590
+ agent: await getSessionAgent(targetSessionId),
591
+ model: await getSessionModel(targetSessionId),
592
+ }).toMatchInlineSnapshot(`
587
593
  {
588
- "modelId": "anthropic/claude-opus-4-6",
589
- "variant": "thinking",
594
+ "agent": "opus",
595
+ "model": {
596
+ "modelId": "anthropic/claude-opus-4-6",
597
+ "variant": "thinking",
598
+ },
590
599
  }
591
600
  `);
592
601
  await db.delete(schema.session_models).where(orm.inArray(schema.session_models.session_id, [sourceSessionId, targetSessionId]));
602
+ await db.delete(schema.session_agents).where(orm.inArray(schema.session_agents.session_id, [sourceSessionId, targetSessionId]));
593
603
  });
594
604
  test('session event persistence uses (timestamp, event_index) ordering for deterministic same-ms replay', async () => {
595
605
  const db = await getDb();
@@ -13,15 +13,15 @@ import { stopOpencodeServer, } from './opencode.js';
13
13
  import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
14
14
  import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
15
15
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
16
- import { asSystemLine, STATUS_PREFIX } from './message-formatting.js';
16
+ import { asSubtext } from './message-formatting.js';
17
17
  import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
18
18
  import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
19
19
  import YAML from 'yaml';
20
- import { getFileAttachments, getTextAttachments, resolveMentions, } from './message-formatting.js';
21
- import { extractBtwSuffix } from './btw-prefix-detection.js';
20
+ import { getFileAttachments, getTextAttachments, resolveContentMentions } from './message-formatting.js';
21
+ import { extractBtwQueueSuffix } from './btw-prefix-detection.js';
22
22
  import { isVoiceAttachment } from './voice-attachment.js';
23
23
  import { forkSessionToBtwThread } from './commands/btw.js';
24
- import { extractQueueSuffix, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
24
+ import { preprocessExistingThreadMessage, preprocessNewThreadMessage, resolveMessagePrompt, } from './message-preprocessing.js';
25
25
  import { cancelPendingActionButtons } from './commands/action-buttons.js';
26
26
  import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
27
27
  import { cancelPendingFileUpload } from './commands/file-upload.js';
@@ -48,7 +48,7 @@ export { initializeOpencodeForDirectory, assertCompatibleOpencodeVersion } from
48
48
  export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, } from './discord-utils.js';
49
49
  export { getOpencodeSystemMessage } from './system-message.js';
50
50
  export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, createDefaultKimakiChannel, getChannelsWithDescriptions, } from './channel-management.js';
51
- import { ChannelType, Client, Events, GatewayIntentBits, Partials, ThreadAutoArchiveDuration, } from 'discord.js';
51
+ import { ChannelType, Client, Events, RESTEvents, GatewayIntentBits, Partials, ThreadAutoArchiveDuration, } from 'discord.js';
52
52
  import fs from 'node:fs';
53
53
  import path from 'node:path';
54
54
  import dedent from 'string-dedent';
@@ -299,6 +299,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
299
299
  discordClient.on(Events.Error, (error) => {
300
300
  discordLogger.error('[GATEWAY] Client error:', formatErrorWithStack(error));
301
301
  });
302
+ // discord.js silently waits out 429s, so log them to explain slow REST calls.
303
+ discordClient.rest.on(RESTEvents.RateLimited, (info) => {
304
+ discordLogger.warn(`[REST] Rate limited ${info.method} ${info.route} retryAfter=${info.retryAfter}ms scope=${info.scope} global=${info.global} sublimitTimeout=${info.sublimitTimeout}ms`);
305
+ });
302
306
  discordClient.on(Events.ShardError, (error, shardId) => {
303
307
  const state = getOrCreateShardState(shardId);
304
308
  state.lastError = error;
@@ -596,35 +600,54 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
596
600
  // `. btw` suffix mirrors /btw for fast side-question forks.
597
601
  // Works like queue: just the word "btw" at the end after punctuation
598
602
  // or newline. The whole message (minus the suffix) becomes the fork prompt.
599
- const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
600
- ? extractBtwSuffix(message.content || '')
601
- : null;
602
- if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
603
+ const suffix = extractBtwQueueSuffix(message.content || '');
604
+ if (suffix.forceBtw && !suffix.forceQueue && projectDirectory
605
+ && worktreeInfo?.status !== 'pending' && !isLeadingMentionToOtherUser) {
603
606
  threadIngressSlot?.release();
604
607
  const btwSdkDir = worktreeInfo?.status === 'ready' &&
605
608
  worktreeInfo.workspace_directory
606
609
  ? worktreeInfo.workspace_directory
607
610
  : projectDirectory;
611
+ // Ack right away: fork + thread creation can take seconds. Runs in
612
+ // parallel with the fork and is edited with the result at the end.
613
+ const ackPromise = message.reply({
614
+ content: asSubtext('Forking session to answer this side question...'),
615
+ flags: SILENT_MESSAGE_FLAGS,
616
+ }).catch((error) => {
617
+ discordLogger.warn('Could not send btw ack:', error);
618
+ return undefined;
619
+ });
620
+ // Long `kimaki send` prompts arrive as prompt.md, so the fork needs attachments too.
621
+ const [btwImages, btwTextAttachments] = await Promise.all([
622
+ getFileAttachments(message),
623
+ getTextAttachments(message),
624
+ ]);
608
625
  const result = await forkSessionToBtwThread({
609
626
  sourceThread: thread,
610
627
  projectDirectory,
611
628
  sdkDirectory: btwSdkDir,
612
- prompt: btwResult.prompt,
613
- userId: message.author.id,
614
- username: message.member?.displayName || message.author.displayName,
629
+ prompt: suffix.prompt,
630
+ modelPrompt: [suffix.prompt, btwTextAttachments].filter(Boolean).join('\n\n'),
631
+ images: btwImages.length > 0 ? btwImages : undefined,
632
+ userId: cliInjectedUserId || message.author.id,
633
+ username: cliInjectedUsername ||
634
+ message.member?.displayName ||
635
+ message.author.displayName,
615
636
  appId: currentAppId,
616
637
  });
617
- if (result instanceof Error) {
618
- await message.reply({
619
- content: result.message,
620
- flags: SILENT_MESSAGE_FLAGS,
621
- });
622
- return;
638
+ const resultContent = result instanceof Error
639
+ ? result.message
640
+ : `Session forked! Continue in ${result.thread.toString()}`;
641
+ const ack = await ackPromise;
642
+ const edited = ack
643
+ ? await ack.edit({ content: resultContent }).catch((error) => {
644
+ discordLogger.warn('Could not edit btw ack:', error);
645
+ return undefined;
646
+ })
647
+ : undefined;
648
+ if (!edited) {
649
+ await message.reply({ content: resultContent, flags: SILENT_MESSAGE_FLAGS });
623
650
  }
624
- await message.reply({
625
- content: `Session forked! Continue in ${result.thread.toString()}`,
626
- flags: SILENT_MESSAGE_FLAGS,
627
- });
628
651
  return;
629
652
  }
630
653
  const hasVoiceAttachment = message.attachments.some((attachment) => {
@@ -677,7 +700,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
677
700
  }
678
701
  void cancelPendingFileUpload(thread.id);
679
702
  };
680
- if (!hasVoiceAttachment) {
703
+ if (!hasVoiceAttachment && !suffix.forceQueue) {
681
704
  await dismissSourceUi();
682
705
  }
683
706
  // A sleep wake only becomes a turn if it can still claim its own row.
@@ -707,6 +730,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
707
730
  message.author.displayName,
708
731
  sourceMessageId: message.id,
709
732
  sourceThreadId: thread.id,
733
+ sourceChannelId: message.channelId,
710
734
  appId: currentAppId,
711
735
  agent: cliInjectedAgent,
712
736
  model: cliInjectedModel,
@@ -734,7 +758,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
734
758
  });
735
759
  // Routing must finish before touching source UI. This chain is separate
736
760
  // from dispatchAction, so abort waiting does not block session events.
737
- if (hasVoiceAttachment && !result.skip) {
761
+ if (hasVoiceAttachment && !result.skip && result.mode !== 'local-queue') {
738
762
  await dismissSourceUi();
739
763
  }
740
764
  return result;
@@ -742,7 +766,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
742
766
  });
743
767
  // Notify when a voice message was queued instead of sent immediately
744
768
  if (enqueueResult.queued && enqueueResult.position) {
745
- await sendThreadMessage(thread, asSystemLine(`Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`));
769
+ await sendThreadMessage(thread, asSubtext(`Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`));
746
770
  }
747
771
  }
748
772
  if (channel.type === ChannelType.GuildText) {
@@ -879,6 +903,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
879
903
  username: message.member?.displayName || message.author.displayName,
880
904
  sourceMessageId: message.id,
881
905
  sourceThreadId: thread.id,
906
+ sourceChannelId: message.channelId,
882
907
  appId: currentAppId,
883
908
  preprocess: async () => {
884
909
  return preprocessNewThreadMessage({
@@ -944,22 +969,27 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
944
969
  const runtime = getRuntime(channel.id);
945
970
  if (!runtime)
946
971
  return;
947
- // Use resolveMentions to match initial preprocessing and preserve
948
- // newlines (stripMentions collapses them, breaking final-line queue
949
- // suffix detection).
950
- const { prompt, forceQueue } = extractQueueSuffix(resolveMentions(message));
972
+ // Same resolution as initial ingress, so the edited prompt matches.
973
+ const { prompt, mode, queuedAction } = await resolveMessagePrompt({
974
+ message,
975
+ text: resolveContentMentions(message),
976
+ });
951
977
  // If the edit removed the queue suffix, remove the item from the queue.
952
978
  // If the suffix is still present, update the prompt.
953
- const result = await runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
979
+ const result = await runtime.updateQueuedMessage({
980
+ sourceMessageId: message.id,
981
+ newPrompt: mode === 'local-queue' ? prompt : '',
982
+ queuedAction,
983
+ });
954
984
  if (result.found && channel.isThread()) {
955
985
  const displayName = message.member?.displayName ?? message.author.displayName;
956
986
  if (result.removed) {
957
987
  discordLogger.log(`[MESSAGE_EDIT] Removed queued message ${message.id} in thread ${channel.id}`);
958
- await sendThreadMessage(channel, `${STATUS_PREFIX}**${displayName}** removed message from queue`);
988
+ await sendThreadMessage(channel, asSubtext(`**${displayName}** removed message from queue`));
959
989
  }
960
990
  else {
961
991
  discordLogger.log(`[MESSAGE_EDIT] Updated queued message ${message.id} in thread ${channel.id}`);
962
- await sendThreadMessage(channel, `${STATUS_PREFIX}**${displayName}** edited queued message`);
992
+ await sendThreadMessage(channel, asSubtext(`**${displayName}** edited queued message`));
963
993
  }
964
994
  }
965
995
  }
@@ -982,7 +1012,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
982
1012
  if (!removed)
983
1013
  return;
984
1014
  discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
985
- await sendThreadMessage(channel, `${STATUS_PREFIX}**${removed.username}** removed message from queue`);
1015
+ await sendThreadMessage(channel, asSubtext(`**${removed.username}** removed message from queue`));
986
1016
  }
987
1017
  catch (error) {
988
1018
  discordLogger.error('Error handling message delete:', error instanceof Error ? error.stack : String(error));
@@ -1030,14 +1060,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1030
1060
  return; // Not an auto-start thread
1031
1061
  }
1032
1062
  discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
1033
- const [textAttachmentsContent, fileAttachments] = await Promise.all([
1034
- getTextAttachments(starterMessage),
1035
- getFileAttachments(starterMessage),
1036
- ]);
1037
- const messageText = resolveMentions(starterMessage).trim();
1038
- const prompt = textAttachmentsContent
1039
- ? `${messageText}\n\n${textAttachmentsContent}`
1040
- : messageText;
1063
+ const resolvedPrompt = await resolveMessagePrompt({
1064
+ message: starterMessage,
1065
+ text: resolveContentMentions(starterMessage).trim(),
1066
+ });
1067
+ const { prompt } = resolvedPrompt;
1041
1068
  if (!prompt) {
1042
1069
  discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
1043
1070
  return;
@@ -1163,7 +1190,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1163
1190
  permissions: marker.permissions,
1164
1191
  injectionGuardPatterns: marker.injectionGuardPatterns,
1165
1192
  parentSessionId: marker.parentSessionId,
1166
- mode: 'opencode',
1193
+ mode: resolvedPrompt.mode,
1167
1194
  sessionStartSource: botThreadStartSource
1168
1195
  ? {
1169
1196
  scheduleKind: botThreadStartSource.scheduleKind,
@@ -1172,11 +1199,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1172
1199
  }
1173
1200
  : undefined,
1174
1201
  preprocess: async () => {
1175
- return {
1176
- prompt,
1177
- mode: 'opencode',
1178
- ...(fileAttachments.length > 0 && { images: fileAttachments }),
1179
- };
1202
+ return resolvedPrompt;
1180
1203
  },
1181
1204
  });
1182
1205
  }
@@ -10,8 +10,9 @@ import { discordApiUrl } from './discord-urls.js';
10
10
  import { Lexer } from 'marked';
11
11
  import { splitTablesFromMarkdown } from './format-tables.js';
12
12
  import { sessionPartContent, shouldLeadWithBlankLine, } from './message-formatting.js';
13
- import { getChannelDirectory, getThreadWorktreeOrWorkspace } from './database.js';
13
+ import { getChannelDirectory, getThreadIdBySessionId, getThreadWorktreeOrWorkspace } from './database.js';
14
14
  import { DiscordOperationError } from './errors.js';
15
+ import YAML from 'yaml';
15
16
  import { limitHeadingDepth } from './limit-heading-depth.js';
16
17
  import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
17
18
  import { createLogger, LogPrefix } from './logger.js';
@@ -20,6 +21,19 @@ import mime from 'mime';
20
21
  import fs from 'node:fs';
21
22
  import path from 'node:path';
22
23
  const discordLogger = createLogger(LogPrefix.DISCORD);
24
+ /** Starter embed with the YAML marker. Links the parent thread when the parent session is known locally. */
25
+ export async function buildThreadStartEmbeds(marker) {
26
+ const parentThreadId = marker.parentSessionId
27
+ ? await getThreadIdBySessionId(marker.parentSessionId)
28
+ : undefined;
29
+ return [
30
+ {
31
+ color: 0x2b2d31,
32
+ ...(parentThreadId && { description: `Parent thread: <#${parentThreadId}>` }),
33
+ footer: { text: YAML.stringify(marker) },
34
+ },
35
+ ];
36
+ }
23
37
  /**
24
38
  * Centralized permission check for Kimaki bot access.
25
39
  * Returns true if the member has permission to use the bot:
package/dist/errors.js CHANGED
@@ -63,6 +63,16 @@ export class TranscriptionError extends errore.createTaggedError({
63
63
  message: 'Transcription failed: $reason',
64
64
  }) {
65
65
  }
66
+ export class TranscriptionApiError extends errore.createTaggedError({
67
+ name: 'TranscriptionApiError',
68
+ message: 'Transcription API returned HTTP $status: $body',
69
+ }) {
70
+ }
71
+ export class TranscriptionBlockedError extends errore.createTaggedError({
72
+ name: 'TranscriptionBlockedError',
73
+ message: 'Transcription blocked by the provider content filter ($reason)',
74
+ }) {
75
+ }
66
76
  export class SpeechGenerationError extends errore.createTaggedError({
67
77
  name: 'SpeechGenerationError',
68
78
  message: 'Speech generation failed: $reason',
@@ -96,11 +106,6 @@ export class NoResponseContentError extends errore.createTaggedError({
96
106
  message: 'No response content from model',
97
107
  }) {
98
108
  }
99
- export class NoToolResponseError extends errore.createTaggedError({
100
- name: 'NoToolResponseError',
101
- message: 'No valid tool responses',
102
- }) {
103
- }
104
109
  // ═══════════════════════════════════════════════════════════════════════════
105
110
  // BOUNDARY ERRORS - Wrapping external library exceptions at .catch() sites
106
111
  // ═══════════════════════════════════════════════════════════════════════════
@@ -67,18 +67,18 @@ describe('external directory permissions', () => {
67
67
  -# *using deterministic-provider/deterministic-v2*
68
68
  > reading external directory
69
69
 
70
- ┣ read *probe.txt*
70
+ -# ┣ read *probe.txt*
71
71
 
72
- > external-directory-probe-done
72
+ external-directory-probe-done
73
73
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
74
74
  --- from: user (external-directory-tester)
75
75
  EXTERNAL_DIRECTORY_PROBE_MARKER followup
76
76
  --- from: assistant (TestBot)
77
77
  > reading external directory
78
78
 
79
- ┣ read *probe.txt*
79
+ -# ┣ read *probe.txt*
80
80
 
81
- > external-directory-probe-done
81
+ external-directory-probe-done
82
82
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
83
83
  `);
84
84
  expect(text).not.toContain('Permission Required');
@@ -57,9 +57,9 @@ describe('external directory project deny', () => {
57
57
  -# *using deterministic-provider/deterministic-v2*
58
58
  > reading external directory
59
59
 
60
- ┣ read *probe.txt*
60
+ -# ┣ read *probe.txt*
61
61
 
62
- > external-directory-probe-denied"
62
+ external-directory-probe-denied"
63
63
  `);
64
64
  // A deny is silent: it must not fall back to asking the user.
65
65
  expect(text).not.toContain('Permission Required');
@@ -324,7 +324,7 @@ describeIf('gateway-proxy e2e', () => {
324
324
  hello from gateway proxy test
325
325
  --- from: assistant (TestBot)
326
326
  -# *using deterministic-provider/deterministic-v2*
327
- > gateway-proxy-reply
327
+ gateway-proxy-reply
328
328
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
329
329
  `);
330
330
  expect(reply).toBeDefined();
@@ -351,12 +351,12 @@ describeIf('gateway-proxy e2e', () => {
351
351
  hello from gateway proxy test
352
352
  --- from: assistant (TestBot)
353
353
  -# *using deterministic-provider/deterministic-v2*
354
- > gateway-proxy-reply
354
+ gateway-proxy-reply
355
355
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
356
356
  --- from: user (proxy-tester)
357
357
  follow up through proxy
358
358
  --- from: assistant (TestBot)
359
- > gateway-proxy-reply
359
+ gateway-proxy-reply
360
360
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
361
361
  `);
362
362
  expect(reply).toBeDefined();
@@ -383,12 +383,12 @@ describeIf('gateway-proxy e2e', () => {
383
383
  hello from gateway proxy test
384
384
  --- from: assistant (TestBot)
385
385
  -# *using deterministic-provider/deterministic-v2*
386
- > gateway-proxy-reply
386
+ gateway-proxy-reply
387
387
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
388
388
  --- from: user (proxy-tester)
389
389
  follow up through proxy
390
390
  --- from: assistant (TestBot)
391
- > gateway-proxy-reply
391
+ gateway-proxy-reply
392
392
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
393
393
  --- from: user (proxy-tester)
394
394
  !echo proxy-shell-test