kimaki 0.27.0 → 0.28.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.
- package/dist/agent-model.e2e.test.js +247 -64
- package/dist/anthropic-account-identity.js +8 -1
- package/dist/anthropic-auth-plugin.js +9 -13
- package/dist/anthropic-auth-plugin.test.js +17 -0
- package/dist/bash-tool-schema-plugin.js +32 -0
- package/dist/bash-tool-schema-plugin.test.js +49 -0
- package/dist/cli-commands/project.js +11 -27
- package/dist/cli-commands/send.js +6 -1
- package/dist/cli-commands/session.js +139 -82
- package/dist/cli-commands/task.js +21 -14
- package/dist/cli-parsing.test.js +24 -1
- package/dist/cli-runner.js +16 -4
- package/dist/cli-send-thread.e2e.test.js +9 -9
- package/dist/commands/action-buttons.js +2 -1
- package/dist/commands/add-project.js +4 -7
- package/dist/commands/agent.js +158 -24
- package/dist/commands/ask-question.js +2 -1
- package/dist/commands/btw.js +28 -6
- package/dist/commands/file-upload.js +2 -1
- package/dist/commands/fork-subagent.js +2 -4
- package/dist/commands/fork.js +4 -4
- package/dist/commands/last-sessions.js +17 -10
- package/dist/commands/merge-worktree.js +13 -6
- package/dist/commands/model-variant.js +3 -2
- package/dist/commands/model.js +4 -3
- package/dist/commands/queue.js +3 -2
- package/dist/commands/resume.js +4 -4
- package/dist/commands/session.js +78 -63
- package/dist/cpu-profiler.js +211 -0
- package/dist/cpu-profiler.test.js +160 -0
- package/dist/database.js +28 -0
- package/dist/db.js +11 -0
- package/dist/db.test.js +100 -0
- package/dist/default-channel-provisioning.test.js +148 -0
- package/dist/discord-bot.js +501 -474
- package/dist/discord-command-registration.js +16 -9
- package/dist/discord-command-registration.test.js +17 -0
- package/dist/discord-utils.js +84 -0
- package/dist/discord-utils.test.js +135 -1
- package/dist/errors.js +5 -0
- package/dist/external-directory-permissions.e2e.test.js +11 -7
- package/dist/external-directory-project-deny.e2e.test.js +5 -3
- package/dist/external-opencode-sync.js +94 -17
- package/dist/external-opencode-sync.test.js +206 -0
- package/dist/extract-sdk-error-message.test.js +13 -0
- package/dist/gateway-proxy.e2e.test.js +18 -18
- package/dist/interaction-handler.js +416 -398
- package/dist/ipc-tools-plugin.js +12 -7
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +2 -2
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/logger.js +1 -0
- package/dist/message-formatting.js +163 -44
- package/dist/message-formatting.test.js +473 -13
- package/dist/message-preprocessing.js +105 -1
- package/dist/opencode-command.js +28 -0
- package/dist/opencode-command.test.js +49 -1
- package/dist/opencode-interrupt-plugin.js +4 -0
- package/dist/opencode-interrupt-plugin.test.js +24 -0
- package/dist/opencode.js +29 -3
- package/dist/project-add.test.js +62 -0
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
- package/dist/question-text-before-dropdown.e2e.test.js +5 -5
- package/dist/queue-advanced-abort.e2e.test.js +8 -10
- package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
- package/dist/queue-advanced-footer.e2e.test.js +29 -32
- package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
- package/dist/queue-advanced-permissions-typing.e2e.test.js +9 -7
- package/dist/queue-advanced-question.e2e.test.js +2 -2
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
- package/dist/queue-advanced-typing.e2e.test.js +7 -9
- package/dist/queue-delete-message.e2e.test.js +3 -3
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
- package/dist/queue-interrupt-drain.e2e.test.js +20 -20
- package/dist/queue-question-select-drain.e2e.test.js +7 -7
- package/dist/runtime-lifecycle.e2e.test.js +16 -19
- package/dist/schema.js +1 -1
- package/dist/session-handler/event-stream-state.js +124 -0
- package/dist/session-handler/event-stream-state.test.js +322 -1
- package/dist/session-handler/model-utils.js +16 -3
- package/dist/session-handler/model-utils.test.js +103 -3
- package/dist/session-handler/opencode-session-event-log.js +30 -1
- package/dist/session-handler/thread-ingress.test.js +41 -0
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
- package/dist/session-handler/thread-session-runtime.js +340 -135
- package/dist/session-search.js +123 -4
- package/dist/session-search.test.js +248 -1
- package/dist/session-title-rename.test.js +30 -0
- package/dist/sleep.e2e.test.js +14 -11
- package/dist/system-message.js +45 -12
- package/dist/system-message.test.js +117 -10
- package/dist/task-runner.js +2 -1
- package/dist/task-schedule.js +32 -2
- package/dist/task-schedule.test.js +79 -1
- package/dist/test-utils.js +17 -9
- package/dist/thinking-utils.js +10 -0
- package/dist/thinking-utils.test.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +65 -54
- package/dist/undo-redo.e2e.test.js +17 -9
- package/dist/voice-handler.js +21 -4
- package/dist/voice-message.e2e.test.js +144 -30
- package/dist/voice-routing-input.e2e.test.js +195 -0
- package/dist/voice-routing-question.e2e.test.js +125 -0
- package/dist/voice.js +28 -8
- package/dist/voice.test.js +41 -1
- package/dist/worktree-lifecycle.e2e.test.js +30 -30
- package/package.json +8 -8
- package/skills/bump-holocron-consumers/SKILL.md +137 -0
- package/skills/critique/SKILL.md +4 -4
- package/skills/errore/SKILL.md +56 -232
- package/skills/holocron/SKILL.md +117 -17
- package/src/agent-model.e2e.test.ts +293 -66
- package/src/anthropic-account-identity.ts +19 -1
- package/src/anthropic-auth-plugin.test.ts +19 -0
- package/src/anthropic-auth-plugin.ts +11 -16
- package/src/bash-tool-schema-plugin.test.ts +55 -0
- package/src/bash-tool-schema-plugin.ts +46 -0
- package/src/cli-commands/project.ts +14 -35
- package/src/cli-commands/send.ts +6 -1
- package/src/cli-commands/session.ts +171 -105
- package/src/cli-commands/task.ts +22 -15
- package/src/cli-parsing.test.ts +34 -1
- package/src/cli-runner.ts +16 -3
- package/src/cli-send-thread.e2e.test.ts +9 -11
- package/src/commands/action-buttons.ts +2 -1
- package/src/commands/add-project.ts +4 -8
- package/src/commands/agent.ts +199 -23
- package/src/commands/ask-question.ts +2 -1
- package/src/commands/btw.ts +40 -5
- package/src/commands/file-upload.ts +2 -1
- package/src/commands/fork-subagent.ts +2 -3
- package/src/commands/fork.ts +4 -3
- package/src/commands/last-sessions.ts +10 -4
- package/src/commands/merge-worktree.ts +13 -6
- package/src/commands/model-variant.ts +3 -2
- package/src/commands/model.ts +4 -3
- package/src/commands/queue.ts +3 -2
- package/src/commands/resume.ts +4 -3
- package/src/commands/session.ts +94 -70
- package/src/cpu-profiler.test.ts +183 -0
- package/src/cpu-profiler.ts +293 -0
- package/src/database.ts +40 -0
- package/src/db.test.ts +105 -0
- package/src/db.ts +12 -0
- package/src/default-channel-provisioning.test.ts +155 -0
- package/src/discord-bot.ts +43 -7
- package/src/discord-command-registration.test.ts +20 -0
- package/src/discord-command-registration.ts +27 -11
- package/src/discord-utils.test.ts +157 -0
- package/src/discord-utils.ts +131 -1
- package/src/errors.ts +7 -0
- package/src/external-directory-permissions.e2e.test.ts +11 -7
- package/src/external-directory-project-deny.e2e.test.ts +5 -3
- package/src/external-opencode-sync.test.ts +248 -0
- package/src/external-opencode-sync.ts +122 -19
- package/src/extract-sdk-error-message.test.ts +18 -0
- package/src/gateway-proxy.e2e.test.ts +18 -17
- package/src/interaction-handler.ts +36 -13
- package/src/ipc-tools-plugin.ts +16 -9
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +5 -5
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/logger.ts +1 -0
- package/src/message-formatting.test.ts +586 -13
- package/src/message-formatting.ts +223 -46
- package/src/message-preprocessing.ts +116 -1
- package/src/opencode-command.test.ts +59 -0
- package/src/opencode-command.ts +38 -0
- package/src/opencode-interrupt-plugin.test.ts +32 -0
- package/src/opencode-interrupt-plugin.ts +3 -0
- package/src/opencode.ts +44 -4
- package/src/project-add.test.ts +80 -0
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
- package/src/question-text-before-dropdown.e2e.test.ts +5 -5
- package/src/queue-advanced-abort.e2e.test.ts +8 -9
- package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
- package/src/queue-advanced-footer.e2e.test.ts +30 -31
- package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
- package/src/queue-advanced-permissions-typing.e2e.test.ts +9 -7
- package/src/queue-advanced-question.e2e.test.ts +2 -2
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
- package/src/queue-advanced-typing.e2e.test.ts +8 -8
- package/src/queue-delete-message.e2e.test.ts +3 -3
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
- package/src/queue-interrupt-drain.e2e.test.ts +20 -20
- package/src/queue-question-select-drain.e2e.test.ts +7 -7
- package/src/runtime-lifecycle.e2e.test.ts +16 -18
- package/src/schema.sql +1 -1
- package/src/schema.ts +1 -1
- package/src/session-handler/event-stream-state.test.ts +353 -0
- package/src/session-handler/event-stream-state.ts +179 -0
- package/src/session-handler/model-utils.test.ts +125 -3
- package/src/session-handler/model-utils.ts +36 -4
- package/src/session-handler/opencode-session-event-log.ts +31 -1
- package/src/session-handler/thread-ingress.test.ts +54 -0
- package/src/session-handler/thread-runtime-state.ts +3 -0
- package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
- package/src/session-handler/thread-session-runtime.ts +427 -158
- package/src/session-search.test.ts +281 -0
- package/src/session-search.ts +241 -7
- package/src/session-title-rename.test.ts +45 -0
- package/src/sleep.e2e.test.ts +14 -11
- package/src/store.ts +1 -0
- package/src/system-message.test.ts +141 -10
- package/src/system-message.ts +66 -11
- package/src/task-runner.ts +2 -1
- package/src/task-schedule.test.ts +94 -0
- package/src/task-schedule.ts +47 -2
- package/src/test-utils.ts +21 -10
- package/src/thinking-utils.test.ts +46 -0
- package/src/thinking-utils.ts +21 -0
- package/src/thread-message-queue.e2e.test.ts +65 -53
- package/src/undo-redo.e2e.test.ts +17 -9
- package/src/voice-handler.ts +23 -4
- package/src/voice-message.e2e.test.ts +149 -29
- package/src/voice-routing-input.e2e.test.ts +203 -0
- package/src/voice-routing-question.e2e.test.ts +131 -0
- package/src/voice.test.ts +44 -0
- package/src/voice.ts +34 -6
- package/src/worktree-lifecycle.e2e.test.ts +30 -30
|
@@ -20,12 +20,19 @@ import { getDataDir } from './config.js'
|
|
|
20
20
|
|
|
21
21
|
// Generic message type compatible with both v1 and v2 SDK
|
|
22
22
|
type GenericSessionMessage = {
|
|
23
|
-
info: { role: string; id?: string }
|
|
23
|
+
info: { role: string; id?: string; parentID?: string }
|
|
24
24
|
parts: Part[]
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const logger = createLogger(LogPrefix.FORMATTING)
|
|
28
28
|
|
|
29
|
+
export const TOOL_PREFIX = '┣ '
|
|
30
|
+
export const FILE_EDIT_PREFIX = '◼︎ '
|
|
31
|
+
export const THINKING_PREFIX = '┣ '
|
|
32
|
+
export const STATUS_PREFIX = '⬦ '
|
|
33
|
+
export const QUEUE_PREFIX = '» '
|
|
34
|
+
export const WORKTREE_PREFIX = STATUS_PREFIX
|
|
35
|
+
|
|
29
36
|
/**
|
|
30
37
|
* Serialize Discord embeds into plain text so the AI model can read them.
|
|
31
38
|
* Each embed becomes an <embed> XML block with title, author, description,
|
|
@@ -166,11 +173,165 @@ function normalizeWhitespace(text: string): string {
|
|
|
166
173
|
return text.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ')
|
|
167
174
|
}
|
|
168
175
|
|
|
176
|
+
export type SessionPartKind = 'text' | 'tool'
|
|
177
|
+
|
|
178
|
+
export function sessionPartKind(part: { type: string }): SessionPartKind {
|
|
179
|
+
return part.type === 'text' ? 'text' : 'tool'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function shouldLeadWithBlankLine({
|
|
183
|
+
previousKind,
|
|
184
|
+
nextKind,
|
|
185
|
+
}: {
|
|
186
|
+
previousKind: SessionPartKind | undefined
|
|
187
|
+
nextKind: SessionPartKind
|
|
188
|
+
}): boolean {
|
|
189
|
+
if (!previousKind) return false
|
|
190
|
+
return previousKind !== nextKind
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function sessionPartContent({
|
|
194
|
+
content,
|
|
195
|
+
leadWithBlankLine,
|
|
196
|
+
}: {
|
|
197
|
+
content: string
|
|
198
|
+
leadWithBlankLine: boolean
|
|
199
|
+
}): string {
|
|
200
|
+
if (!leadWithBlankLine) return content
|
|
201
|
+
return `\n${content}`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function asDiscordQuote(text: string): string {
|
|
205
|
+
const lead = text.startsWith('\n') ? '\n' : ''
|
|
206
|
+
const body = lead ? text.slice(1) : text
|
|
207
|
+
return lead + body.split('\n').map((line) => {
|
|
208
|
+
if (line.startsWith('>')) return line
|
|
209
|
+
return `> ${line}`
|
|
210
|
+
}).join('\n')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isNonEmptyTextPart(part: { type: string; text?: string }): boolean {
|
|
214
|
+
return part.type === 'text' && Boolean(part.text?.trim())
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isRenderableTurnPart(part: { type: string; text?: string }): boolean {
|
|
218
|
+
return isNonEmptyTextPart(part) || part.type === 'tool'
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function nextToolNameAfter({
|
|
222
|
+
parts,
|
|
223
|
+
fromIndex,
|
|
224
|
+
}: {
|
|
225
|
+
parts: Array<{ type: string; tool?: string; text?: string }>
|
|
226
|
+
fromIndex: number
|
|
227
|
+
}): string | undefined {
|
|
228
|
+
for (const part of parts.slice(fromIndex + 1)) {
|
|
229
|
+
if (isNonEmptyTextPart(part)) return undefined
|
|
230
|
+
if (part.type === 'tool' && part.tool) return part.tool
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function shouldQuoteIntermediateTextPart({
|
|
235
|
+
part,
|
|
236
|
+
isLastInTurn,
|
|
237
|
+
nextToolName,
|
|
238
|
+
}: {
|
|
239
|
+
part: { type: string; text?: string }
|
|
240
|
+
isLastInTurn: boolean
|
|
241
|
+
nextToolName?: string
|
|
242
|
+
}): boolean {
|
|
243
|
+
if (!isNonEmptyTextPart(part) || isLastInTurn) return false
|
|
244
|
+
const text = part.text ?? ''
|
|
245
|
+
if (text.includes('<callout')) return false
|
|
246
|
+
if (text.trim().split('\n').length > 2) return false
|
|
247
|
+
if (
|
|
248
|
+
nextToolName === 'question'
|
|
249
|
+
|| nextToolName?.endsWith('kimaki_sleep')
|
|
250
|
+
|| nextToolName?.endsWith('kimaki_action_buttons')
|
|
251
|
+
) {
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
return true
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export type AssistantTurnFlushMode = 'progress' | 'interactive' | 'final'
|
|
258
|
+
|
|
259
|
+
export type PlannedAssistantTurnPart<T extends { id: string; type: string; text?: string }> = {
|
|
260
|
+
part: T
|
|
261
|
+
quoteText: boolean
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function planAssistantTurnFlush<T extends {
|
|
265
|
+
id: string
|
|
266
|
+
type: string
|
|
267
|
+
text?: string
|
|
268
|
+
tool?: string
|
|
269
|
+
time?: { end?: number; created?: number }
|
|
270
|
+
}>({
|
|
271
|
+
parts,
|
|
272
|
+
mode,
|
|
273
|
+
throughPartId,
|
|
274
|
+
}: {
|
|
275
|
+
parts: T[]
|
|
276
|
+
mode: AssistantTurnFlushMode
|
|
277
|
+
throughPartId?: string
|
|
278
|
+
}): {
|
|
279
|
+
send: Array<{ id: string; quoteText: boolean }>
|
|
280
|
+
hold: Array<{ id: string; quoteText: boolean }>
|
|
281
|
+
sendParts: Array<PlannedAssistantTurnPart<T>>
|
|
282
|
+
} {
|
|
283
|
+
const lastText = parts.filter(isNonEmptyTextPart).at(-1)
|
|
284
|
+
const lastTextIndex = lastText
|
|
285
|
+
? parts.findLastIndex((part) => part.id === lastText.id)
|
|
286
|
+
: -1
|
|
287
|
+
const throughIndex = throughPartId
|
|
288
|
+
? parts.findIndex((part) => part.id === throughPartId)
|
|
289
|
+
: -1
|
|
290
|
+
|
|
291
|
+
const sendUntil = (() => {
|
|
292
|
+
if (mode === 'final') return parts.length
|
|
293
|
+
if (mode === 'interactive') {
|
|
294
|
+
if (throughIndex >= 0) return throughIndex + 1
|
|
295
|
+
return parts.length
|
|
296
|
+
}
|
|
297
|
+
if (!lastText) return parts.length
|
|
298
|
+
// Hold unfinished last text and anything after it so tool order stays intact.
|
|
299
|
+
if (!lastText.time?.end) return lastTextIndex
|
|
300
|
+
return parts.length
|
|
301
|
+
})()
|
|
302
|
+
|
|
303
|
+
const sendParts: Array<PlannedAssistantTurnPart<T>> = []
|
|
304
|
+
const holdParts: T[] = []
|
|
305
|
+
for (const [index, part] of parts.entries()) {
|
|
306
|
+
if (index < sendUntil) {
|
|
307
|
+
sendParts.push({
|
|
308
|
+
part,
|
|
309
|
+
quoteText: shouldQuoteIntermediateTextPart({
|
|
310
|
+
part,
|
|
311
|
+
isLastInTurn:
|
|
312
|
+
mode !== 'progress'
|
|
313
|
+
&& parts.filter(isRenderableTurnPart).at(-1)?.id === part.id,
|
|
314
|
+
nextToolName: nextToolNameAfter({ parts, fromIndex: index }),
|
|
315
|
+
}),
|
|
316
|
+
})
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
holdParts.push(part)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
send: sendParts.map((entry) => ({ id: entry.part.id, quoteText: entry.quoteText })),
|
|
324
|
+
hold: holdParts.map((part) => ({ id: part.id, quoteText: false })),
|
|
325
|
+
sendParts,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
169
329
|
// A chunk of formatted content with associated part IDs, ready to be
|
|
170
330
|
// batched into as few Discord messages as possible.
|
|
171
331
|
export type SessionChunk = {
|
|
172
332
|
partIds: string[]
|
|
173
333
|
content: string
|
|
334
|
+
kind: SessionPartKind
|
|
174
335
|
}
|
|
175
336
|
|
|
176
337
|
/**
|
|
@@ -206,7 +367,19 @@ export function collectSessionChunks({
|
|
|
206
367
|
if (!content.trim()) {
|
|
207
368
|
continue
|
|
208
369
|
}
|
|
209
|
-
|
|
370
|
+
const quote = shouldQuoteIntermediateTextPart({
|
|
371
|
+
part,
|
|
372
|
+
isLastInTurn: false,
|
|
373
|
+
nextToolName: nextToolNameAfter({
|
|
374
|
+
parts: message.parts,
|
|
375
|
+
fromIndex: message.parts.indexOf(part),
|
|
376
|
+
}),
|
|
377
|
+
})
|
|
378
|
+
allChunks.push({
|
|
379
|
+
partIds: [part.id],
|
|
380
|
+
content: (quote ? asDiscordQuote(content) : content).trimEnd(),
|
|
381
|
+
kind: sessionPartKind(part),
|
|
382
|
+
})
|
|
210
383
|
}
|
|
211
384
|
}
|
|
212
385
|
|
|
@@ -228,19 +401,37 @@ export function batchChunksForDiscord(chunks: SessionChunk[]): SessionChunk[] {
|
|
|
228
401
|
return []
|
|
229
402
|
}
|
|
230
403
|
const batched: SessionChunk[] = []
|
|
231
|
-
let current: SessionChunk = {
|
|
404
|
+
let current: SessionChunk = {
|
|
405
|
+
partIds: [...chunks[0]!.partIds],
|
|
406
|
+
content: chunks[0]!.content,
|
|
407
|
+
kind: chunks[0]!.kind,
|
|
408
|
+
}
|
|
232
409
|
|
|
233
410
|
for (let i = 1; i < chunks.length; i++) {
|
|
234
411
|
const next = chunks[i]!
|
|
412
|
+
if (next.kind !== current.kind) {
|
|
413
|
+
batched.push(current)
|
|
414
|
+
current = {
|
|
415
|
+
partIds: [...next.partIds],
|
|
416
|
+
content: next.content,
|
|
417
|
+
kind: next.kind,
|
|
418
|
+
}
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
235
421
|
const merged = current.content + '\n' + next.content
|
|
236
422
|
if (merged.length <= DISCORD_BATCH_MAX_LENGTH) {
|
|
237
423
|
current = {
|
|
238
424
|
partIds: [...current.partIds, ...next.partIds],
|
|
239
425
|
content: merged,
|
|
426
|
+
kind: current.kind,
|
|
240
427
|
}
|
|
241
428
|
} else {
|
|
242
429
|
batched.push(current)
|
|
243
|
-
current = {
|
|
430
|
+
current = {
|
|
431
|
+
partIds: [...next.partIds],
|
|
432
|
+
content: next.content,
|
|
433
|
+
kind: next.kind,
|
|
434
|
+
}
|
|
244
435
|
}
|
|
245
436
|
}
|
|
246
437
|
batched.push(current)
|
|
@@ -419,31 +610,23 @@ export async function getFileAttachments(
|
|
|
419
610
|
return results.filter((r) => r !== null) as DiscordFileAttachment[]
|
|
420
611
|
}
|
|
421
612
|
|
|
422
|
-
const MAX_BASH_COMMAND_INLINE_LENGTH =
|
|
613
|
+
const MAX_BASH_COMMAND_INLINE_LENGTH = 50
|
|
423
614
|
|
|
424
|
-
/**
|
|
425
|
-
* Format the inline title for a bash tool part. Handles three cases:
|
|
426
|
-
* 1. Short single-line command → show full command
|
|
427
|
-
* 2. Long/multiline command with description → show description
|
|
428
|
-
* 3. Long/multiline command without description → truncate first line of command
|
|
429
|
-
*
|
|
430
|
-
* The description field was removed from the opencode v2 bash tool schema but
|
|
431
|
-
* kimaki's system prompt instructs models to always send it as an extra field.
|
|
432
|
-
* Case 3 is the fallback when a model omits it.
|
|
433
|
-
*/
|
|
434
615
|
export function formatBashToolTitle({
|
|
435
616
|
command,
|
|
436
617
|
description,
|
|
618
|
+
summary,
|
|
437
619
|
stateTitle,
|
|
438
620
|
}: {
|
|
439
621
|
command: string
|
|
440
622
|
description?: string
|
|
623
|
+
summary?: string
|
|
441
624
|
stateTitle?: string
|
|
442
625
|
}): string {
|
|
443
|
-
|
|
626
|
+
const label = description || summary
|
|
627
|
+
if (!command && !label && !stateTitle) return ''
|
|
444
628
|
|
|
445
629
|
const isSingleLine = !command.includes('\n')
|
|
446
|
-
// Find first non-empty line to handle commands with leading blank lines
|
|
447
630
|
const firstMeaningfulLine =
|
|
448
631
|
command
|
|
449
632
|
.split('\n')
|
|
@@ -453,8 +636,8 @@ export function formatBashToolTitle({
|
|
|
453
636
|
if (command && isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH) {
|
|
454
637
|
return ` _${escapeInlineMarkdown(command)}_`
|
|
455
638
|
}
|
|
456
|
-
if (
|
|
457
|
-
return ` _${escapeInlineMarkdown(
|
|
639
|
+
if (label) {
|
|
640
|
+
return ` _${escapeInlineMarkdown(label)}_`
|
|
458
641
|
}
|
|
459
642
|
if (firstMeaningfulLine.length > 0) {
|
|
460
643
|
const needsTruncation = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH
|
|
@@ -613,14 +796,10 @@ export function formatTodoList(part: Part): string {
|
|
|
613
796
|
})
|
|
614
797
|
const activeTodo = todos[activeIndex]
|
|
615
798
|
if (activeIndex === -1 || !activeTodo) return ''
|
|
616
|
-
// digit-with-period ⒈-⒛ for 1-20, fallback to regular number for 21+
|
|
617
|
-
const digitWithPeriod = '⒈⒉⒊⒋⒌⒍⒎⒏⒐⒑⒒⒓⒔⒕⒖⒗⒘⒙⒚⒛'
|
|
618
799
|
const todoNumber = activeIndex + 1
|
|
619
|
-
const num =
|
|
620
|
-
todoNumber <= 20 ? digitWithPeriod[todoNumber - 1] : `${todoNumber}.`
|
|
621
800
|
const content =
|
|
622
801
|
activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1)
|
|
623
|
-
return `${
|
|
802
|
+
return `${todoNumber}. **${escapeInlineMarkdown(content)}**`
|
|
624
803
|
}
|
|
625
804
|
|
|
626
805
|
export function formatTaskToolTitle(part: Extract<Part, { type: 'tool' }>): string {
|
|
@@ -638,7 +817,7 @@ export function formatTaskToolTitle(part: Extract<Part, { type: 'tool' }>): stri
|
|
|
638
817
|
|
|
639
818
|
const subagentType = part.state.input?.subagent_type
|
|
640
819
|
const agent = typeof subagentType === 'string' ? subagentType : 'task'
|
|
641
|
-
return
|
|
820
|
+
return `${TOOL_PREFIX}${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`
|
|
642
821
|
}
|
|
643
822
|
|
|
644
823
|
export function formatPart(part: Part, prefix?: string): string {
|
|
@@ -647,25 +826,15 @@ export function formatPart(part: Part, prefix?: string): string {
|
|
|
647
826
|
if (part.type === 'text') {
|
|
648
827
|
const text = part.text?.trim()
|
|
649
828
|
if (!text) return ''
|
|
650
|
-
// For subtask text, always use bullet with prefix
|
|
651
829
|
if (prefix) {
|
|
652
|
-
return
|
|
653
|
-
}
|
|
654
|
-
const firstChar = text[0] || ''
|
|
655
|
-
const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|']
|
|
656
|
-
const startsWithMarkdown =
|
|
657
|
-
markdownStarters.includes(firstChar) ||
|
|
658
|
-
/^\d+\./.test(text) ||
|
|
659
|
-
/^<callout[\s>]/i.test(text)
|
|
660
|
-
if (startsWithMarkdown) {
|
|
661
|
-
return `\n${text}`
|
|
830
|
+
return `${pfx}${text}`
|
|
662
831
|
}
|
|
663
|
-
return
|
|
832
|
+
return text
|
|
664
833
|
}
|
|
665
834
|
|
|
666
835
|
if (part.type === 'reasoning') {
|
|
667
836
|
if (!part.text?.trim()) return ''
|
|
668
|
-
return
|
|
837
|
+
return `${THINKING_PREFIX}${pfx}thinking`
|
|
669
838
|
}
|
|
670
839
|
|
|
671
840
|
if (part.type === 'file') {
|
|
@@ -677,23 +846,24 @@ export function formatPart(part: Part, prefix?: string): string {
|
|
|
677
846
|
if (
|
|
678
847
|
part.type === 'step-start' ||
|
|
679
848
|
part.type === 'step-finish' ||
|
|
680
|
-
part.type === 'patch'
|
|
849
|
+
part.type === 'patch' ||
|
|
850
|
+
part.type === 'compaction'
|
|
681
851
|
) {
|
|
682
852
|
return ''
|
|
683
853
|
}
|
|
684
854
|
|
|
685
855
|
if (part.type === 'agent') {
|
|
686
|
-
return
|
|
856
|
+
return `${TOOL_PREFIX}${pfx}agent ${part.id}`
|
|
687
857
|
}
|
|
688
858
|
|
|
689
859
|
if (part.type === 'snapshot') {
|
|
690
|
-
return
|
|
860
|
+
return `${TOOL_PREFIX}${pfx}snapshot ${part.snapshot}`
|
|
691
861
|
}
|
|
692
862
|
|
|
693
863
|
if (part.type === 'tool') {
|
|
694
864
|
if (part.tool === 'todowrite') {
|
|
695
865
|
const formatted = formatTodoList(part)
|
|
696
|
-
return prefix && formatted ?
|
|
866
|
+
return prefix && formatted ? `${TOOL_PREFIX}${pfx}${formatted}` : formatted
|
|
697
867
|
}
|
|
698
868
|
|
|
699
869
|
// Question tool is handled via Discord dropdowns, not text
|
|
@@ -722,11 +892,13 @@ export function formatPart(part: Part, prefix?: string): string {
|
|
|
722
892
|
}
|
|
723
893
|
const command = (part.state.input?.command as string) || ''
|
|
724
894
|
const description = (part.state.input?.description as string) || ''
|
|
895
|
+
const summary = (part.state.input?.summary as string) || ''
|
|
725
896
|
const toolTitle = formatBashToolTitle({
|
|
726
897
|
command,
|
|
727
898
|
description,
|
|
899
|
+
summary,
|
|
728
900
|
})
|
|
729
|
-
return
|
|
901
|
+
return `${TOOL_PREFIX}${pfx}bash${toolTitle}`
|
|
730
902
|
}
|
|
731
903
|
|
|
732
904
|
const summaryText = getToolSummaryText(part)
|
|
@@ -738,9 +910,11 @@ export function formatPart(part: Part, prefix?: string): string {
|
|
|
738
910
|
} else if (part.tool === 'bash') {
|
|
739
911
|
const command = (part.state.input?.command as string) || ''
|
|
740
912
|
const description = (part.state.input?.description as string) || ''
|
|
913
|
+
const summary = (part.state.input?.summary as string) || ''
|
|
741
914
|
const formatted = formatBashToolTitle({
|
|
742
915
|
command,
|
|
743
916
|
description,
|
|
917
|
+
summary,
|
|
744
918
|
stateTitle,
|
|
745
919
|
})
|
|
746
920
|
toolTitle = formatted.startsWith(' ') ? formatted.slice(1) : formatted
|
|
@@ -757,14 +931,17 @@ export function formatPart(part: Part, prefix?: string): string {
|
|
|
757
931
|
part.tool === 'write' ||
|
|
758
932
|
part.tool === 'apply_patch'
|
|
759
933
|
) {
|
|
760
|
-
return
|
|
934
|
+
return FILE_EDIT_PREFIX
|
|
761
935
|
}
|
|
762
|
-
return
|
|
936
|
+
return TOOL_PREFIX
|
|
763
937
|
})()
|
|
764
938
|
const toolParts = [part.tool, toolTitle, summaryText]
|
|
765
939
|
.filter(Boolean)
|
|
766
940
|
.join(' ')
|
|
767
|
-
|
|
941
|
+
if (icon === '⨯') {
|
|
942
|
+
return `${icon} ${pfx}${toolParts}`
|
|
943
|
+
}
|
|
944
|
+
return `${icon}${pfx}${toolParts}`
|
|
768
945
|
}
|
|
769
946
|
|
|
770
947
|
logger.warn('Unknown part type:', part)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Message pre-processing pipeline for incoming Discord messages.
|
|
2
2
|
// Extracts prompt text, voice transcription, file/text attachments, and
|
|
3
3
|
// session context from a Discord Message before handing off to the runtime.
|
|
4
|
+
// Voice side/fresh-chat requests dispatch to separate threads instead.
|
|
4
5
|
//
|
|
5
6
|
// This module exists so discord-bot.ts stays a thin event router and the
|
|
6
7
|
// expensive async work (voice transcription, context fetch, attachment
|
|
@@ -11,6 +12,7 @@ import type { Message, ThreadChannel } from 'discord.js'
|
|
|
11
12
|
import { DiscordOperationError, OpenCodeSdkError } from './errors.js'
|
|
12
13
|
import type { DiscordFileAttachment } from './message-formatting.js'
|
|
13
14
|
import type { PreprocessResult } from './session-handler/thread-session-runtime.js'
|
|
15
|
+
import { getOrCreateRuntime } from './session-handler/thread-session-runtime.js'
|
|
14
16
|
import type { AgentInfo, RepliedMessageContext } from './system-message.js'
|
|
15
17
|
import {
|
|
16
18
|
resolveMentions,
|
|
@@ -21,7 +23,11 @@ import { processVoiceAttachment } from './voice-handler.js'
|
|
|
21
23
|
import { isVoiceAttachment } from './voice-attachment.js'
|
|
22
24
|
import { initializeOpencodeForDirectory } from './opencode.js'
|
|
23
25
|
import { getCompactSessionContext, getLastSessionId } from './markdown.js'
|
|
24
|
-
import { getThreadSession } from './database.js'
|
|
26
|
+
import { getThreadSession, getThreadWorktreeOrWorkspace } from './database.js'
|
|
27
|
+
import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage } from './discord-utils.js'
|
|
28
|
+
import { forkSessionToBtwThread } from './commands/btw.js'
|
|
29
|
+
import { createNewSessionThread } from './commands/session.js'
|
|
30
|
+
import type { TranscriptionResult } from './voice.js'
|
|
25
31
|
import * as errore from 'errore'
|
|
26
32
|
import { createLogger, LogPrefix } from './logger.js'
|
|
27
33
|
import { notifyError } from './sentry.js'
|
|
@@ -32,6 +38,96 @@ const voiceLogger = createLogger(LogPrefix.VOICE)
|
|
|
32
38
|
export const VOICE_MESSAGE_TRANSCRIPTION_PREFIX =
|
|
33
39
|
'Voice message transcription from Discord user:\n'
|
|
34
40
|
|
|
41
|
+
// Dispatch before returning a prompt so the source runtime never receives the side request.
|
|
42
|
+
async function routeVoiceSession({
|
|
43
|
+
voiceResult,
|
|
44
|
+
message,
|
|
45
|
+
thread,
|
|
46
|
+
appId,
|
|
47
|
+
}: {
|
|
48
|
+
voiceResult: TranscriptionResult | null
|
|
49
|
+
message: Message
|
|
50
|
+
thread: ThreadChannel
|
|
51
|
+
appId?: string
|
|
52
|
+
}): Promise<boolean> {
|
|
53
|
+
if (!voiceResult?.sessionAction) return false
|
|
54
|
+
const resolved = await resolveWorkingDirectory({ channel: thread })
|
|
55
|
+
if (!resolved) {
|
|
56
|
+
await sendThreadMessage(thread, 'Could not determine project directory. Use /add-project to configure it, then retry.')
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
const images = await getFileAttachments(message)
|
|
60
|
+
const textAttachments = await getTextAttachments(message)
|
|
61
|
+
const caption = resolveMentions(message).trim()
|
|
62
|
+
const prompt = [caption, voiceResult.transcription, textAttachments]
|
|
63
|
+
.filter((part) => part?.trim())
|
|
64
|
+
.join('\n\n')
|
|
65
|
+
const modelPrompt = [caption, `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`, textAttachments]
|
|
66
|
+
.filter((part) => part?.trim())
|
|
67
|
+
.join('\n\n')
|
|
68
|
+
if (voiceResult.sessionAction === 'btw') {
|
|
69
|
+
const result = await forkSessionToBtwThread({
|
|
70
|
+
sourceThread: thread,
|
|
71
|
+
projectDirectory: resolved.projectDirectory,
|
|
72
|
+
sdkDirectory: resolved.workingDirectory,
|
|
73
|
+
prompt,
|
|
74
|
+
modelPrompt,
|
|
75
|
+
images,
|
|
76
|
+
agent: voiceResult.agent,
|
|
77
|
+
userId: message.author.id,
|
|
78
|
+
username: message.member?.displayName || message.author.displayName,
|
|
79
|
+
appId,
|
|
80
|
+
})
|
|
81
|
+
.catch((cause) => new DiscordOperationError({ operation: 'forkVoiceSession', cause }))
|
|
82
|
+
if (result instanceof Error) {
|
|
83
|
+
logger.error('Voice side chat failed:', result)
|
|
84
|
+
await sendThreadMessage(thread, `${result.message}. Start a session first, then retry with /btw.`)
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
await sendThreadMessage(thread, `Session forked! Continue in ${result.thread.toString()}`)
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
const textChannel = await resolveTextChannel(thread)
|
|
91
|
+
if (!textChannel) {
|
|
92
|
+
await sendThreadMessage(thread, 'Could not resolve parent text channel. Retry /new-session in the project channel.')
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
const result = await createNewSessionThread({
|
|
96
|
+
textChannel,
|
|
97
|
+
projectDirectory: resolved.projectDirectory,
|
|
98
|
+
prompt,
|
|
99
|
+
userId: message.author.id,
|
|
100
|
+
sourceWorkspace: await getThreadWorktreeOrWorkspace(thread.id),
|
|
101
|
+
}).catch((cause) => new DiscordOperationError({ operation: 'createVoiceSessionThread', cause }))
|
|
102
|
+
if (result instanceof Error) {
|
|
103
|
+
logger.error('Voice new session failed:', result)
|
|
104
|
+
await sendThreadMessage(thread, `${result.message}. Retry with /new-session.`)
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
await sendThreadMessage(thread, `Created new session in ${result.toString()}`)
|
|
108
|
+
const runtime = getOrCreateRuntime({
|
|
109
|
+
threadId: result.id,
|
|
110
|
+
thread: result,
|
|
111
|
+
projectDirectory: resolved.projectDirectory,
|
|
112
|
+
sdkDirectory: resolved.workingDirectory,
|
|
113
|
+
channelId: textChannel.id,
|
|
114
|
+
appId,
|
|
115
|
+
})
|
|
116
|
+
await runtime.enqueueIncoming({
|
|
117
|
+
prompt: modelPrompt,
|
|
118
|
+
images,
|
|
119
|
+
agent: voiceResult.agent,
|
|
120
|
+
userId: message.author.id,
|
|
121
|
+
username: message.member?.displayName || message.author.displayName,
|
|
122
|
+
appId,
|
|
123
|
+
mode: 'opencode',
|
|
124
|
+
}).catch(async (error) => {
|
|
125
|
+
logger.error('Voice session dispatch failed:', error)
|
|
126
|
+
await sendThreadMessage(result, 'Could not send the request to OpenCode. Send your request again in this thread.')
|
|
127
|
+
})
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
35
131
|
/** Fetch available agents from OpenCode for voice transcription agent selection. */
|
|
36
132
|
async function fetchAvailableAgents(
|
|
37
133
|
getClient: Awaited<ReturnType<typeof initializeOpencodeForDirectory>>,
|
|
@@ -245,7 +341,15 @@ export async function preprocessExistingThreadMessage({
|
|
|
245
341
|
currentSessionContext,
|
|
246
342
|
lastSessionContext,
|
|
247
343
|
agents,
|
|
344
|
+
canForkSession: true,
|
|
248
345
|
})
|
|
346
|
+
if (voiceResult instanceof Error) {
|
|
347
|
+
voiceLogger.warn('Voice request not dispatched:', voiceResult)
|
|
348
|
+
return { prompt: '', mode: 'opencode', skip: true }
|
|
349
|
+
}
|
|
350
|
+
if (await routeVoiceSession({ voiceResult, message, thread, appId })) {
|
|
351
|
+
return { prompt: '', mode: 'opencode', skip: true }
|
|
352
|
+
}
|
|
249
353
|
if (voiceResult) {
|
|
250
354
|
messageContent = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`
|
|
251
355
|
}
|
|
@@ -327,6 +431,13 @@ export async function preprocessNewSessionMessage({
|
|
|
327
431
|
appId,
|
|
328
432
|
agents,
|
|
329
433
|
})
|
|
434
|
+
if (voiceResult instanceof Error) {
|
|
435
|
+
voiceLogger.warn('Voice request not dispatched:', voiceResult)
|
|
436
|
+
return { prompt: '', mode: 'opencode', skip: true }
|
|
437
|
+
}
|
|
438
|
+
if (await routeVoiceSession({ voiceResult, message, thread, appId })) {
|
|
439
|
+
return { prompt: '', mode: 'opencode', skip: true }
|
|
440
|
+
}
|
|
330
441
|
if (voiceResult) {
|
|
331
442
|
prompt = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`
|
|
332
443
|
}
|
|
@@ -424,6 +535,10 @@ export async function preprocessNewThreadMessage({
|
|
|
424
535
|
appId,
|
|
425
536
|
agents,
|
|
426
537
|
})
|
|
538
|
+
if (voiceResult instanceof Error) {
|
|
539
|
+
voiceLogger.warn('Voice request not dispatched:', voiceResult)
|
|
540
|
+
return { prompt: '', mode: 'opencode', skip: true }
|
|
541
|
+
}
|
|
427
542
|
if (voiceResult) {
|
|
428
543
|
messageContent = `${VOICE_MESSAGE_TRANSCRIPTION_PREFIX}${voiceResult.transcription}`
|
|
429
544
|
}
|
|
@@ -7,11 +7,70 @@ import path from 'node:path'
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
|
8
8
|
import {
|
|
9
9
|
ensureKimakiCommandShim,
|
|
10
|
+
getIncompatibleOpencodeVersionError,
|
|
10
11
|
getSpawnCommandAndArgs,
|
|
12
|
+
INCOMPATIBLE_OPENCODE_MAJOR_VERSION,
|
|
13
|
+
isIncompatibleOpencodeMajor,
|
|
14
|
+
parseOpencodeVersion,
|
|
11
15
|
sanitizeShimExecArgv,
|
|
12
16
|
selectResolvedCommand,
|
|
13
17
|
splitCommandLookupOutput,
|
|
14
18
|
} from './opencode-command.js'
|
|
19
|
+
import { OpencodeIncompatibleVersionError } from './errors.js'
|
|
20
|
+
|
|
21
|
+
describe('parseOpencodeVersion', () => {
|
|
22
|
+
test('extracts major.minor.patch from opencode --version output', () => {
|
|
23
|
+
expect(parseOpencodeVersion('1.2.3')).toEqual({
|
|
24
|
+
major: 1,
|
|
25
|
+
minor: 2,
|
|
26
|
+
patch: 3,
|
|
27
|
+
raw: '1.2.3',
|
|
28
|
+
})
|
|
29
|
+
expect(parseOpencodeVersion('opencode 2.0.0\n')).toEqual({
|
|
30
|
+
major: 2,
|
|
31
|
+
minor: 0,
|
|
32
|
+
patch: 0,
|
|
33
|
+
raw: '2.0.0',
|
|
34
|
+
})
|
|
35
|
+
expect(parseOpencodeVersion('v2.1.0-beta.1')).toEqual({
|
|
36
|
+
major: 2,
|
|
37
|
+
minor: 1,
|
|
38
|
+
patch: 0,
|
|
39
|
+
raw: '2.1.0',
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('returns null when output has no three-part version', () => {
|
|
44
|
+
expect(parseOpencodeVersion('')).toBeNull()
|
|
45
|
+
expect(parseOpencodeVersion('opencode')).toBeNull()
|
|
46
|
+
expect(parseOpencodeVersion('2.0')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('isIncompatibleOpencodeMajor', () => {
|
|
51
|
+
test('rejects the incompatible major and allows every other major', () => {
|
|
52
|
+
expect(INCOMPATIBLE_OPENCODE_MAJOR_VERSION).toBe(2)
|
|
53
|
+
expect(isIncompatibleOpencodeMajor({ major: 2 })).toBe(true)
|
|
54
|
+
expect(isIncompatibleOpencodeMajor({ major: 1 })).toBe(false)
|
|
55
|
+
expect(isIncompatibleOpencodeMajor({ major: 0 })).toBe(false)
|
|
56
|
+
expect(isIncompatibleOpencodeMajor({ major: 3 })).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('getIncompatibleOpencodeVersionError', () => {
|
|
61
|
+
test('returns a tagged error for OpenCode 2.x', () => {
|
|
62
|
+
const error = getIncompatibleOpencodeVersionError('2.0.0')
|
|
63
|
+
expect(error).toBeInstanceOf(OpencodeIncompatibleVersionError)
|
|
64
|
+
expect(error?.message).toMatchInlineSnapshot(
|
|
65
|
+
`"Kimaki is not compatible with OpenCode version 2.0.0. Install an OpenCode 1.x release."`,
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('allows 1.x and unparseable output', () => {
|
|
70
|
+
expect(getIncompatibleOpencodeVersionError('1.4.0')).toBeNull()
|
|
71
|
+
expect(getIncompatibleOpencodeVersionError('opencode')).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
})
|
|
15
74
|
|
|
16
75
|
describe('splitCommandLookupOutput', () => {
|
|
17
76
|
test('splits windows command lookup output into trimmed lines', () => {
|