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.
- package/dist/anthropic-auth-plugin.js +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- 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
|
+
}
|
package/src/voice-handler.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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 }
|