kimaki 0.4.78 → 0.4.80

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 (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
@@ -294,7 +294,8 @@ Use \`--send-at\` to schedule a one-time or recurring task:
294
294
  kimaki send --channel ${channelId} --prompt "Reminder: review open PRs" --send-at "2026-03-01T09:00:00Z"
295
295
  kimaki send --channel ${channelId} --prompt "Run weekly test suite and summarize failures" --send-at "0 9 * * 1"
296
296
 
297
- When using a date for \`--send-at\`, it must be UTC in ISO format ending with \`Z\`.
297
+ ALL scheduling is in UTC. Dates must be UTC ISO format ending with \`Z\`. Cron expressions also fire in UTC (e.g. \`0 9 * * 1\` means 9:00 UTC every Monday).
298
+ When the user specifies a time without a timezone, ask them to confirm their timezone or the UTC equivalent. Never guess the user's timezone.
298
299
 
299
300
  \`--send-at\` supports the same useful options for new threads:
300
301
  - \`--notify-only\` to create a reminder thread without auto-starting a session
@@ -318,6 +319,7 @@ Notification strategy for scheduled tasks:
318
319
  Manage scheduled tasks with:
319
320
 
320
321
  kimaki task list
322
+ kimaki task edit <id> --prompt "new prompt" [--send-at "new schedule"]
321
323
  kimaki task delete <id>
322
324
 
323
325
  \`kimaki session list\` also shows if a session was started by a scheduled \`delay\` or \`cron\` task, including task ID when available.
@@ -5,7 +5,7 @@ import yaml from 'js-yaml';
5
5
  import { claimScheduledTaskRunning, getDuePlannedScheduledTasks, markScheduledTaskCronRescheduled, markScheduledTaskCronRetry, markScheduledTaskFailed, markScheduledTaskOneShotCompleted, recoverStaleRunningScheduledTasks, } from './database.js';
6
6
  import { createLogger, formatErrorWithStack, LogPrefix } from './logger.js';
7
7
  import { notifyError } from './sentry.js';
8
- import { getLocalTimeZone, getNextCronRun, getPromptPreview, parseScheduledTaskPayload, } from './task-schedule.js';
8
+ import { getNextCronRun, getPromptPreview, parseScheduledTaskPayload, } from './task-schedule.js';
9
9
  const taskLogger = createLogger(LogPrefix.TASK);
10
10
  function isRecord(value) {
11
11
  return typeof value === 'object' && value !== null;
@@ -28,6 +28,7 @@ async function executeThreadScheduledTask({ rest, task, payload, }) {
28
28
  ...(payload.model ? { model: payload.model } : {}),
29
29
  ...(payload.username ? { username: payload.username } : {}),
30
30
  ...(payload.userId ? { userId: payload.userId } : {}),
31
+ ...(payload.permissions?.length ? { permissions: payload.permissions } : {}),
31
32
  };
32
33
  const embed = [{ color: 0x2b2d31, footer: { text: yaml.dump(marker) } }];
33
34
  const prefixedPrompt = `» **kimaki-cli:** ${payload.prompt}`;
@@ -59,6 +60,7 @@ async function executeChannelScheduledTask({ rest, task, payload, }) {
59
60
  ...(payload.model ? { model: payload.model } : {}),
60
61
  ...(payload.username ? { username: payload.username } : {}),
61
62
  ...(payload.userId ? { userId: payload.userId } : {}),
63
+ ...(payload.permissions?.length ? { permissions: payload.permissions } : {}),
62
64
  };
63
65
  const embeds = marker
64
66
  ? [{ color: 0x2b2d31, footer: { text: yaml.dump(marker) } }]
@@ -151,7 +153,8 @@ async function finalizeSuccessfulTask({ task, completedAt, }) {
151
153
  });
152
154
  return;
153
155
  }
154
- const timezone = task.timezone || getLocalTimeZone();
156
+ // Use stored timezone, falling back to UTC (not machine local) for consistency
157
+ const timezone = task.timezone || 'UTC';
155
158
  const nextRunResult = getNextCronRun({
156
159
  cronExpr: task.cron_expr,
157
160
  timezone,
@@ -173,7 +176,8 @@ async function finalizeSuccessfulTask({ task, completedAt, }) {
173
176
  }
174
177
  async function finalizeFailedTask({ task, failedAt, error, }) {
175
178
  if (task.schedule_kind === 'cron' && task.cron_expr) {
176
- const timezone = task.timezone || getLocalTimeZone();
179
+ // Use stored timezone, falling back to UTC (not machine local) for consistency
180
+ const timezone = task.timezone || 'UTC';
177
181
  const nextRunResult = getNextCronRun({
178
182
  cronExpr: task.cron_expr,
179
183
  timezone,
@@ -124,6 +124,14 @@ function asString(value) {
124
124
  }
125
125
  return value;
126
126
  }
127
+ function asStringArray(value) {
128
+ if (!Array.isArray(value)) {
129
+ return null;
130
+ }
131
+ return value.filter((v) => {
132
+ return typeof v === 'string';
133
+ });
134
+ }
127
135
  export function parseScheduledTaskPayload(payloadJson) {
128
136
  const parsed = errore.try({
129
137
  try: () => {
@@ -147,6 +155,7 @@ export function parseScheduledTaskPayload(payloadJson) {
147
155
  const model = asString(parsed.model);
148
156
  const username = asString(parsed.username);
149
157
  const userId = asString(parsed.userId);
158
+ const permissions = asStringArray(parsed.permissions);
150
159
  if (!threadId || !prompt) {
151
160
  return new Error('Thread task payload requires threadId and prompt');
152
161
  }
@@ -158,6 +167,7 @@ export function parseScheduledTaskPayload(payloadJson) {
158
167
  model,
159
168
  username,
160
169
  userId,
170
+ permissions,
161
171
  };
162
172
  }
163
173
  if (kind === 'channel') {
@@ -171,6 +181,7 @@ export function parseScheduledTaskPayload(payloadJson) {
171
181
  const model = asString(parsed.model);
172
182
  const username = asString(parsed.username);
173
183
  const userId = asString(parsed.userId);
184
+ const permissions = asStringArray(parsed.permissions);
174
185
  if (!channelId || !prompt) {
175
186
  return new Error('Channel task payload requires channelId and prompt');
176
187
  }
@@ -185,6 +196,7 @@ export function parseScheduledTaskPayload(payloadJson) {
185
196
  model,
186
197
  username,
187
198
  userId,
199
+ permissions,
188
200
  };
189
201
  }
190
202
  return new Error('Task payload has unknown kind');
@@ -435,11 +435,20 @@ e2eTest('thread message queue ordering', () => {
435
435
  },
436
436
  });
437
437
  const th = discord.thread(thread.id);
438
- // Wait for the first bot reply so session is established
438
+ // Wait for the first bot reply AND its footer so the first response
439
+ // cycle is fully complete before sending follow-ups. Without this,
440
+ // the footer for "one" can still be in-flight when the snapshot runs.
439
441
  const firstReply = await th.waitForBotReply({
440
442
  timeout: 4_000,
441
443
  });
442
444
  expect(firstReply.content.trim().length).toBeGreaterThan(0);
445
+ await waitForFooterMessage({
446
+ discord,
447
+ threadId: thread.id,
448
+ timeout: 4_000,
449
+ afterMessageIncludes: 'one',
450
+ afterAuthorId: TEST_USER_ID,
451
+ });
443
452
  // Snapshot bot message count before sending follow-ups
444
453
  const before = await th.getMessages();
445
454
  const beforeBotCount = before.filter((m) => {
@@ -478,10 +487,13 @@ e2eTest('thread message queue ordering', () => {
478
487
  Reply with exactly: one
479
488
  --- from: assistant (TestBot)
480
489
  ⬥ ok
490
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
481
491
  --- from: user (queue-tester)
482
492
  Reply with exactly: two
483
493
  Reply with exactly: three
484
494
  --- from: assistant (TestBot)
495
+ ⬥ ok
496
+ ⬥ ok
485
497
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
486
498
  `);
487
499
  const userThreeIndex = after.findIndex((message) => {
@@ -0,0 +1,166 @@
1
+ // E2e test for /undo command.
2
+ // Validates that:
3
+ // 1. After /undo, session.revert state is set (files reverted, revert boundary marked)
4
+ // 2. Messages are NOT deleted yet (they stay until next prompt cleans them up)
5
+ // 3. On the next user message, reverted messages are cleaned up by OpenCode's
6
+ // SessionRevert.cleanup() and the model only sees pre-revert messages
7
+ //
8
+ // This matches the OpenCode TUI behavior (use-session-commands.tsx):
9
+ // - Pass the user message ID (not assistant ID)
10
+ // - Don't delete messages — just mark session as reverted
11
+ // - Cleanup happens automatically on next promptAsync()
12
+ //
13
+ // Uses opencode-deterministic-provider (no real LLM calls).
14
+ // Poll timeouts: 4s max, 100ms interval.
15
+ import { describe, test, expect } from 'vitest';
16
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
17
+ import { waitForFooterMessage } from './test-utils.js';
18
+ import { getThreadSession } from './database.js';
19
+ import { initializeOpencodeForDirectory } from './opencode.js';
20
+ const TEXT_CHANNEL_ID = '200000000000001200';
21
+ const e2eTest = describe;
22
+ e2eTest('/undo sets revert state and cleans up on next prompt', () => {
23
+ const ctx = setupQueueAdvancedSuite({
24
+ channelId: TEXT_CHANNEL_ID,
25
+ channelName: 'qa-undo-e2e',
26
+ dirName: 'qa-undo-e2e',
27
+ username: 'undo-tester',
28
+ });
29
+ test('undo sets revert state, next message cleans up reverted messages', async () => {
30
+ // 1. Send a message and wait for complete session (footer)
31
+ await ctx.discord
32
+ .channel(TEXT_CHANNEL_ID)
33
+ .user(TEST_USER_ID)
34
+ .sendMessage({
35
+ content: 'Reply with exactly: undo-test-message',
36
+ });
37
+ const thread = await ctx.discord
38
+ .channel(TEXT_CHANNEL_ID)
39
+ .waitForThread({
40
+ timeout: 4_000,
41
+ predicate: (t) => {
42
+ return t.name === 'Reply with exactly: undo-test-message';
43
+ },
44
+ });
45
+ const th = ctx.discord.thread(thread.id);
46
+ await th.waitForBotReply({ timeout: 4_000 });
47
+ await waitForFooterMessage({
48
+ discord: ctx.discord,
49
+ threadId: thread.id,
50
+ timeout: 4_000,
51
+ });
52
+ // 2. Get session ID and verify it has messages
53
+ const sessionId = await getThreadSession(thread.id);
54
+ expect(sessionId).toBeTruthy();
55
+ const getClient = await initializeOpencodeForDirectory(ctx.directories.projectDirectory);
56
+ if (getClient instanceof Error) {
57
+ throw getClient;
58
+ }
59
+ const beforeMessages = await getClient().session.messages({
60
+ sessionID: sessionId,
61
+ directory: ctx.directories.projectDirectory,
62
+ });
63
+ const beforeCount = (beforeMessages.data || []).length;
64
+ expect(beforeCount).toBeGreaterThan(0);
65
+ const beforeUserMessages = (beforeMessages.data || []).filter((m) => {
66
+ return m.info.role === 'user';
67
+ });
68
+ const beforeAssistantMessages = (beforeMessages.data || []).filter((m) => {
69
+ return m.info.role === 'assistant';
70
+ });
71
+ expect(beforeUserMessages.length).toBeGreaterThan(0);
72
+ expect(beforeAssistantMessages.length).toBeGreaterThan(0);
73
+ // Verify no revert state yet
74
+ const beforeSession = await getClient().session.get({
75
+ sessionID: sessionId,
76
+ });
77
+ expect(beforeSession.data?.revert).toBeFalsy();
78
+ // 3. Run /undo command
79
+ const { id: undoInteractionId } = await th
80
+ .user(TEST_USER_ID)
81
+ .runSlashCommand({ name: 'undo' });
82
+ const undoAck = await th.waitForInteractionAck({
83
+ interactionId: undoInteractionId,
84
+ timeout: 4_000,
85
+ });
86
+ expect(undoAck).toBeDefined();
87
+ // Wait for the undo reply to appear (deferred reply gets edited)
88
+ if (undoAck.messageId) {
89
+ const start = Date.now();
90
+ while (Date.now() - start < 4_000) {
91
+ const messages = await th.getMessages();
92
+ const undoMessage = messages.find((m) => {
93
+ return m.id === undoAck.messageId;
94
+ });
95
+ if (undoMessage && undoMessage.content.length > 0) {
96
+ break;
97
+ }
98
+ await new Promise((r) => {
99
+ setTimeout(r, 100);
100
+ });
101
+ }
102
+ }
103
+ // 4. Verify session now has revert state set
104
+ const afterSession = await getClient().session.get({
105
+ sessionID: sessionId,
106
+ });
107
+ expect(afterSession.data?.revert).toBeTruthy();
108
+ expect(afterSession.data?.revert?.messageID).toBeTruthy();
109
+ // Messages should still exist (not deleted — cleanup happens on next prompt)
110
+ const afterMessages = await getClient().session.messages({
111
+ sessionID: sessionId,
112
+ directory: ctx.directories.projectDirectory,
113
+ });
114
+ expect((afterMessages.data || []).length).toBe(beforeCount);
115
+ // 5. Send a new message — this triggers SessionRevert.cleanup()
116
+ // which removes reverted messages before processing the new prompt
117
+ await th.user(TEST_USER_ID).sendMessage({
118
+ content: 'Reply with exactly: after-undo-message',
119
+ });
120
+ await waitForFooterMessage({
121
+ discord: ctx.discord,
122
+ threadId: thread.id,
123
+ timeout: 4_000,
124
+ afterMessageIncludes: 'after-undo-message',
125
+ });
126
+ // 6. Verify reverted messages were cleaned up
127
+ const finalMessages = await getClient().session.messages({
128
+ sessionID: sessionId,
129
+ directory: ctx.directories.projectDirectory,
130
+ });
131
+ const finalAssistantMessages = (finalMessages.data || []).filter((m) => {
132
+ return m.info.role === 'assistant';
133
+ });
134
+ // The original assistant message should have been cleaned up,
135
+ // only the new one (from after-undo-message) should remain
136
+ const originalAssistantStillExists = finalAssistantMessages.some((m) => {
137
+ return m.parts.some((p) => {
138
+ return p.type === 'text' && 'text' in p && p.text === 'ok';
139
+ });
140
+ });
141
+ // The first "ok" response was reverted and should be cleaned up.
142
+ // The new response for "after-undo-message" should produce a fresh "ok".
143
+ // We verify the total count dropped: the original user+assistant pair
144
+ // was removed, and replaced by just the new user+assistant pair.
145
+ expect(finalAssistantMessages.length).toBeLessThanOrEqual(beforeAssistantMessages.length);
146
+ // Revert state should be cleared after cleanup
147
+ const finalSession = await getClient().session.get({
148
+ sessionID: sessionId,
149
+ });
150
+ expect(finalSession.data?.revert).toBeFalsy();
151
+ // 7. Snapshot the Discord thread
152
+ expect(await th.text()).toMatchInlineSnapshot(`
153
+ "--- from: user (undo-tester)
154
+ Reply with exactly: undo-test-message
155
+ --- from: assistant (TestBot)
156
+ ⬥ ok
157
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
158
+ Undone - reverted last assistant message
159
+ --- from: user (undo-tester)
160
+ Reply with exactly: after-undo-message
161
+ --- from: assistant (TestBot)
162
+ ⬥ ok
163
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
164
+ `);
165
+ }, 20_000);
166
+ });
package/dist/utils.js CHANGED
@@ -47,7 +47,7 @@ export function generateBotInstallUrl({ clientId, permissions = [
47
47
  }
48
48
  export const KIMAKI_GATEWAY_APP_ID = process.env.KIMAKI_GATEWAY_APP_ID || '1477605701202481173';
49
49
  export const KIMAKI_WEBSITE_URL = process.env.KIMAKI_WEBSITE_URL || 'https://kimaki.xyz';
50
- export function generateDiscordInstallUrlForBot({ appId, mode, clientId, clientSecret, gatewayCallbackUrl, }) {
50
+ export function generateDiscordInstallUrlForBot({ appId, mode, clientId, clientSecret, gatewayCallbackUrl, reachableUrl, }) {
51
51
  if (mode !== 'gateway') {
52
52
  return generateBotInstallUrl({ clientId: appId });
53
53
  }
@@ -66,6 +66,9 @@ export function generateDiscordInstallUrlForBot({ appId, mode, clientId, clientS
66
66
  if (gatewayCallbackUrl) {
67
67
  url.searchParams.set('kimakiCallbackUrl', gatewayCallbackUrl);
68
68
  }
69
+ if (reachableUrl) {
70
+ url.searchParams.set('reachableUrl', reachableUrl);
71
+ }
69
72
  return url.toString();
70
73
  }
71
74
  export function deduplicateByKey(arr, keyFn) {
@@ -0,0 +1,34 @@
1
+ // Voice attachment detection helpers.
2
+ // Normalizes Discord attachment heuristics for voice-message detection so
3
+ // message routing, transcription, and empty-prompt guards all agree even when
4
+ // Discord omits contentType on uploaded audio attachments.
5
+ import path from 'node:path';
6
+ const VOICE_ATTACHMENT_EXTENSIONS = new Set([
7
+ '.m4a',
8
+ '.mp3',
9
+ '.mp4',
10
+ '.oga',
11
+ '.ogg',
12
+ '.opus',
13
+ '.wav',
14
+ ]);
15
+ export function getVoiceAttachmentMatchReason(attachment) {
16
+ const contentType = attachment.contentType?.trim().toLowerCase() || '';
17
+ if (contentType.startsWith('audio/')) {
18
+ return `contentType:${contentType}`;
19
+ }
20
+ if (typeof attachment.duration === 'number' && attachment.duration > 0) {
21
+ return `duration:${attachment.duration}`;
22
+ }
23
+ if (attachment.waveform?.trim()) {
24
+ return 'waveform';
25
+ }
26
+ const extension = path.extname(attachment.name || '').toLowerCase();
27
+ if (VOICE_ATTACHMENT_EXTENSIONS.has(extension)) {
28
+ return `extension:${extension}`;
29
+ }
30
+ return null;
31
+ }
32
+ export function isVoiceAttachment(attachment) {
33
+ return getVoiceAttachmentMatchReason(attachment) !== null;
34
+ }
@@ -3,21 +3,21 @@
3
3
  // and routes audio to the GenAI worker for real-time voice assistant interactions.
4
4
  import * as errore from 'errore';
5
5
  import { VoiceConnectionStatus, EndBehaviorType, joinVoiceChannel, entersState, } from '@discordjs/voice';
6
- import { exec } from 'node:child_process';
7
6
  import fs, { createWriteStream } from 'node:fs';
8
7
  import { mkdir } from 'node:fs/promises';
9
8
  import path from 'node:path';
10
- import { promisify } from 'node:util';
11
9
  import { Transform } from 'node:stream';
12
10
  import * as prism from 'prism-media';
13
11
  import dedent from 'string-dedent';
14
12
  import { Events, ActionRowBuilder, ButtonBuilder, ButtonStyle, } from 'discord.js';
15
13
  import { createGenAIWorker } from './genai-worker-wrapper.js';
16
14
  import { getVoiceChannelDirectory, getGeminiApiKey, getTranscriptionApiKey, findTextChannelByVoiceChannel, } from './database.js';
17
- import { sendThreadMessage, escapeDiscordFormatting, SILENT_MESSAGE_FLAGS, hasKimakiBotPermission, } from './discord-utils.js';
15
+ import { sendThreadMessage, escapeDiscordFormatting, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, hasKimakiBotPermission, } from './discord-utils.js';
18
16
  import { transcribeAudio } from './voice.js';
19
17
  import { FetchError } from './errors.js';
20
18
  import { store } from './store.js';
19
+ import { getVoiceAttachmentMatchReason, isVoiceAttachment, } from './voice-attachment.js';
20
+ import { execAsync } from './worktrees.js';
21
21
  import { createLogger, LogPrefix } from './logger.js';
22
22
  import { notifyError } from './sentry.js';
23
23
  const voiceLogger = createLogger(LogPrefix.VOICE);
@@ -195,7 +195,7 @@ export async function setupVoiceHandling({ connection, guildId, channelId, appId
195
195
  if (textChannel?.isTextBased() && 'send' in textChannel) {
196
196
  await textChannel.send({
197
197
  content: `⚠️ Voice session error: ${String(error).slice(0, 1900)}`,
198
- flags: SILENT_MESSAGE_FLAGS,
198
+ flags: NOTIFY_MESSAGE_FLAGS,
199
199
  });
200
200
  }
201
201
  }
@@ -329,10 +329,11 @@ export async function cleanupVoiceConnection(guildId) {
329
329
  // Per-thread serialization is handled by ThreadSessionRuntime.enqueueIncoming()
330
330
  // via the runtime action queue; no local serialization is needed here.
331
331
  export async function processVoiceAttachment({ message, thread, projectDirectory, isNewThread = false, appId, currentSessionContext, lastSessionContext, }) {
332
- const audioAttachment = Array.from(message.attachments.values()).find((attachment) => attachment.contentType?.startsWith('audio/'));
332
+ const audioAttachment = Array.from(message.attachments.values()).find((attachment) => isVoiceAttachment(attachment));
333
333
  if (!audioAttachment)
334
334
  return null;
335
- voiceLogger.log(`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`);
335
+ const attachmentMatchReason = getVoiceAttachmentMatchReason(audioAttachment);
336
+ voiceLogger.log(`Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType || 'no contentType'}, ${attachmentMatchReason || 'unknown reason'})`);
336
337
  await sendThreadMessage(thread, '🎤 Transcribing voice message...');
337
338
  // Deterministic mode: skip audio download and AI model call entirely,
338
339
  // return a canned result after an optional delay. Used by e2e tests to
@@ -370,7 +371,7 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
370
371
  });
371
372
  if (audioResponse instanceof Error) {
372
373
  voiceLogger.error(`Failed to download audio attachment:`, audioResponse.message);
373
- await sendThreadMessage(thread, `⚠️ Failed to download audio: ${audioResponse.message}`);
374
+ await sendThreadMessage(thread, `⚠️ Failed to download audio: ${audioResponse.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
374
375
  return null;
375
376
  }
376
377
  const audioBuffer = Buffer.from(await audioResponse.arrayBuffer());
@@ -379,7 +380,6 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
379
380
  if (projectDirectory) {
380
381
  try {
381
382
  voiceLogger.log(`Getting project file tree from ${projectDirectory}`);
382
- const execAsync = promisify(exec);
383
383
  const { stdout } = await execAsync('git ls-files | tree --fromfile -a', {
384
384
  cwd: projectDirectory,
385
385
  });
@@ -450,7 +450,9 @@ export async function processVoiceAttachment({ message, thread, projectDirectory
450
450
  Error: (e) => e.message,
451
451
  });
452
452
  voiceLogger.error(`Transcription failed:`, transcription);
453
- await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}`);
453
+ await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}`, {
454
+ flags: NOTIFY_MESSAGE_FLAGS,
455
+ });
454
456
  return null;
455
457
  }
456
458
  const { transcription: text, queueMessage } = transcription;
@@ -414,6 +414,84 @@ e2eTest('voice message handling', () => {
414
414
  const assistantTexts = getAssistantTexts(messages);
415
415
  expect(assistantTexts.length).toBeGreaterThan(0);
416
416
  }, 8_000);
417
+ test('voice attachment without content type still transcribes and avoids empty prompt dispatch', async () => {
418
+ setDeterministicTranscription({
419
+ transcription: 'Investigate the missing content type path',
420
+ queueMessage: false,
421
+ });
422
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
423
+ content: '',
424
+ attachments: [
425
+ {
426
+ id: 'voice-no-content-type',
427
+ filename: 'voice-message.ogg',
428
+ size: 1024,
429
+ url: 'https://fake-cdn.discord.test/voice-no-content-type.ogg',
430
+ proxy_url: 'https://fake-cdn.discord.test/voice-no-content-type.ogg',
431
+ },
432
+ ],
433
+ });
434
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
435
+ timeout: 4_000,
436
+ predicate: (t) => {
437
+ return t.name?.includes('Investigate the missing content type path') ?? false;
438
+ },
439
+ });
440
+ const th = discord.thread(thread.id);
441
+ await waitForBotMessageContaining({
442
+ discord,
443
+ threadId: thread.id,
444
+ userId: TEST_USER_ID,
445
+ text: 'Transcribing voice message',
446
+ timeout: 4_000,
447
+ });
448
+ await waitForBotMessageContaining({
449
+ discord,
450
+ threadId: thread.id,
451
+ userId: TEST_USER_ID,
452
+ text: 'Investigate the missing content type path',
453
+ timeout: 4_000,
454
+ });
455
+ await waitForFooterMessage({
456
+ discord,
457
+ threadId: thread.id,
458
+ timeout: 4_000,
459
+ });
460
+ const finalState = await waitForThreadState({
461
+ threadId: thread.id,
462
+ predicate: (state) => {
463
+ return Boolean(state.sessionId) && state.queueItems.length === 0;
464
+ },
465
+ timeout: 4_000,
466
+ description: 'voice attachment without content type settled',
467
+ });
468
+ expect(await th.text()).toMatchInlineSnapshot(`
469
+ "--- from: user (voice-tester)
470
+ [attachment: voice-message.ogg]
471
+ --- from: assistant (TestBot)
472
+ 🎤 Transcribing voice message...
473
+ 📝 **Transcribed message:** Investigate the missing content type path
474
+ ⬥ session-reply
475
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
476
+ `);
477
+ const messages = await waitForSessionMessages({
478
+ projectDirectory: directories.projectDirectory,
479
+ sessionID: finalState.sessionId,
480
+ timeout: 4_000,
481
+ description: 'voice attachment without content type dispatched once',
482
+ predicate: (all) => {
483
+ const userTexts = getUserTexts(all);
484
+ return userTexts.some((text) => {
485
+ return text.includes('Investigate the missing content type path');
486
+ });
487
+ },
488
+ });
489
+ const userTexts = getUserTexts(messages);
490
+ expect(userTexts).not.toContain('');
491
+ expect(userTexts.some((text) => {
492
+ return text.includes('Investigate the missing content type path');
493
+ })).toBe(true);
494
+ }, 8_000);
417
495
  // ── Test 2: Voice message in thread with idle session ──
418
496
  test('voice message in thread with idle session starts new request', async () => {
419
497
  // 1. Create a session with a text message first
@@ -4,6 +4,7 @@ import { describe, test, expect } from 'vitest';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { transcribeAudio, convertOggToWav, extractTranscription, normalizeAudioMediaType, getOpenAIAudioConversionStrategy, } from './voice.js';
7
+ import { getVoiceAttachmentMatchReason, isVoiceAttachment, } from './voice-attachment.js';
7
8
  describe('audio media type routing', () => {
8
9
  test('normalizes m4a aliases to audio/mp4', () => {
9
10
  expect(normalizeAudioMediaType('audio/x-m4a')).toMatchInlineSnapshot('"audio/mp4"');
@@ -20,6 +21,36 @@ describe('audio media type routing', () => {
20
21
  expect(getOpenAIAudioConversionStrategy('audio/mpeg')).toMatchInlineSnapshot('"none"');
21
22
  });
22
23
  });
24
+ describe('voice attachment detection', () => {
25
+ test('detects voice attachments by content type, extension, and waveform metadata', () => {
26
+ expect([
27
+ getVoiceAttachmentMatchReason({
28
+ name: 'voice-message.ogg',
29
+ contentType: 'audio/ogg',
30
+ }),
31
+ getVoiceAttachmentMatchReason({
32
+ name: 'voice-message.ogg',
33
+ contentType: null,
34
+ }),
35
+ getVoiceAttachmentMatchReason({
36
+ name: 'upload.bin',
37
+ contentType: null,
38
+ waveform: 'abc123',
39
+ }),
40
+ isVoiceAttachment({
41
+ name: 'notes.txt',
42
+ contentType: null,
43
+ }),
44
+ ]).toMatchInlineSnapshot(`
45
+ [
46
+ "contentType:audio/ogg",
47
+ "extension:.ogg",
48
+ "waveform",
49
+ false,
50
+ ]
51
+ `);
52
+ });
53
+ });
23
54
  describe('extractTranscription', () => {
24
55
  test('extracts transcription from tool call', () => {
25
56
  const result = extractTranscription([
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.78",
5
+ "version": "0.4.80",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -19,13 +19,15 @@
19
19
  "@types/json-schema": "^7.0.15",
20
20
  "@types/ms": "^2.1.0",
21
21
  "@types/node": "^24.3.0",
22
+ "@types/proper-lockfile": "^4.1.4",
22
23
  "eventsource-parser": "^3.0.6",
24
+ "lintcn": "^0.3.0",
23
25
  "prisma": "7.4.2",
24
26
  "tsx": "^4.20.5",
25
27
  "discord-digital-twin": "^0.1.0",
28
+ "db": "^0.0.0",
26
29
  "opencode-cached-provider": "^0.0.1",
27
- "opencode-deterministic-provider": "^0.0.1",
28
- "db": "^0.0.0"
30
+ "opencode-deterministic-provider": "^0.0.1"
29
31
  },
30
32
  "dependencies": {
31
33
  "@ai-sdk/google": "^3.0.30",
@@ -35,8 +37,9 @@
35
37
  "@discordjs/voice": "^0.19.0",
36
38
  "@google/genai": "^1.34.0",
37
39
  "@libsql/client": "^0.15.15",
38
- "@opencode-ai/plugin": "^1.2.15",
39
- "@opencode-ai/sdk": "^1.2.15",
40
+ "@openauthjs/openauth": "^0.4.3",
41
+ "@opencode-ai/plugin": "^1.2.27",
42
+ "@opencode-ai/sdk": "^1.2.27",
40
43
  "@parcel/watcher": "^2.5.6",
41
44
  "@prisma/adapter-libsql": "7.4.2",
42
45
  "@prisma/client": "7.4.2",
@@ -54,14 +57,15 @@
54
57
  "mime": "^4.1.0",
55
58
  "picocolors": "^1.1.1",
56
59
  "pretty-ms": "^9.3.0",
60
+ "proper-lockfile": "^4.1.2",
57
61
  "string-dedent": "^3.0.2",
58
62
  "undici": "^7.16.0",
59
63
  "ws": "^8.19.0",
60
64
  "xdg-basedir": "^5.1.0",
61
65
  "zod": "^4.3.6",
62
66
  "zustand": "^5.0.11",
63
- "errore": "^0.14.0",
64
- "traforo": "^0.0.9"
67
+ "errore": "^0.14.1",
68
+ "traforo": "^0.1.0"
65
69
  },
66
70
  "optionalDependencies": {
67
71
  "@discordjs/opus": "^0.10.0",
@@ -81,6 +85,7 @@
81
85
  "validate-typing-indicator": "doppler run -- tsx scripts/validate-typing-indicator.ts",
82
86
  "test:send": "tsx send-test-message.ts",
83
87
  "register-commands": "tsx scripts/register-commands.ts",
88
+ "lint": "lintcn lint",
84
89
  "format": "oxfmt src",
85
90
  "sync-skills": "tsx scripts/sync-skills.ts"
86
91
  }