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
@@ -0,0 +1,207 @@
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
+
16
+ import { describe, test, expect } from 'vitest'
17
+ import {
18
+ setupQueueAdvancedSuite,
19
+ TEST_USER_ID,
20
+ } from './queue-advanced-e2e-setup.js'
21
+ import { waitForFooterMessage } from './test-utils.js'
22
+ import { getThreadSession } from './database.js'
23
+ import { initializeOpencodeForDirectory } from './opencode.js'
24
+
25
+ const TEXT_CHANNEL_ID = '200000000000001200'
26
+
27
+ const e2eTest = describe
28
+
29
+ e2eTest('/undo sets revert state and cleans up on next prompt', () => {
30
+ const ctx = setupQueueAdvancedSuite({
31
+ channelId: TEXT_CHANNEL_ID,
32
+ channelName: 'qa-undo-e2e',
33
+ dirName: 'qa-undo-e2e',
34
+ username: 'undo-tester',
35
+ })
36
+
37
+ test(
38
+ 'undo sets revert state, next message cleans up reverted messages',
39
+ async () => {
40
+ // 1. Send a message and wait for complete session (footer)
41
+ await ctx.discord
42
+ .channel(TEXT_CHANNEL_ID)
43
+ .user(TEST_USER_ID)
44
+ .sendMessage({
45
+ content: 'Reply with exactly: undo-test-message',
46
+ })
47
+
48
+ const thread = await ctx.discord
49
+ .channel(TEXT_CHANNEL_ID)
50
+ .waitForThread({
51
+ timeout: 4_000,
52
+ predicate: (t) => {
53
+ return t.name === 'Reply with exactly: undo-test-message'
54
+ },
55
+ })
56
+
57
+ const th = ctx.discord.thread(thread.id)
58
+ await th.waitForBotReply({ timeout: 4_000 })
59
+
60
+ await waitForFooterMessage({
61
+ discord: ctx.discord,
62
+ threadId: thread.id,
63
+ timeout: 4_000,
64
+ })
65
+
66
+ // 2. Get session ID and verify it has messages
67
+ const sessionId = await getThreadSession(thread.id)
68
+ expect(sessionId).toBeTruthy()
69
+
70
+ const getClient = await initializeOpencodeForDirectory(
71
+ ctx.directories.projectDirectory,
72
+ )
73
+ if (getClient instanceof Error) {
74
+ throw getClient
75
+ }
76
+
77
+ const beforeMessages = await getClient().session.messages({
78
+ sessionID: sessionId!,
79
+ directory: ctx.directories.projectDirectory,
80
+ })
81
+ const beforeCount = (beforeMessages.data || []).length
82
+ expect(beforeCount).toBeGreaterThan(0)
83
+
84
+ const beforeUserMessages = (beforeMessages.data || []).filter((m) => {
85
+ return m.info.role === 'user'
86
+ })
87
+ const beforeAssistantMessages = (beforeMessages.data || []).filter(
88
+ (m) => {
89
+ return m.info.role === 'assistant'
90
+ },
91
+ )
92
+ expect(beforeUserMessages.length).toBeGreaterThan(0)
93
+ expect(beforeAssistantMessages.length).toBeGreaterThan(0)
94
+
95
+ // Verify no revert state yet
96
+ const beforeSession = await getClient().session.get({
97
+ sessionID: sessionId!,
98
+ })
99
+ expect(beforeSession.data?.revert).toBeFalsy()
100
+
101
+ // 3. Run /undo command
102
+ const { id: undoInteractionId } = await th
103
+ .user(TEST_USER_ID)
104
+ .runSlashCommand({ name: 'undo' })
105
+
106
+ const undoAck = await th.waitForInteractionAck({
107
+ interactionId: undoInteractionId,
108
+ timeout: 4_000,
109
+ })
110
+ expect(undoAck).toBeDefined()
111
+
112
+ // Wait for the undo reply to appear (deferred reply gets edited)
113
+ if (undoAck.messageId) {
114
+ const start = Date.now()
115
+ while (Date.now() - start < 4_000) {
116
+ const messages = await th.getMessages()
117
+ const undoMessage = messages.find((m) => {
118
+ return m.id === undoAck.messageId
119
+ })
120
+ if (undoMessage && undoMessage.content.length > 0) {
121
+ break
122
+ }
123
+ await new Promise((r) => {
124
+ setTimeout(r, 100)
125
+ })
126
+ }
127
+ }
128
+
129
+ // 4. Verify session now has revert state set
130
+ const afterSession = await getClient().session.get({
131
+ sessionID: sessionId!,
132
+ })
133
+ expect(afterSession.data?.revert).toBeTruthy()
134
+ expect(afterSession.data?.revert?.messageID).toBeTruthy()
135
+
136
+ // Messages should still exist (not deleted — cleanup happens on next prompt)
137
+ const afterMessages = await getClient().session.messages({
138
+ sessionID: sessionId!,
139
+ directory: ctx.directories.projectDirectory,
140
+ })
141
+ expect((afterMessages.data || []).length).toBe(beforeCount)
142
+
143
+ // 5. Send a new message — this triggers SessionRevert.cleanup()
144
+ // which removes reverted messages before processing the new prompt
145
+ await th.user(TEST_USER_ID).sendMessage({
146
+ content: 'Reply with exactly: after-undo-message',
147
+ })
148
+
149
+ await waitForFooterMessage({
150
+ discord: ctx.discord,
151
+ threadId: thread.id,
152
+ timeout: 4_000,
153
+ afterMessageIncludes: 'after-undo-message',
154
+ })
155
+
156
+ // 6. Verify reverted messages were cleaned up
157
+ const finalMessages = await getClient().session.messages({
158
+ sessionID: sessionId!,
159
+ directory: ctx.directories.projectDirectory,
160
+ })
161
+ const finalAssistantMessages = (finalMessages.data || []).filter(
162
+ (m) => {
163
+ return m.info.role === 'assistant'
164
+ },
165
+ )
166
+
167
+ // The original assistant message should have been cleaned up,
168
+ // only the new one (from after-undo-message) should remain
169
+ const originalAssistantStillExists = finalAssistantMessages.some(
170
+ (m) => {
171
+ return m.parts.some((p) => {
172
+ return p.type === 'text' && 'text' in p && p.text === 'ok'
173
+ })
174
+ },
175
+ )
176
+ // The first "ok" response was reverted and should be cleaned up.
177
+ // The new response for "after-undo-message" should produce a fresh "ok".
178
+ // We verify the total count dropped: the original user+assistant pair
179
+ // was removed, and replaced by just the new user+assistant pair.
180
+ expect(finalAssistantMessages.length).toBeLessThanOrEqual(
181
+ beforeAssistantMessages.length,
182
+ )
183
+
184
+ // Revert state should be cleared after cleanup
185
+ const finalSession = await getClient().session.get({
186
+ sessionID: sessionId!,
187
+ })
188
+ expect(finalSession.data?.revert).toBeFalsy()
189
+
190
+ // 7. Snapshot the Discord thread
191
+ expect(await th.text()).toMatchInlineSnapshot(`
192
+ "--- from: user (undo-tester)
193
+ Reply with exactly: undo-test-message
194
+ --- from: assistant (TestBot)
195
+ ⬥ ok
196
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
197
+ Undone - reverted last assistant message
198
+ --- from: user (undo-tester)
199
+ Reply with exactly: after-undo-message
200
+ --- from: assistant (TestBot)
201
+ ⬥ ok
202
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
203
+ `)
204
+ },
205
+ 20_000,
206
+ )
207
+ })
package/src/utils.ts CHANGED
@@ -86,6 +86,7 @@ export function generateDiscordInstallUrlForBot({
86
86
  clientId,
87
87
  clientSecret,
88
88
  gatewayCallbackUrl,
89
+ reachableUrl,
89
90
  }: {
90
91
  appId: string
91
92
  mode: BotMode
@@ -94,6 +95,9 @@ export function generateDiscordInstallUrlForBot({
94
95
  /** Optional external URL to redirect to after OAuth completes instead of the
95
96
  * default success page. The website appends ?guild_id=<id> before redirecting. */
96
97
  gatewayCallbackUrl?: string
98
+ /** When set (KIMAKI_INTERNET_REACHABLE_URL), the website stores this URL in
99
+ * gateway_clients.reachable_url so the gateway-proxy connects outbound. */
100
+ reachableUrl?: string
97
101
  }): Error | string {
98
102
  if (mode !== 'gateway') {
99
103
  return generateBotInstallUrl({ clientId: appId })
@@ -115,6 +119,9 @@ export function generateDiscordInstallUrlForBot({
115
119
  if (gatewayCallbackUrl) {
116
120
  url.searchParams.set('kimakiCallbackUrl', gatewayCallbackUrl)
117
121
  }
122
+ if (reachableUrl) {
123
+ url.searchParams.set('reachableUrl', reachableUrl)
124
+ }
118
125
  return url.toString()
119
126
  }
120
127
 
@@ -0,0 +1,51 @@
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
+
6
+ import path from 'node:path'
7
+
8
+ const VOICE_ATTACHMENT_EXTENSIONS = new Set<string>([
9
+ '.m4a',
10
+ '.mp3',
11
+ '.mp4',
12
+ '.oga',
13
+ '.ogg',
14
+ '.opus',
15
+ '.wav',
16
+ ])
17
+
18
+ export type VoiceAttachmentLike = {
19
+ contentType?: string | null
20
+ name?: string | null
21
+ duration?: number | null
22
+ waveform?: string | null
23
+ }
24
+
25
+ export function getVoiceAttachmentMatchReason(
26
+ attachment: VoiceAttachmentLike,
27
+ ): string | null {
28
+ const contentType = attachment.contentType?.trim().toLowerCase() || ''
29
+ if (contentType.startsWith('audio/')) {
30
+ return `contentType:${contentType}`
31
+ }
32
+
33
+ if (typeof attachment.duration === 'number' && attachment.duration > 0) {
34
+ return `duration:${attachment.duration}`
35
+ }
36
+
37
+ if (attachment.waveform?.trim()) {
38
+ return 'waveform'
39
+ }
40
+
41
+ const extension = path.extname(attachment.name || '').toLowerCase()
42
+ if (VOICE_ATTACHMENT_EXTENSIONS.has(extension)) {
43
+ return `extension:${extension}`
44
+ }
45
+
46
+ return null
47
+ }
48
+
49
+ export function isVoiceAttachment(attachment: VoiceAttachmentLike): boolean {
50
+ return getVoiceAttachmentMatchReason(attachment) !== null
51
+ }
@@ -10,11 +10,9 @@ import {
10
10
  entersState,
11
11
  type VoiceConnection,
12
12
  } from '@discordjs/voice'
13
- import { exec } from 'node:child_process'
14
13
  import fs, { createWriteStream } from 'node:fs'
15
14
  import { mkdir } from 'node:fs/promises'
16
15
  import path from 'node:path'
17
- import { promisify } from 'node:util'
18
16
  import { Transform, type TransformCallback } from 'node:stream'
19
17
  import * as prism from 'prism-media'
20
18
  import dedent from 'string-dedent'
@@ -40,11 +38,17 @@ import {
40
38
  sendThreadMessage,
41
39
  escapeDiscordFormatting,
42
40
  SILENT_MESSAGE_FLAGS,
41
+ NOTIFY_MESSAGE_FLAGS,
43
42
  hasKimakiBotPermission,
44
43
  } from './discord-utils.js'
45
44
  import { transcribeAudio, type TranscriptionResult } from './voice.js'
46
45
  import { FetchError } from './errors.js'
47
46
  import { store } from './store.js'
47
+ import {
48
+ getVoiceAttachmentMatchReason,
49
+ isVoiceAttachment,
50
+ } from './voice-attachment.js'
51
+ import { execAsync } from './worktrees.js'
48
52
 
49
53
  import { createLogger, LogPrefix } from './logger.js'
50
54
  import { notifyError } from './sentry.js'
@@ -285,7 +289,7 @@ export async function setupVoiceHandling({
285
289
  if (textChannel?.isTextBased() && 'send' in textChannel) {
286
290
  await textChannel.send({
287
291
  content: `⚠️ Voice session error: ${String(error).slice(0, 1900)}`,
288
- flags: SILENT_MESSAGE_FLAGS,
292
+ flags: NOTIFY_MESSAGE_FLAGS,
289
293
  })
290
294
  }
291
295
  } catch (e) {
@@ -469,13 +473,15 @@ export async function processVoiceAttachment({
469
473
  lastSessionContext,
470
474
  }: ProcessVoiceAttachmentArgs): Promise<TranscriptionResult | null> {
471
475
  const audioAttachment = Array.from(message.attachments.values()).find(
472
- (attachment) => attachment.contentType?.startsWith('audio/'),
476
+ (attachment) => isVoiceAttachment(attachment),
473
477
  )
474
478
 
475
479
  if (!audioAttachment) return null
476
480
 
481
+ const attachmentMatchReason = getVoiceAttachmentMatchReason(audioAttachment)
482
+
477
483
  voiceLogger.log(
478
- `Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType})`,
484
+ `Detected audio attachment: ${audioAttachment.name} (${audioAttachment.contentType || 'no contentType'}, ${attachmentMatchReason || 'unknown reason'})`,
479
485
  )
480
486
 
481
487
  await sendThreadMessage(thread, '🎤 Transcribing voice message...')
@@ -529,6 +535,7 @@ export async function processVoiceAttachment({
529
535
  await sendThreadMessage(
530
536
  thread,
531
537
  `⚠️ Failed to download audio: ${audioResponse.message}`,
538
+ { flags: NOTIFY_MESSAGE_FLAGS },
532
539
  )
533
540
  return null
534
541
  }
@@ -541,7 +548,6 @@ export async function processVoiceAttachment({
541
548
  if (projectDirectory) {
542
549
  try {
543
550
  voiceLogger.log(`Getting project file tree from ${projectDirectory}`)
544
- const execAsync = promisify(exec)
545
551
  const { stdout } = await execAsync('git ls-files | tree --fromfile -a', {
546
552
  cwd: projectDirectory,
547
553
  })
@@ -620,7 +626,9 @@ export async function processVoiceAttachment({
620
626
  Error: (e) => e.message,
621
627
  })
622
628
  voiceLogger.error(`Transcription failed:`, transcription)
623
- await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}`)
629
+ await sendThreadMessage(thread, `⚠️ Transcription failed: ${errMsg}`, {
630
+ flags: NOTIFY_MESSAGE_FLAGS,
631
+ })
624
632
  return null
625
633
  }
626
634
 
@@ -526,6 +526,101 @@ e2eTest('voice message handling', () => {
526
526
  8_000,
527
527
  )
528
528
 
529
+ test(
530
+ 'voice attachment without content type still transcribes and avoids empty prompt dispatch',
531
+ async () => {
532
+ setDeterministicTranscription({
533
+ transcription: 'Investigate the missing content type path',
534
+ queueMessage: false,
535
+ })
536
+
537
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
538
+ content: '',
539
+ attachments: [
540
+ {
541
+ id: 'voice-no-content-type',
542
+ filename: 'voice-message.ogg',
543
+ size: 1024,
544
+ url: 'https://fake-cdn.discord.test/voice-no-content-type.ogg',
545
+ proxy_url: 'https://fake-cdn.discord.test/voice-no-content-type.ogg',
546
+ },
547
+ ],
548
+ })
549
+
550
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
551
+ timeout: 4_000,
552
+ predicate: (t) => {
553
+ return t.name?.includes('Investigate the missing content type path') ?? false
554
+ },
555
+ })
556
+
557
+ const th = discord.thread(thread.id)
558
+
559
+ await waitForBotMessageContaining({
560
+ discord,
561
+ threadId: thread.id,
562
+ userId: TEST_USER_ID,
563
+ text: 'Transcribing voice message',
564
+ timeout: 4_000,
565
+ })
566
+
567
+ await waitForBotMessageContaining({
568
+ discord,
569
+ threadId: thread.id,
570
+ userId: TEST_USER_ID,
571
+ text: 'Investigate the missing content type path',
572
+ timeout: 4_000,
573
+ })
574
+
575
+ await waitForFooterMessage({
576
+ discord,
577
+ threadId: thread.id,
578
+ timeout: 4_000,
579
+ })
580
+
581
+ const finalState = await waitForThreadState({
582
+ threadId: thread.id,
583
+ predicate: (state) => {
584
+ return Boolean(state.sessionId) && state.queueItems.length === 0
585
+ },
586
+ timeout: 4_000,
587
+ description: 'voice attachment without content type settled',
588
+ })
589
+
590
+ expect(await th.text()).toMatchInlineSnapshot(`
591
+ "--- from: user (voice-tester)
592
+ [attachment: voice-message.ogg]
593
+ --- from: assistant (TestBot)
594
+ 🎤 Transcribing voice message...
595
+ 📝 **Transcribed message:** Investigate the missing content type path
596
+ ⬥ session-reply
597
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
598
+ `)
599
+
600
+ const messages = await waitForSessionMessages({
601
+ projectDirectory: directories.projectDirectory,
602
+ sessionID: finalState.sessionId!,
603
+ timeout: 4_000,
604
+ description: 'voice attachment without content type dispatched once',
605
+ predicate: (all) => {
606
+ const userTexts = getUserTexts(all)
607
+ return userTexts.some((text) => {
608
+ return text.includes('Investigate the missing content type path')
609
+ })
610
+ },
611
+ })
612
+
613
+ const userTexts = getUserTexts(messages)
614
+ expect(userTexts).not.toContain('')
615
+ expect(
616
+ userTexts.some((text) => {
617
+ return text.includes('Investigate the missing content type path')
618
+ }),
619
+ ).toBe(true)
620
+ },
621
+ 8_000,
622
+ )
623
+
529
624
  // ── Test 2: Voice message in thread with idle session ──
530
625
 
531
626
  test(
package/src/voice.test.ts CHANGED
@@ -11,6 +11,10 @@ import {
11
11
  normalizeAudioMediaType,
12
12
  getOpenAIAudioConversionStrategy,
13
13
  } from './voice.js'
14
+ import {
15
+ getVoiceAttachmentMatchReason,
16
+ isVoiceAttachment,
17
+ } from './voice-attachment.js'
14
18
 
15
19
  describe('audio media type routing', () => {
16
20
  test('normalizes m4a aliases to audio/mp4', () => {
@@ -31,6 +35,38 @@ describe('audio media type routing', () => {
31
35
  })
32
36
  })
33
37
 
38
+ describe('voice attachment detection', () => {
39
+ test('detects voice attachments by content type, extension, and waveform metadata', () => {
40
+ expect(
41
+ [
42
+ getVoiceAttachmentMatchReason({
43
+ name: 'voice-message.ogg',
44
+ contentType: 'audio/ogg',
45
+ }),
46
+ getVoiceAttachmentMatchReason({
47
+ name: 'voice-message.ogg',
48
+ contentType: null,
49
+ }),
50
+ getVoiceAttachmentMatchReason({
51
+ name: 'upload.bin',
52
+ contentType: null,
53
+ waveform: 'abc123',
54
+ }),
55
+ isVoiceAttachment({
56
+ name: 'notes.txt',
57
+ contentType: null,
58
+ }),
59
+ ]).toMatchInlineSnapshot(`
60
+ [
61
+ "contentType:audio/ogg",
62
+ "extension:.ogg",
63
+ "waveform",
64
+ false,
65
+ ]
66
+ `)
67
+ })
68
+ })
69
+
34
70
  describe('extractTranscription', () => {
35
71
  test('extracts transcription from tool call', () => {
36
72
  const result = extractTranscription([
@@ -1,93 +0,0 @@
1
- // OpenCode plugin that injects onboarding tutorial system instructions.
2
- // Detects TUTORIAL_WELCOME_TEXT in any text part of the session (the thread
3
- // starter content appears in the user prompt via "Context from thread:..."
4
- // prepended by message-preprocessing.ts). When found, injects
5
- // ONBOARDING_TUTORIAL_INSTRUCTIONS as a synthetic system-reminder.
6
- //
7
- // Exported from opencode-plugin.ts — each export is treated as a separate
8
- // plugin by OpenCode's plugin loader.
9
-
10
- import type { Plugin } from '@opencode-ai/plugin'
11
- import crypto from 'node:crypto'
12
- import * as errore from 'errore'
13
- import {
14
- createLogger,
15
- formatErrorWithStack,
16
- LogPrefix,
17
- } from './logger.js'
18
- import { notifyError } from './sentry.js'
19
- import {
20
- ONBOARDING_TUTORIAL_INSTRUCTIONS,
21
- TUTORIAL_WELCOME_TEXT,
22
- } from './onboarding-tutorial.js'
23
-
24
- const logger = createLogger(LogPrefix.OPENCODE)
25
-
26
- const onboardingTutorialPlugin: Plugin = async () => {
27
- // Track sessions where tutorial instructions have been injected.
28
- // Once injected, never inject again for the same session.
29
- const sessionTutorialInjected = new Set<string>()
30
-
31
- return {
32
- 'chat.message': async (input, output) => {
33
- const hookResult = await errore.tryAsync({
34
- try: async () => {
35
- const { sessionID } = input
36
- if (sessionTutorialInjected.has(sessionID)) {
37
- return
38
- }
39
-
40
- // Check ALL text parts (including system/synthetic) for the
41
- // welcome text. The thread starter content is prepended to the
42
- // user prompt by message-preprocessing.ts as "Context from thread:".
43
- const hasTutorialContext = output.parts.some((part) => {
44
- return part.type === 'text' && part.text.includes(TUTORIAL_WELCOME_TEXT)
45
- })
46
- if (!hasTutorialContext) {
47
- return
48
- }
49
-
50
- sessionTutorialInjected.add(sessionID)
51
-
52
- // Use messageID from the first text part for the synthetic injection
53
- const firstText = output.parts.find((part) => {
54
- return part.type === 'text'
55
- })
56
- if (!firstText) {
57
- return
58
- }
59
-
60
- output.parts.push({
61
- id: `prt_${crypto.randomUUID()}`,
62
- sessionID,
63
- messageID: firstText.messageID,
64
- type: 'text' as const,
65
- text: `<system-reminder>\n${ONBOARDING_TUTORIAL_INSTRUCTIONS}\n</system-reminder>`,
66
- synthetic: true,
67
- })
68
- },
69
- catch: (error) => {
70
- return new Error('onboarding tutorial hook failed', { cause: error })
71
- },
72
- })
73
- if (hookResult instanceof Error) {
74
- logger.warn(
75
- `[onboarding-tutorial-plugin] ${formatErrorWithStack(hookResult)}`,
76
- )
77
- void notifyError(hookResult, 'onboarding tutorial plugin hook failed')
78
- }
79
- },
80
-
81
- event: async ({ event }) => {
82
- if (event.type !== 'session.deleted') {
83
- return
84
- }
85
- const id = event.properties?.info?.id
86
- if (id) {
87
- sessionTutorialInjected.delete(id)
88
- }
89
- },
90
- }
91
- }
92
-
93
- export { onboardingTutorialPlugin }