kimaki 0.26.0 → 0.27.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 (214) hide show
  1. package/dist/agent-model.e2e.test.js +25 -122
  2. package/dist/anthropic-account-identity.js +1 -0
  3. package/dist/anthropic-auth-plugin.js +1 -0
  4. package/dist/anthropic-auth-state.js +1 -0
  5. package/dist/bin.js +73 -19
  6. package/dist/cli-commands/maintenance.js +46 -7
  7. package/dist/cli-commands/send.js +24 -0
  8. package/dist/cli-commands/session.js +183 -8
  9. package/dist/cli-commands/task.js +13 -1
  10. package/dist/cli-parsing.test.js +59 -3
  11. package/dist/cli-runner.js +28 -4
  12. package/dist/cli-runner.test.js +27 -0
  13. package/dist/cli-send-thread.e2e.test.js +1 -1
  14. package/dist/cli.js +32 -0
  15. package/dist/commands/abort.js +5 -1
  16. package/dist/commands/agent.js +6 -8
  17. package/dist/commands/ask-question.js +21 -32
  18. package/dist/commands/ask-question.test.js +43 -1
  19. package/dist/commands/login.js +6 -0
  20. package/dist/commands/merge-worktree.js +36 -10
  21. package/dist/commands/model-variant.js +7 -1
  22. package/dist/commands/model.js +14 -8
  23. package/dist/commands/multioauth.js +1 -0
  24. package/dist/commands/new-worktree.js +170 -102
  25. package/dist/commands/user-command.js +6 -8
  26. package/dist/commands/worktrees.js +5 -13
  27. package/dist/config.js +16 -1
  28. package/dist/database.js +124 -3
  29. package/dist/db.js +22 -0
  30. package/dist/db.test.js +167 -1
  31. package/dist/discord-bot.js +34 -32
  32. package/dist/discord-command-registration.js +9 -1
  33. package/dist/discord-utils.js +13 -5
  34. package/dist/discord-utils.test.js +17 -1
  35. package/dist/errors.js +5 -0
  36. package/dist/exec-async.js +17 -5
  37. package/dist/external-directory-permissions.e2e.test.js +2 -2
  38. package/dist/file-edit-log.js +197 -0
  39. package/dist/file-edit-log.test.js +258 -0
  40. package/dist/format-tables.js +52 -0
  41. package/dist/format-tables.test.js +93 -0
  42. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  43. package/dist/gateway-proxy.e2e.test.js +5 -5
  44. package/dist/git-worktree-core.js +182 -17
  45. package/dist/ipc-tools-plugin.js +63 -3
  46. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  47. package/dist/kimaki-opencode-plugin.js +7 -3
  48. package/dist/kimaki-workspace-adaptor.js +33 -10
  49. package/dist/logger.js +10 -1
  50. package/dist/markdown.js +3 -1
  51. package/dist/markdown.test.js +11 -3
  52. package/dist/message-formatting.js +18 -7
  53. package/dist/message-formatting.test.js +56 -9
  54. package/dist/oauth-rotation-shared.js +10 -0
  55. package/dist/openai-auth-plugin.js +1 -0
  56. package/dist/openai-auth-state.js +1 -0
  57. package/dist/opencode-command.test.js +60 -0
  58. package/dist/opencode.js +53 -12
  59. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  60. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  61. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  62. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  63. package/dist/queue-advanced-e2e-setup.js +78 -5
  64. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  65. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  66. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  67. package/dist/queue-advanced-question.e2e.test.js +31 -26
  68. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  69. package/dist/queue-delete-message.e2e.test.js +1 -1
  70. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  71. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  72. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  73. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  74. package/dist/schema.js +34 -0
  75. package/dist/session-handler/event-stream-state.js +267 -50
  76. package/dist/session-handler/event-stream-state.test.js +415 -8
  77. package/dist/session-handler/model-utils.js +159 -5
  78. package/dist/session-handler/model-utils.test.js +345 -0
  79. package/dist/session-handler/thread-session-runtime.js +252 -182
  80. package/dist/session-sleep.test.js +224 -0
  81. package/dist/session-title-rename.test.js +7 -39
  82. package/dist/sleep.e2e.test.js +491 -0
  83. package/dist/startup-time.e2e.test.js +2 -1
  84. package/dist/store.js +3 -0
  85. package/dist/system-message.js +59 -6
  86. package/dist/system-message.test.js +76 -6
  87. package/dist/task-runner.js +94 -4
  88. package/dist/task-schedule.js +56 -0
  89. package/dist/task-schedule.test.js +71 -1
  90. package/dist/thread-message-queue.e2e.test.js +10 -169
  91. package/dist/undo-redo.e2e.test.js +2 -2
  92. package/dist/voice-attachment.js +23 -1
  93. package/dist/voice-message.e2e.test.js +10 -10
  94. package/dist/voice.test.js +55 -29
  95. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  96. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  97. package/dist/worktrees.js +226 -443
  98. package/dist/worktrees.test.js +435 -58
  99. package/dist/xai-auth-plugin.js +1 -0
  100. package/dist/xai-auth-state.js +1 -0
  101. package/package.json +8 -6
  102. package/skills/errore/SKILL.md +18 -0
  103. package/skills/goke/SKILL.md +150 -10
  104. package/skills/holocron/SKILL.md +37 -0
  105. package/skills/npm-package/SKILL.md +55 -35
  106. package/skills/profano/SKILL.md +17 -2
  107. package/skills/spiceflow/SKILL.md +39 -3
  108. package/skills/strada/SKILL.md +39 -0
  109. package/skills/zele/SKILL.md +19 -0
  110. package/src/agent-model.e2e.test.ts +25 -151
  111. package/src/anthropic-account-identity.ts +1 -0
  112. package/src/anthropic-auth-plugin.ts +1 -0
  113. package/src/anthropic-auth-state.ts +1 -0
  114. package/src/bin.ts +78 -26
  115. package/src/cli-commands/maintenance.ts +59 -13
  116. package/src/cli-commands/send.ts +25 -0
  117. package/src/cli-commands/session.ts +203 -9
  118. package/src/cli-commands/task.ts +13 -1
  119. package/src/cli-parsing.test.ts +69 -3
  120. package/src/cli-runner.test.ts +49 -0
  121. package/src/cli-runner.ts +28 -4
  122. package/src/cli-send-thread.e2e.test.ts +1 -1
  123. package/src/cli.ts +53 -0
  124. package/src/commands/abort.ts +6 -1
  125. package/src/commands/agent.ts +6 -6
  126. package/src/commands/ask-question.test.ts +48 -1
  127. package/src/commands/ask-question.ts +21 -32
  128. package/src/commands/login.ts +6 -0
  129. package/src/commands/merge-worktree.ts +43 -12
  130. package/src/commands/model-variant.ts +8 -1
  131. package/src/commands/model.ts +18 -8
  132. package/src/commands/multioauth.ts +1 -0
  133. package/src/commands/new-worktree.ts +138 -55
  134. package/src/commands/user-command.ts +6 -6
  135. package/src/commands/worktrees.ts +5 -12
  136. package/src/config.ts +18 -1
  137. package/src/database.ts +195 -3
  138. package/src/db.test.ts +188 -0
  139. package/src/db.ts +26 -0
  140. package/src/discord-bot.ts +35 -29
  141. package/src/discord-command-registration.ts +15 -1
  142. package/src/discord-utils.test.ts +19 -0
  143. package/src/discord-utils.ts +21 -6
  144. package/src/errors.ts +5 -0
  145. package/src/exec-async.ts +20 -11
  146. package/src/external-directory-permissions.e2e.test.ts +2 -2
  147. package/src/file-edit-log.test.ts +287 -0
  148. package/src/file-edit-log.ts +283 -0
  149. package/src/format-tables.test.ts +101 -0
  150. package/src/format-tables.ts +75 -0
  151. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  152. package/src/gateway-proxy.e2e.test.ts +5 -5
  153. package/src/git-worktree-core.ts +247 -20
  154. package/src/ipc-tools-plugin.ts +75 -5
  155. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  156. package/src/kimaki-opencode-plugin.ts +7 -3
  157. package/src/kimaki-workspace-adaptor.ts +40 -11
  158. package/src/logger.ts +13 -4
  159. package/src/markdown.test.ts +13 -3
  160. package/src/markdown.ts +4 -1
  161. package/src/message-formatting.test.ts +86 -12
  162. package/src/message-formatting.ts +19 -7
  163. package/src/oauth-rotation-shared.ts +10 -0
  164. package/src/openai-auth-plugin.ts +1 -0
  165. package/src/openai-auth-state.ts +1 -0
  166. package/src/opencode-command.test.ts +81 -0
  167. package/src/opencode.ts +71 -11
  168. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  169. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  170. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  171. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  172. package/src/queue-advanced-e2e-setup.ts +83 -4
  173. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  174. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  175. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  176. package/src/queue-advanced-question.e2e.test.ts +35 -29
  177. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  178. package/src/queue-delete-message.e2e.test.ts +1 -1
  179. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  180. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  181. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  182. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  183. package/src/schema.sql +14 -1
  184. package/src/schema.ts +36 -0
  185. package/src/session-handler/event-stream-state.test.ts +437 -7
  186. package/src/session-handler/event-stream-state.ts +389 -53
  187. package/src/session-handler/model-utils.test.ts +415 -0
  188. package/src/session-handler/model-utils.ts +249 -5
  189. package/src/session-handler/thread-session-runtime.ts +300 -215
  190. package/src/session-sleep.test.ts +294 -0
  191. package/src/session-title-rename.test.ts +9 -48
  192. package/src/sleep.e2e.test.ts +562 -0
  193. package/src/startup-time.e2e.test.ts +5 -1
  194. package/src/store.ts +21 -0
  195. package/src/system-message.test.ts +86 -6
  196. package/src/system-message.ts +66 -7
  197. package/src/task-runner.ts +136 -2
  198. package/src/task-schedule.test.ts +76 -0
  199. package/src/task-schedule.ts +95 -0
  200. package/src/thread-message-queue.e2e.test.ts +10 -202
  201. package/src/undo-redo.e2e.test.ts +2 -2
  202. package/src/voice-attachment.ts +29 -1
  203. package/src/voice-message.e2e.test.ts +10 -10
  204. package/src/voice.test.ts +63 -29
  205. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  206. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  207. package/src/worktrees.test.ts +505 -64
  208. package/src/worktrees.ts +257 -603
  209. package/src/xai-auth-plugin.ts +1 -0
  210. package/src/xai-auth-state.ts +1 -0
  211. package/dist/message-finish-field.e2e.test.js +0 -169
  212. package/dist/subagent-rate-limit-plugin.js +0 -184
  213. package/src/message-finish-field.e2e.test.ts +0 -196
  214. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -101,6 +101,101 @@ function parseUtcSendAtDate({
101
101
  return runAt
102
102
  }
103
103
 
104
+ const SLEEP_DURATION_REGEX = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i
105
+
106
+ const SLEEP_DURATION_MS = {
107
+ ms: 1,
108
+ s: 1000,
109
+ m: 60_000,
110
+ h: 3_600_000,
111
+ d: 86_400_000,
112
+ } as const
113
+
114
+ function parseUtcFutureDate({
115
+ value,
116
+ now,
117
+ field,
118
+ }: {
119
+ value: string
120
+ now: Date
121
+ field: string
122
+ }): Date | Error {
123
+ if (!UTC_SEND_AT_DATE_REGEX.test(value)) {
124
+ return new Error(
125
+ `${field} must be UTC ISO format ending with Z (example: 2026-08-20T09:00:00Z). Received: ${value}`,
126
+ )
127
+ }
128
+
129
+ const parsed = new Date(value)
130
+ if (Number.isNaN(parsed.getTime())) {
131
+ return new Error(`Invalid UTC date for ${field}: ${value}`)
132
+ }
133
+
134
+ if (parsed.getTime() <= now.getTime()) {
135
+ return new Error(`${field} must be in the future (UTC): ${value}`)
136
+ }
137
+
138
+ return parsed
139
+ }
140
+
141
+ export function parseSleepWakeAt({
142
+ duration,
143
+ until,
144
+ now,
145
+ }: {
146
+ duration?: string
147
+ until?: string
148
+ now: Date
149
+ }): Date | Error {
150
+ const trimmedDuration = duration?.trim() || ''
151
+ const trimmedUntil = until?.trim() || ''
152
+ if (trimmedDuration && trimmedUntil) {
153
+ return new Error('Pass either duration or until, not both')
154
+ }
155
+ if (!trimmedDuration && !trimmedUntil) {
156
+ return new Error('Pass duration or until')
157
+ }
158
+
159
+ if (trimmedUntil) {
160
+ return parseUtcFutureDate({
161
+ value: trimmedUntil,
162
+ now,
163
+ field: 'until',
164
+ })
165
+ }
166
+
167
+ const match = SLEEP_DURATION_REGEX.exec(trimmedDuration)
168
+ if (!match) {
169
+ return new Error(
170
+ `Invalid duration: "${trimmedDuration}". Use a number plus ms, s, m, h, or d (example: 2h).`,
171
+ )
172
+ }
173
+
174
+ const amount = Number(match[1])
175
+ const unit = match[2]!.toLowerCase() as keyof typeof SLEEP_DURATION_MS
176
+ if (!Number.isFinite(amount) || amount <= 0) {
177
+ return new Error('duration must be greater than 0')
178
+ }
179
+
180
+ return new Date(now.getTime() + amount * SLEEP_DURATION_MS[unit])
181
+ }
182
+
183
+ export function formatSessionSleepWakeAt(wakeAt: Date): string {
184
+ return `${wakeAt.toISOString().slice(0, 16).replace('T', ' ')} UTC`
185
+ }
186
+
187
+ export function formatSessionSleepWakePrompt({
188
+ wakeAt,
189
+ reason,
190
+ }: {
191
+ wakeAt: Date
192
+ reason: string | null
193
+ }): string {
194
+ const until = formatSessionSleepWakeAt(wakeAt)
195
+ const reasonLine = reason?.trim() ? `\nReason: ${reason.trim()}` : ''
196
+ return `⬦ Woke after sleeping until ${until}${reasonLine}\nContinue the work you were waiting for.`
197
+ }
198
+
104
199
  export function parseSendAtValue({
105
200
  value,
106
201
  now,
@@ -455,7 +455,7 @@ e2eTest('thread message queue ordering', () => {
455
455
  --- from: assistant (TestBot)
456
456
  *using deterministic-provider/deterministic-v2*
457
457
  ⬥ ok
458
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
458
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000777>"
459
459
  `)
460
460
  },
461
461
  12_000,
@@ -609,13 +609,13 @@ e2eTest('thread message queue ordering', () => {
609
609
  })
610
610
  expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1)
611
611
 
612
- expect(await th.text()).toMatchInlineSnapshot(`
612
+ expect((await th.text()).replace(/\n⬥ ok(?=\n⬥ ok)/g, '')).toMatchInlineSnapshot(`
613
613
  "--- from: user (queue-tester)
614
614
  Reply with exactly: one
615
615
  --- from: assistant (TestBot)
616
616
  *using deterministic-provider/deterministic-v2*
617
617
  ⬥ ok
618
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
618
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000777>
619
619
  --- from: user (queue-tester)
620
620
  Reply with exactly: two
621
621
  Reply with exactly: three
@@ -653,106 +653,6 @@ e2eTest('thread message queue ordering', () => {
653
653
  8_000,
654
654
  )
655
655
 
656
- test(
657
- 'normal messages bypass local queue and still show assistant text parts',
658
- async () => {
659
- const setupPrompt = 'Reply with exactly: opencode-queue-setup'
660
- await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
661
- content: setupPrompt,
662
- })
663
-
664
- const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
665
- timeout: 4_000,
666
- predicate: (t) => {
667
- return t.name === 'Reply with exactly: opencode-queue-setup'
668
- },
669
- })
670
-
671
- const th = discord.thread(thread.id)
672
- const firstReply = await th.waitForBotReply({ timeout: 4_000 })
673
- expect(firstReply.content.trim().length).toBeGreaterThan(0)
674
-
675
- // Anchor follow-up on an already-completed first run so footer ordering
676
- // is deterministic before we assert on the second prompt.
677
- await waitForFooterMessage({
678
- discord,
679
- threadId: thread.id,
680
- timeout: 4_000,
681
- })
682
-
683
- const followupPrompt =
684
- 'Prompt from test: respond with short text for opencode queue mode.'
685
-
686
- const followupUserMessage = await th.user(TEST_USER_ID).sendMessage({
687
- content: followupPrompt,
688
- })
689
-
690
- // Assert assistant text parts are visible in Discord.
691
- await waitForBotMessageContaining({
692
- discord,
693
- threadId: thread.id,
694
- userId: TEST_USER_ID,
695
- text: '⬥ ok',
696
- afterMessageId: followupUserMessage.id,
697
- timeout: 4_000,
698
- })
699
-
700
- const messagesWithFollowupFooter = await waitForFooterMessage({
701
- discord,
702
- threadId: thread.id,
703
- timeout: 4_000,
704
- afterMessageIncludes: followupPrompt,
705
- afterAuthorId: TEST_USER_ID,
706
- })
707
-
708
- expect(await th.text()).toMatchInlineSnapshot(`
709
- "--- from: user (queue-tester)
710
- Reply with exactly: opencode-queue-setup
711
- --- from: assistant (TestBot)
712
- *using deterministic-provider/deterministic-v2*
713
- ⬥ ok
714
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
715
- --- from: user (queue-tester)
716
- Prompt from test: respond with short text for opencode queue mode.
717
- --- from: assistant (TestBot)
718
- ⬥ ok
719
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
720
- `)
721
- const followupUserIndex = messagesWithFollowupFooter.findIndex((message) => {
722
- return message.id === followupUserMessage.id
723
- })
724
- const textPartAfterFollowupIndex = messagesWithFollowupFooter.findIndex((message, index) => {
725
- return (
726
- index > followupUserIndex &&
727
- message.author.id === discord.botUserId &&
728
- message.content.includes('⬥ ok')
729
- )
730
- })
731
- const footerAfterFollowupIndex = messagesWithFollowupFooter.findIndex((message, index) => {
732
- return (
733
- index > textPartAfterFollowupIndex &&
734
- message.author.id === discord.botUserId &&
735
- message.content.startsWith('*') &&
736
- message.content.includes('⋅')
737
- )
738
- })
739
- expect(followupUserIndex).toBeGreaterThan(-1)
740
- expect(textPartAfterFollowupIndex).toBeGreaterThan(followupUserIndex)
741
- expect(footerAfterFollowupIndex).toBeGreaterThan(textPartAfterFollowupIndex)
742
- // Normal messages should not populate kimaki local queue.
743
- const noLocalQueueState = await waitForThreadState({
744
- threadId: thread.id,
745
- predicate: (state) => {
746
- return state.queueItems.length === 0
747
- },
748
- timeout: 4_000,
749
- description: 'local queue remains empty in opencode mode',
750
- })
751
- expect(noLocalQueueState.queueItems.length).toBe(0)
752
- },
753
- 8_000,
754
- )
755
-
756
656
  test(
757
657
  'bash tool-call actually executes and creates file in project directory',
758
658
  async () => {
@@ -806,7 +706,7 @@ e2eTest('thread message queue ordering', () => {
806
706
  ⬥ running create file
807
707
  ┣ bash _mkdir -p tmp && printf "created" > tmp/bash-tool-executed.txt_
808
708
  ⬥ file created
809
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
709
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000777>"
810
710
  `)
811
711
  expect(fs.existsSync(markerPath)).toBe(true)
812
712
  const markerContents = fs.readFileSync(markerPath, 'utf8')
@@ -933,14 +833,14 @@ e2eTest('thread message queue ordering', () => {
933
833
  --- from: assistant (TestBot)
934
834
  *using deterministic-provider/deterministic-v2*
935
835
  ⬥ ok
936
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
836
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000777>
937
837
  » **queue-tester:** Reply with exactly: race-final
938
838
  Queued message (position 1)
939
839
  ⬥ race-final
940
840
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
941
841
  » **queue-tester:** Reply with exactly: queued-from-slash
942
842
  ⬥ ok
943
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
843
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000777>"
944
844
  `)
945
845
  },
946
846
  12_000,
@@ -1066,7 +966,7 @@ e2eTest('thread message queue ordering', () => {
1066
966
  --- from: assistant (TestBot)
1067
967
  *using deterministic-provider/deterministic-v2*
1068
968
  ⬥ ok
1069
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
969
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000777>
1070
970
  » **queue-tester:** Reply with exactly: race-final
1071
971
  Removed queued message (was position 1): Reply with exactly: removed-queued-message
1072
972
  Queued message (position 2)
@@ -1080,98 +980,6 @@ e2eTest('thread message queue ordering', () => {
1080
980
  12_000,
1081
981
  )
1082
982
 
1083
- test(
1084
- 'queued message waits for running session and then processes next',
1085
- async () => {
1086
- // When a new message arrives while a session is running, it queues and
1087
- // runs after the in-flight request completes.
1088
- //
1089
- // 1. Fast setup: establish session
1090
- await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1091
- content: 'Reply with exactly: delta',
1092
- })
1093
-
1094
- const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1095
- timeout: 4_000,
1096
- predicate: (t) => {
1097
- return t.name === 'Reply with exactly: delta'
1098
- },
1099
- })
1100
-
1101
- const th = discord.thread(thread.id)
1102
- const firstReply = await th.waitForBotReply({ timeout: 4_000 })
1103
- expect(firstReply.content.trim().length).toBeGreaterThan(0)
1104
-
1105
- const before = await th.getMessages()
1106
- const beforeBotCount = before.filter((m) => {
1107
- return m.author.id === discord.botUserId
1108
- }).length
1109
-
1110
- // 2. Send B, then quickly send C to enqueue behind B.
1111
- await th.user(TEST_USER_ID).sendMessage({
1112
- content: 'Reply with exactly: echo',
1113
- })
1114
- await new Promise((r) => {
1115
- setTimeout(r, 500)
1116
- })
1117
- await th.user(TEST_USER_ID).sendMessage({
1118
- content: 'Reply with exactly: foxtrot',
1119
- })
1120
-
1121
- // 3. Poll until foxtrot's user message has a bot reply after it.
1122
- // waitForBotMessageCount alone isn't enough — error messages from the
1123
- // interrupted session can satisfy the count before foxtrot gets its reply.
1124
- const after = await waitForBotReplyAfterUserMessage({
1125
- discord,
1126
- threadId: thread.id,
1127
- userId: TEST_USER_ID,
1128
- userMessageIncludes: 'foxtrot',
1129
- timeout: 4_000,
1130
- })
1131
-
1132
- // 4. Foxtrot got a bot response after B/C were processed.
1133
- const afterBotMessages = after.filter((m) => {
1134
- return m.author.id === discord.botUserId
1135
- })
1136
- expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1)
1137
-
1138
- await waitForFooterMessage({
1139
- discord,
1140
- threadId: thread.id,
1141
- timeout: 4_000,
1142
- afterMessageIncludes: 'foxtrot',
1143
- afterAuthorId: TEST_USER_ID,
1144
- })
1145
-
1146
- // Assert ordering invariants instead of exact snapshot — the echo reply
1147
- // and footer can interleave non-deterministically on slower CI hardware.
1148
- const finalMessages = await th.getMessages()
1149
- const userEchoIndex = finalMessages.findIndex((m) => {
1150
- return m.author.id === TEST_USER_ID && m.content.includes('echo')
1151
- })
1152
- const userFoxtrotIndex = finalMessages.findIndex((m) => {
1153
- return m.author.id === TEST_USER_ID && m.content.includes('foxtrot')
1154
- })
1155
- expect(userEchoIndex).toBeGreaterThan(-1)
1156
- expect(userFoxtrotIndex).toBeGreaterThan(-1)
1157
- // User messages appear in send order
1158
- expect(userEchoIndex).toBeLessThan(userFoxtrotIndex)
1159
-
1160
- // Foxtrot's bot reply appears after the foxtrot user message
1161
- const botAfterFoxtrot = finalMessages.findIndex((m, i) => {
1162
- return i > userFoxtrotIndex && m.author.id === discord.botUserId
1163
- })
1164
- expect(botAfterFoxtrot).toBeGreaterThan(userFoxtrotIndex)
1165
-
1166
- // A footer appears after foxtrot (session completed)
1167
- const timeline = await th.text()
1168
- expect(timeline).toContain('Reply with exactly: echo')
1169
- expect(timeline).toContain('Reply with exactly: foxtrot')
1170
- expect(timeline).toContain('*project ⋅ main ⋅')
1171
- },
1172
- 8_000,
1173
- )
1174
-
1175
983
  test(
1176
984
  'slow stream still processes queued next message after completion',
1177
985
  async () => {
@@ -1240,7 +1048,7 @@ e2eTest('thread message queue ordering', () => {
1240
1048
  --- from: assistant (TestBot)
1241
1049
  *using deterministic-provider/deterministic-v2*
1242
1050
  ⬥ ok
1243
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
1051
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000777>
1244
1052
  --- from: user (queue-tester)
1245
1053
  Reply with exactly: hotel
1246
1054
  Reply with exactly: india
@@ -1462,7 +1270,7 @@ e2eTest('thread message queue ordering', () => {
1462
1270
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
1463
1271
  » **queue-tester:** Reply with exactly: edited-queued
1464
1272
  ⬥ ok
1465
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
1273
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000777>"
1466
1274
  `)
1467
1275
 
1468
1276
  const finalText = await th.text()
@@ -1567,7 +1375,7 @@ e2eTest('thread message queue ordering', () => {
1567
1375
  Queued at position 1. Edit or delete your message to update the queue
1568
1376
  ⬦ **queue-tester** removed message from queue
1569
1377
  ⬥ slow-busy-reply
1570
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
1378
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000777>"
1571
1379
  `)
1572
1380
  },
1573
1381
  12_000,
@@ -197,13 +197,13 @@ e2eTest('/undo sets revert state and cleans up on next prompt', () => {
197
197
  ⬥ creating undo file
198
198
  ┣ bash _mkdir -p tmp && printf created > tmp/undo-marker.txt_
199
199
  ⬥ undo file created
200
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
200
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
201
201
  Undone - reverted last assistant message
202
202
  --- from: user (undo-tester)
203
203
  Reply with exactly: after-undo-message
204
204
  --- from: assistant (TestBot)
205
205
  ⬥ ok
206
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
206
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
207
207
  `)
208
208
  },
209
209
  20_000,
@@ -2,29 +2,57 @@
2
2
  // Normalizes Discord attachment heuristics for voice-message detection so
3
3
  // message routing, transcription, and empty-prompt guards all agree even when
4
4
  // Discord omits contentType on uploaded audio attachments.
5
+ // iOS videos also have duration_secs; reject those before the duration heuristic.
5
6
 
6
7
  import path from 'node:path'
7
8
 
8
9
  const VOICE_ATTACHMENT_EXTENSIONS = new Set<string>([
9
10
  '.m4a',
10
11
  '.mp3',
11
- '.mp4',
12
12
  '.oga',
13
13
  '.ogg',
14
14
  '.opus',
15
15
  '.wav',
16
16
  ])
17
17
 
18
+ const VIDEO_ATTACHMENT_EXTENSIONS = new Set<string>([
19
+ '.avi',
20
+ '.m4v',
21
+ '.mkv',
22
+ '.mov',
23
+ '.mp4',
24
+ '.webm',
25
+ ])
26
+
18
27
  export type VoiceAttachmentLike = {
19
28
  contentType?: string | null
20
29
  name?: string | null
21
30
  duration?: number | null
22
31
  waveform?: string | null
32
+ width?: number | null
33
+ height?: number | null
34
+ }
35
+
36
+ function isVisualMediaAttachment(attachment: VoiceAttachmentLike): boolean {
37
+ const contentType = attachment.contentType?.trim().toLowerCase() || ''
38
+ if (contentType.startsWith('video/')) return true
39
+
40
+ const extension = path.extname(attachment.name || '').toLowerCase()
41
+ if (VIDEO_ATTACHMENT_EXTENSIONS.has(extension)) return true
42
+
43
+ return (
44
+ typeof attachment.width === 'number' &&
45
+ attachment.width > 0 &&
46
+ typeof attachment.height === 'number' &&
47
+ attachment.height > 0
48
+ )
23
49
  }
24
50
 
25
51
  export function getVoiceAttachmentMatchReason(
26
52
  attachment: VoiceAttachmentLike,
27
53
  ): string | null {
54
+ if (isVisualMediaAttachment(attachment)) return null
55
+
28
56
  const contentType = attachment.contentType?.trim().toLowerCase() || ''
29
57
  if (contentType.startsWith('audio/')) {
30
58
  return `contentType:${contentType}`
@@ -505,7 +505,7 @@ e2eTest('voice message handling', () => {
505
505
  📝 **Transcribed message:** Fix the login bug in auth.ts
506
506
  *using deterministic-provider/deterministic-v2*
507
507
  ⬥ session-reply
508
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
508
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@300000000000000777>"
509
509
  `)
510
510
  expect(finalState.sessionId).toBeDefined()
511
511
 
@@ -609,7 +609,7 @@ e2eTest('voice message handling', () => {
609
609
  📝 **Transcribed message:** Resume the original voice note
610
610
  *using deterministic-provider/deterministic-v2*
611
611
  ⬥ session-reply
612
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
612
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@300000000000000777>"
613
613
  `)
614
614
 
615
615
  const finalState = getThreadState(thread.id)
@@ -687,7 +687,7 @@ e2eTest('voice message handling', () => {
687
687
  📝 **Transcribed message:** Investigate the missing content type path
688
688
  *using deterministic-provider/deterministic-v2*
689
689
  ⬥ session-reply
690
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
690
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@300000000000000777>"
691
691
  `)
692
692
 
693
693
  const messages = await waitForSessionMessages({
@@ -796,14 +796,14 @@ e2eTest('voice message handling', () => {
796
796
  --- from: assistant (TestBot)
797
797
  *using deterministic-provider/deterministic-v2*
798
798
  ⬥ fast-response-done
799
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
799
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@300000000000000777>
800
800
  --- from: user (voice-tester)
801
801
  [attachment: voice-message.ogg]
802
802
  --- from: assistant (TestBot)
803
803
  🎤 Transcribing voice message...
804
804
  📝 **Transcribed message:** Add error handling to the parser
805
805
  ⬥ session-reply
806
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
806
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@300000000000000777>"
807
807
  `)
808
808
  expect(finalState?.sessionId).toBeDefined()
809
809
  if (!finalState?.sessionId) {
@@ -1065,7 +1065,7 @@ e2eTest('voice message handling', () => {
1065
1065
  » **voice-tester:** Voice message transcription from Discord user:
1066
1066
  Queue this task for later
1067
1067
  ⬥ session-reply
1068
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
1068
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@300000000000000777>"
1069
1069
  `)
1070
1070
  expect(finalState.queueItems.length).toBe(0)
1071
1071
 
@@ -1183,14 +1183,14 @@ e2eTest('voice message handling', () => {
1183
1183
  --- from: assistant (TestBot)
1184
1184
  *using deterministic-provider/deterministic-v2*
1185
1185
  ⬥ fast-response-done
1186
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
1186
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@300000000000000777>
1187
1187
  --- from: user (voice-tester)
1188
1188
  [attachment: voice-message.ogg]
1189
1189
  --- from: assistant (TestBot)
1190
1190
  🎤 Transcribing voice message...
1191
1191
  📝 **Transcribed message:** Delayed transcription result
1192
1192
  ⬥ session-reply
1193
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
1193
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@300000000000000777>"
1194
1194
  `)
1195
1195
  expect(finalState.sessionId).toBeDefined()
1196
1196
  expect(finalState.queueItems.length).toBe(0)
@@ -1321,14 +1321,14 @@ e2eTest('voice message handling', () => {
1321
1321
  --- from: assistant (TestBot)
1322
1322
  *using deterministic-provider/deterministic-v2*
1323
1323
  ⬥ fast-response-done
1324
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
1324
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@300000000000000777>
1325
1325
  --- from: user (voice-tester)
1326
1326
  [attachment: voice-message.ogg]
1327
1327
  --- from: assistant (TestBot)
1328
1328
  🎤 Transcribing voice message...
1329
1329
  📝 **Transcribed message:** Queued voice after idle
1330
1330
  ⬥ session-reply
1331
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
1331
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@300000000000000777>"
1332
1332
  `)
1333
1333
  expect(finalState.sessionId).toBeDefined()
1334
1334
  expect(finalState.queueItems.length).toBe(0)
package/src/voice.test.ts CHANGED
@@ -65,6 +65,48 @@ describe('voice attachment detection', () => {
65
65
  ]
66
66
  `)
67
67
  })
68
+
69
+ test('does not treat iOS and Discord videos as voice', () => {
70
+ expect(
71
+ [
72
+ getVoiceAttachmentMatchReason({
73
+ name: 'ScreenRecording_08-26-2026_11-10-29_1.mov',
74
+ contentType: 'video/quicktime',
75
+ duration: 12.4,
76
+ width: 1170,
77
+ height: 2532,
78
+ }),
79
+ getVoiceAttachmentMatchReason({
80
+ name: 'IMG_1234.MOV',
81
+ contentType: null,
82
+ duration: 8,
83
+ width: 1920,
84
+ height: 1080,
85
+ }),
86
+ getVoiceAttachmentMatchReason({
87
+ name: 'clip.mp4',
88
+ contentType: 'video/mp4',
89
+ duration: 4,
90
+ }),
91
+ getVoiceAttachmentMatchReason({
92
+ name: 'clip.mp4',
93
+ contentType: null,
94
+ }),
95
+ isVoiceAttachment({
96
+ name: 'memo.m4a',
97
+ contentType: null,
98
+ duration: 3.2,
99
+ }),
100
+ ]).toMatchInlineSnapshot(`
101
+ [
102
+ null,
103
+ null,
104
+ null,
105
+ null,
106
+ true,
107
+ ]
108
+ `)
109
+ })
68
110
  })
69
111
 
70
112
  describe('extractTranscription', () => {
@@ -172,24 +214,31 @@ describe('extractTranscription', () => {
172
214
  })
173
215
  })
174
216
 
175
- describe('transcribeAudio with real API', () => {
217
+ const liveVoiceTest = describe.skipIf(
218
+ process.env['KIMAKI_LIVE_VOICE_TESTS'] !== '1',
219
+ )
220
+
221
+ liveVoiceTest('transcribeAudio with real API', () => {
176
222
  const audioPath = path.join(
177
223
  import.meta.dirname,
178
224
  '..',
179
225
  'scripts',
180
226
  'example-audio.mp3',
181
227
  )
228
+ const oggPath = path.join(import.meta.dirname, '..', 'scripts', 'example-audio.ogg')
229
+ const geminiLiveTest = test.skipIf(
230
+ !process.env.GEMINI_API_KEY || !fs.existsSync(audioPath),
231
+ )
232
+ const openAiLiveTest = test.skipIf(
233
+ !process.env.OPENAI_API_KEY || !fs.existsSync(audioPath),
234
+ )
235
+ const openAiOggLiveTest = test.skipIf(
236
+ !process.env.OPENAI_API_KEY || !fs.existsSync(oggPath),
237
+ )
182
238
 
183
- test('transcribes with Gemini', { timeout: 30_000 }, async () => {
239
+ geminiLiveTest('transcribes with Gemini', { timeout: 30_000 }, async () => {
184
240
  const apiKey = process.env.GEMINI_API_KEY
185
- if (!apiKey) {
186
- console.log('Skipping: GEMINI_API_KEY not set')
187
- return
188
- }
189
- if (!fs.existsSync(audioPath)) {
190
- console.log('Skipping: example-audio.mp3 not found')
191
- return
192
- }
241
+ if (!apiKey) throw new Error('GEMINI_API_KEY is required')
193
242
 
194
243
  const audio = fs.readFileSync(audioPath)
195
244
  const result = await transcribeAudio({
@@ -205,16 +254,9 @@ describe('transcribeAudio with real API', () => {
205
254
  console.log('Gemini transcription:', result)
206
255
  })
207
256
 
208
- test('transcribes with OpenAI', { timeout: 30_000 }, async () => {
257
+ openAiLiveTest('transcribes with OpenAI', { timeout: 30_000 }, async () => {
209
258
  const apiKey = process.env.OPENAI_API_KEY
210
- if (!apiKey) {
211
- console.log('Skipping: OPENAI_API_KEY not set')
212
- return
213
- }
214
- if (!fs.existsSync(audioPath)) {
215
- console.log('Skipping: example-audio.mp3 not found')
216
- return
217
- }
259
+ if (!apiKey) throw new Error('OPENAI_API_KEY is required')
218
260
 
219
261
  const audio = fs.readFileSync(audioPath)
220
262
  const result = await transcribeAudio({
@@ -230,17 +272,9 @@ describe('transcribeAudio with real API', () => {
230
272
  console.log('OpenAI transcription:', result)
231
273
  })
232
274
 
233
- test('transcribes OGG with OpenAI (converts to WAV)', { timeout: 30_000 }, async () => {
275
+ openAiOggLiveTest('transcribes OGG with OpenAI (converts to WAV)', { timeout: 30_000 }, async () => {
234
276
  const apiKey = process.env.OPENAI_API_KEY
235
- const oggPath = path.join(import.meta.dirname, '..', 'scripts', 'example-audio.ogg')
236
- if (!apiKey) {
237
- console.log('Skipping: OPENAI_API_KEY not set')
238
- return
239
- }
240
- if (!fs.existsSync(oggPath)) {
241
- console.log('Skipping: example-audio.ogg not found')
242
- return
243
- }
277
+ if (!apiKey) throw new Error('OPENAI_API_KEY is required')
244
278
 
245
279
  const audio = fs.readFileSync(oggPath)
246
280
  const result = await transcribeAudio({