kimaki 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-model.e2e.test.js +157 -122
- package/dist/analytics.test.js +30 -1
- package/dist/anthropic-account-identity.js +1 -0
- package/dist/anthropic-auth-plugin.js +1 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/send.js +37 -3
- package/dist/cli-commands/session.js +183 -8
- package/dist/cli-commands/task.js +39 -8
- package/dist/cli-parsing.test.js +59 -3
- package/dist/cli-runner.js +94 -74
- package/dist/cli-runner.test.js +37 -1
- package/dist/cli-send-thread.e2e.test.js +1 -1
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/agent.js +93 -82
- package/dist/commands/ask-question.js +21 -32
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +36 -10
- package/dist/commands/model-variant.js +7 -1
- package/dist/commands/model.js +32 -8
- package/dist/commands/multioauth.js +170 -1
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/tasks.js +6 -0
- package/dist/commands/unset-model.js +5 -20
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/database.js +189 -3
- package/dist/db.js +22 -0
- package/dist/db.test.js +167 -1
- package/dist/discord-bot.js +41 -33
- package/dist/discord-command-registration.js +9 -1
- package/dist/discord-utils.js +13 -5
- package/dist/discord-utils.test.js +17 -1
- package/dist/errors.js +5 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +2 -2
- package/dist/file-edit-log.js +197 -0
- package/dist/file-edit-log.test.js +258 -0
- package/dist/format-tables.js +52 -0
- package/dist/format-tables.test.js +93 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
- package/dist/gateway-proxy.e2e.test.js +5 -5
- package/dist/git-worktree-core.js +182 -17
- package/dist/ipc-tools-plugin.js +67 -6
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +9 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +10 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +95 -5
- package/dist/message-formatting.test.js +207 -2
- package/dist/oauth-rotation-shared.js +17 -1
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +21 -0
- package/dist/opencode-command.test.js +60 -0
- package/dist/opencode.js +53 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +1 -1
- package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +40 -246
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
- package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
- package/dist/queue-advanced-question.e2e.test.js +31 -26
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
- package/dist/queue-delete-message.e2e.test.js +1 -1
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
- package/dist/queue-interrupt-drain.e2e.test.js +112 -2
- package/dist/queue-question-select-drain.e2e.test.js +30 -128
- package/dist/runtime-lifecycle.e2e.test.js +10 -37
- package/dist/schema.js +53 -0
- package/dist/session-handler/event-stream-state.js +364 -0
- package/dist/session-handler/event-stream-state.test.js +844 -1
- package/dist/session-handler/model-utils.js +159 -5
- package/dist/session-handler/model-utils.test.js +345 -0
- package/dist/session-handler/thread-session-runtime.js +288 -165
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +7 -39
- package/dist/sleep.e2e.test.js +491 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +76 -9
- package/dist/system-message.test.js +103 -9
- package/dist/task-runner.js +235 -14
- package/dist/task-schedule.js +68 -0
- package/dist/task-schedule.test.js +219 -2
- package/dist/thread-message-queue.e2e.test.js +10 -169
- package/dist/undo-redo.e2e.test.js +2 -2
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-message.e2e.test.js +10 -10
- package/dist/voice.test.js +55 -29
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +29 -10
- package/dist/worktrees.js +226 -443
- package/dist/worktrees.test.js +435 -58
- package/dist/xai-auth-plugin.js +238 -0
- package/dist/xai-auth-state.js +234 -0
- package/dist/xai-auth-state.test.js +183 -0
- package/package.json +9 -7
- package/skills/errore/SKILL.md +18 -0
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +37 -0
- package/skills/npm-package/SKILL.md +55 -35
- package/skills/profano/SKILL.md +20 -3
- package/skills/spiceflow/SKILL.md +63 -4
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +194 -152
- package/src/analytics.test.ts +30 -1
- package/src/analytics.ts +1 -0
- package/src/anthropic-account-identity.ts +1 -0
- package/src/anthropic-auth-plugin.ts +1 -0
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/send.ts +47 -3
- package/src/cli-commands/session.ts +203 -9
- package/src/cli-commands/task.ts +45 -20
- package/src/cli-parsing.test.ts +69 -3
- package/src/cli-runner.test.ts +65 -1
- package/src/cli-runner.ts +116 -93
- package/src/cli-send-thread.e2e.test.ts +1 -1
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/agent.ts +126 -90
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +21 -32
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +43 -12
- package/src/commands/model-variant.ts +8 -1
- package/src/commands/model.ts +43 -8
- package/src/commands/multioauth.ts +196 -2
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/tasks.ts +6 -0
- package/src/commands/unset-model.ts +5 -21
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/database.ts +319 -3
- package/src/db.test.ts +188 -0
- package/src/db.ts +26 -0
- package/src/discord-bot.ts +42 -30
- package/src/discord-command-registration.ts +15 -1
- package/src/discord-utils.test.ts +19 -0
- package/src/discord-utils.ts +21 -6
- package/src/errors.ts +5 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +2 -2
- package/src/file-edit-log.test.ts +287 -0
- package/src/file-edit-log.ts +283 -0
- package/src/format-tables.test.ts +101 -0
- package/src/format-tables.ts +75 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
- package/src/gateway-proxy.e2e.test.ts +5 -5
- package/src/git-worktree-core.ts +247 -20
- package/src/ipc-tools-plugin.ts +79 -8
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +9 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +13 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +271 -2
- package/src/message-formatting.ts +110 -5
- package/src/oauth-rotation-shared.ts +16 -1
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +20 -0
- package/src/opencode-command.test.ts +81 -0
- package/src/opencode.ts +71 -11
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +1 -1
- package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +47 -294
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
- package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
- package/src/queue-advanced-question.e2e.test.ts +35 -29
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
- package/src/queue-delete-message.e2e.test.ts +1 -1
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
- package/src/queue-interrupt-drain.e2e.test.ts +123 -2
- package/src/queue-question-select-drain.e2e.test.ts +32 -157
- package/src/runtime-lifecycle.e2e.test.ts +10 -41
- package/src/schema.sql +29 -1
- package/src/schema.ts +56 -0
- package/src/session-handler/event-stream-state.test.ts +920 -0
- package/src/session-handler/event-stream-state.ts +546 -0
- package/src/session-handler/model-utils.test.ts +415 -0
- package/src/session-handler/model-utils.ts +250 -5
- package/src/session-handler/thread-session-runtime.ts +346 -200
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +9 -48
- package/src/sleep.e2e.test.ts +562 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +21 -0
- package/src/system-message.test.ts +114 -9
- package/src/system-message.ts +85 -10
- package/src/task-runner.ts +304 -13
- package/src/task-schedule.test.ts +248 -2
- package/src/task-schedule.ts +118 -2
- package/src/thread-message-queue.e2e.test.ts +10 -202
- package/src/undo-redo.e2e.test.ts +2 -2
- package/src/voice-attachment.ts +29 -1
- package/src/voice-message.e2e.test.ts +10 -10
- package/src/voice.test.ts +63 -29
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +29 -9
- package/src/worktrees.test.ts +505 -64
- package/src/worktrees.ts +257 -603
- package/src/xai-auth-plugin.ts +303 -0
- package/src/xai-auth-state.test.ts +224 -0
- package/src/xai-auth-state.ts +294 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -173
- package/src/message-finish-field.e2e.test.ts +0 -196
- package/src/subagent-rate-limit-plugin.ts +0 -218
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { afterEach, describe, test, expect } from 'vitest'
|
|
4
|
+
import { formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, serializeEmbeds, serializePoll, serializeMessageSnapshots, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js'
|
|
5
|
+
import { getDataDir } from './config.js'
|
|
3
6
|
import type { Collection, Embed, Message, MessageSnapshot, Poll } from 'discord.js'
|
|
4
7
|
import type { Part } from '@opencode-ai/sdk/v2'
|
|
5
8
|
|
|
@@ -48,6 +51,105 @@ describe('formatPart', () => {
|
|
|
48
51
|
})
|
|
49
52
|
})
|
|
50
53
|
|
|
54
|
+
describe('formatTaskToolTitle', () => {
|
|
55
|
+
function taskPart({
|
|
56
|
+
status,
|
|
57
|
+
input = {},
|
|
58
|
+
title = '',
|
|
59
|
+
sessionId,
|
|
60
|
+
}: {
|
|
61
|
+
status: 'running' | 'completed'
|
|
62
|
+
input?: { description?: string; subagent_type?: string }
|
|
63
|
+
title?: string
|
|
64
|
+
sessionId?: string
|
|
65
|
+
}): Extract<Part, { type: 'tool' }> {
|
|
66
|
+
const base = {
|
|
67
|
+
id: 'prt_task',
|
|
68
|
+
type: 'tool' as const,
|
|
69
|
+
tool: 'task',
|
|
70
|
+
callID: 'call_task',
|
|
71
|
+
sessionID: 'ses_parent',
|
|
72
|
+
messageID: 'msg_assistant',
|
|
73
|
+
}
|
|
74
|
+
if (status === 'completed') {
|
|
75
|
+
return {
|
|
76
|
+
...base,
|
|
77
|
+
state: {
|
|
78
|
+
status,
|
|
79
|
+
input,
|
|
80
|
+
output: '',
|
|
81
|
+
title,
|
|
82
|
+
metadata: sessionId ? { sessionId } : {},
|
|
83
|
+
time: { start: 1, end: 2 },
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
...base,
|
|
89
|
+
state: {
|
|
90
|
+
status,
|
|
91
|
+
input,
|
|
92
|
+
title,
|
|
93
|
+
metadata: sessionId ? { sessionId } : {},
|
|
94
|
+
time: { start: 1 },
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('uses the running task title when OpenAI omits the description', () => {
|
|
100
|
+
expect(
|
|
101
|
+
formatTaskToolTitle(
|
|
102
|
+
taskPart({
|
|
103
|
+
status: 'running',
|
|
104
|
+
input: { subagent_type: 'general' },
|
|
105
|
+
title: 'Classify pending changes',
|
|
106
|
+
sessionId: 'ses_child',
|
|
107
|
+
}),
|
|
108
|
+
),
|
|
109
|
+
).toMatchInlineSnapshot(`"┣ general **Classify pending changes**"`)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('prefers input description on running parts', () => {
|
|
113
|
+
expect(
|
|
114
|
+
formatTaskToolTitle(
|
|
115
|
+
taskPart({
|
|
116
|
+
status: 'running',
|
|
117
|
+
input: { description: 'inspect repo', subagent_type: 'explore' },
|
|
118
|
+
title: 'ignored title',
|
|
119
|
+
sessionId: 'ses_child',
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
).toMatchInlineSnapshot(`"┣ explore **inspect repo**"`)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('does not format completed parts so Discord does not post the line at the end', () => {
|
|
126
|
+
expect(
|
|
127
|
+
formatTaskToolTitle(
|
|
128
|
+
taskPart({
|
|
129
|
+
status: 'completed',
|
|
130
|
+
input: { subagent_type: 'general' },
|
|
131
|
+
title: 'Classify pending changes',
|
|
132
|
+
sessionId: 'ses_child',
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
).toBe('')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('shows queued task calls before the child session exists', () => {
|
|
139
|
+
expect(
|
|
140
|
+
formatTaskToolTitle(
|
|
141
|
+
taskPart({
|
|
142
|
+
status: 'running',
|
|
143
|
+
input: {
|
|
144
|
+
description: 'audit customer pages',
|
|
145
|
+
subagent_type: 'general',
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
),
|
|
149
|
+
).toMatchInlineSnapshot(`"┣ general **audit customer pages**"`)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
51
153
|
describe('formatBashToolTitle', () => {
|
|
52
154
|
test('short single-line command shown in full', () => {
|
|
53
155
|
expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`)
|
|
@@ -429,3 +531,170 @@ describe('serializeMessageSnapshots', () => {
|
|
|
429
531
|
`)
|
|
430
532
|
})
|
|
431
533
|
})
|
|
534
|
+
|
|
535
|
+
describe('getTextAttachments', () => {
|
|
536
|
+
const originalFetch = globalThis.fetch
|
|
537
|
+
|
|
538
|
+
afterEach(() => {
|
|
539
|
+
globalThis.fetch = originalFetch
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
function stubFetch(impl: (url: string) => Promise<Response>) {
|
|
543
|
+
const fetchFn = async (input: string | URL | Request) => impl(String(input))
|
|
544
|
+
fetchFn.preconnect = () => {}
|
|
545
|
+
globalThis.fetch = fetchFn
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function messageWithAttachments(
|
|
549
|
+
attachments: Array<{
|
|
550
|
+
id: string
|
|
551
|
+
name: string
|
|
552
|
+
contentType: string
|
|
553
|
+
url: string
|
|
554
|
+
size: number
|
|
555
|
+
}>,
|
|
556
|
+
) {
|
|
557
|
+
return {
|
|
558
|
+
attachments: new Map(attachments.map((attachment) => {
|
|
559
|
+
return [attachment.id, attachment]
|
|
560
|
+
})),
|
|
561
|
+
} as unknown as Message
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function snapshotAttachments(result: string) {
|
|
565
|
+
return result.replaceAll(getDataDir(), '<dataDir>')
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
test('inlines small text files and saves them locally', async () => {
|
|
569
|
+
stubFetch(async () => {
|
|
570
|
+
return new Response('hello from file', { status: 200 })
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
const result = await getTextAttachments(
|
|
574
|
+
messageWithAttachments([
|
|
575
|
+
{
|
|
576
|
+
id: 'att1',
|
|
577
|
+
name: 'notes.txt',
|
|
578
|
+
contentType: 'text/plain',
|
|
579
|
+
url: 'https://cdn.example/notes.txt',
|
|
580
|
+
size: 16,
|
|
581
|
+
},
|
|
582
|
+
]),
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
const savedPath = path.join(getDataDir(), 'attachments', 'att1-notes.txt')
|
|
586
|
+
expect(fs.readFileSync(savedPath, 'utf8')).toBe('hello from file')
|
|
587
|
+
expect(result).toContain('https://cdn.example/notes.txt')
|
|
588
|
+
expect(result).toContain(savedPath)
|
|
589
|
+
expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
|
|
590
|
+
"<attachment filename="notes.txt" mime="text/plain" size="16" url="https://cdn.example/notes.txt" path="<dataDir>/attachments/att1-notes.txt">
|
|
591
|
+
hello from file
|
|
592
|
+
</attachment>"
|
|
593
|
+
`)
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
test('saves large text files locally without inlining contents', async () => {
|
|
597
|
+
stubFetch(async () => {
|
|
598
|
+
return new Response('SHOULD NOT BE INLINED', { status: 200 })
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
const result = await getTextAttachments(
|
|
602
|
+
messageWithAttachments([
|
|
603
|
+
{
|
|
604
|
+
id: 'att2',
|
|
605
|
+
name: 'huge.log',
|
|
606
|
+
contentType: 'text/plain',
|
|
607
|
+
url: 'https://cdn.discordapp.com/attachments/1/2/huge.log',
|
|
608
|
+
size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1,
|
|
609
|
+
},
|
|
610
|
+
]),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
const savedPath = path.join(getDataDir(), 'attachments', 'att2-huge.log')
|
|
614
|
+
expect(fs.readFileSync(savedPath, 'utf8')).toBe('SHOULD NOT BE INLINED')
|
|
615
|
+
expect(result).not.toContain('SHOULD NOT BE INLINED')
|
|
616
|
+
expect(result).toContain('https://cdn.discordapp.com/attachments/1/2/huge.log')
|
|
617
|
+
expect(result).toContain(savedPath)
|
|
618
|
+
expect(result).toContain('text/plain')
|
|
619
|
+
expect(result).toContain(String(TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1))
|
|
620
|
+
expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
|
|
621
|
+
"<attachment filename="huge.log" mime="text/plain" size="65537" url="https://cdn.discordapp.com/attachments/1/2/huge.log" path="<dataDir>/attachments/att2-huge.log" large="true">
|
|
622
|
+
This file is large (64 KB, text/plain). Contents were not inlined to save context. Read the local path.
|
|
623
|
+
</attachment>"
|
|
624
|
+
`)
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
test('still inlines large prompt.md attachments from kimaki send', async () => {
|
|
628
|
+
stubFetch(async () => {
|
|
629
|
+
return new Response('the actual long prompt', { status: 200 })
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
const result = await getTextAttachments(
|
|
633
|
+
messageWithAttachments([
|
|
634
|
+
{
|
|
635
|
+
id: 'att3',
|
|
636
|
+
name: 'prompt.md',
|
|
637
|
+
contentType: 'text/markdown',
|
|
638
|
+
url: 'https://cdn.example/prompt.md',
|
|
639
|
+
size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 1,
|
|
640
|
+
},
|
|
641
|
+
]),
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
const savedPath = path.join(getDataDir(), 'attachments', 'att3-prompt.md')
|
|
645
|
+
expect(fs.readFileSync(savedPath, 'utf8')).toBe('the actual long prompt')
|
|
646
|
+
expect(result).toContain('the actual long prompt')
|
|
647
|
+
expect(result).toContain('https://cdn.example/prompt.md')
|
|
648
|
+
expect(result).toContain(savedPath)
|
|
649
|
+
expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
|
|
650
|
+
"<attachment filename="prompt.md" mime="text/markdown" size="65537" url="https://cdn.example/prompt.md" path="<dataDir>/attachments/att3-prompt.md">
|
|
651
|
+
the actual long prompt
|
|
652
|
+
</attachment>"
|
|
653
|
+
`)
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
test('keeps small files inlined next to large file references', async () => {
|
|
657
|
+
stubFetch(async (url) => {
|
|
658
|
+
if (url.includes('small.txt')) {
|
|
659
|
+
return new Response('tiny', { status: 200 })
|
|
660
|
+
}
|
|
661
|
+
return new Response('SHOULD NOT BE INLINED', { status: 200 })
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
const result = await getTextAttachments(
|
|
665
|
+
messageWithAttachments([
|
|
666
|
+
{
|
|
667
|
+
id: 'att4',
|
|
668
|
+
name: 'small.txt',
|
|
669
|
+
contentType: 'text/plain',
|
|
670
|
+
url: 'https://cdn.example/small.txt',
|
|
671
|
+
size: 4,
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
id: 'att5',
|
|
675
|
+
name: 'dump.json',
|
|
676
|
+
contentType: 'application/json',
|
|
677
|
+
url: 'https://cdn.example/dump.json',
|
|
678
|
+
size: TEXT_ATTACHMENT_INLINE_LIMIT_BYTES + 50,
|
|
679
|
+
},
|
|
680
|
+
]),
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
expect(result).toContain('tiny')
|
|
684
|
+
expect(result).not.toContain('SHOULD NOT BE INLINED')
|
|
685
|
+
expect(result).toContain('https://cdn.example/small.txt')
|
|
686
|
+
expect(result).toContain('https://cdn.example/dump.json')
|
|
687
|
+
expect(result).toContain(path.join(getDataDir(), 'attachments', 'att4-small.txt'))
|
|
688
|
+
expect(result).toContain(path.join(getDataDir(), 'attachments', 'att5-dump.json'))
|
|
689
|
+
expect(result).toContain('application/json')
|
|
690
|
+
expect(snapshotAttachments(result)).toMatchInlineSnapshot(`
|
|
691
|
+
"<attachment filename="small.txt" mime="text/plain" size="4" url="https://cdn.example/small.txt" path="<dataDir>/attachments/att4-small.txt">
|
|
692
|
+
tiny
|
|
693
|
+
</attachment>
|
|
694
|
+
|
|
695
|
+
<attachment filename="dump.json" mime="application/json" size="65586" url="https://cdn.example/dump.json" path="<dataDir>/attachments/att5-dump.json" large="true">
|
|
696
|
+
This file is large (64 KB, application/json). Contents were not inlined to save context. Read the local path.
|
|
697
|
+
</attachment>"
|
|
698
|
+
`)
|
|
699
|
+
})
|
|
700
|
+
})
|
|
@@ -10,10 +10,13 @@ export type DiscordFileAttachment = FilePartInput & {
|
|
|
10
10
|
sourceUrl?: string
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
13
15
|
import { createLogger, LogPrefix } from './logger.js'
|
|
14
|
-
import { FetchError } from './errors.js'
|
|
16
|
+
import { FetchError, FilesystemOperationError } from './errors.js'
|
|
15
17
|
import { processImage } from './image-utils.js'
|
|
16
18
|
import { parsePatchFileCounts } from './patch-text-parser.js'
|
|
19
|
+
import { getDataDir } from './config.js'
|
|
17
20
|
|
|
18
21
|
// Generic message type compatible with both v1 and v2 SDK
|
|
19
22
|
type GenericSessionMessage = {
|
|
@@ -261,6 +264,52 @@ export function isTextMimeType(contentType: string | null): boolean {
|
|
|
261
264
|
return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix))
|
|
262
265
|
}
|
|
263
266
|
|
|
267
|
+
// Small Discord "send as file" prompts stay inlined. Bigger dumps only get a URL.
|
|
268
|
+
export const TEXT_ATTACHMENT_INLINE_LIMIT_BYTES = 64 * 1024
|
|
269
|
+
const KIMAKI_SEND_PROMPT_ATTACHMENT_NAME = 'prompt.md'
|
|
270
|
+
|
|
271
|
+
function formatAttachmentSize(bytes: number): string {
|
|
272
|
+
if (bytes < 1024) return `${bytes} B`
|
|
273
|
+
if (bytes < 1024 * 1024) {
|
|
274
|
+
const kb = bytes / 1024
|
|
275
|
+
return `${kb < 10 ? kb.toFixed(1) : kb.toFixed(0)} KB`
|
|
276
|
+
}
|
|
277
|
+
const mb = bytes / (1024 * 1024)
|
|
278
|
+
return `${mb < 10 ? mb.toFixed(1) : mb.toFixed(0)} MB`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function shouldInlineTextAttachment(attachment: {
|
|
282
|
+
name: string
|
|
283
|
+
size: number
|
|
284
|
+
}): boolean {
|
|
285
|
+
if (attachment.name === KIMAKI_SEND_PROMPT_ATTACHMENT_NAME) return true
|
|
286
|
+
return attachment.size <= TEXT_ATTACHMENT_INLINE_LIMIT_BYTES
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function textAttachmentAttrs(
|
|
290
|
+
attachment: { name: string; contentType: string | null; size: number; url: string },
|
|
291
|
+
extra: string[] = [],
|
|
292
|
+
): string {
|
|
293
|
+
return [
|
|
294
|
+
`filename="${attachment.name}"`,
|
|
295
|
+
`mime="${attachment.contentType}"`,
|
|
296
|
+
`size="${attachment.size}"`,
|
|
297
|
+
`url="${attachment.url}"`,
|
|
298
|
+
...extra,
|
|
299
|
+
].join(' ')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function safeAttachmentBasename(name: string): string {
|
|
303
|
+
const sanitized = path.basename(name).replace(/\0/g, '')
|
|
304
|
+
if (!sanitized || sanitized === '.' || sanitized === '..') return 'attachment'
|
|
305
|
+
return sanitized
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function localAttachmentPath(attachment: { id?: string; name: string; url: string }): string {
|
|
309
|
+
const id = attachment.id || attachment.url.slice(-12).replace(/[^a-zA-Z0-9]/g, '')
|
|
310
|
+
return path.join(getDataDir(), 'attachments', `${id}-${safeAttachmentBasename(attachment.name)}`)
|
|
311
|
+
}
|
|
312
|
+
|
|
264
313
|
export async function getTextAttachments(message: Message): Promise<string> {
|
|
265
314
|
const textAttachments = Array.from(message.attachments.values()).filter(
|
|
266
315
|
(attachment) => isTextMimeType(attachment.contentType),
|
|
@@ -270,18 +319,40 @@ export async function getTextAttachments(message: Message): Promise<string> {
|
|
|
270
319
|
return ''
|
|
271
320
|
}
|
|
272
321
|
|
|
322
|
+
const attachmentsDir = path.join(getDataDir(), 'attachments')
|
|
323
|
+
fs.mkdirSync(attachmentsDir, { recursive: true })
|
|
324
|
+
|
|
273
325
|
const textContents = await Promise.all(
|
|
274
326
|
textAttachments.map(async (attachment) => {
|
|
275
327
|
const response = await fetch(attachment.url)
|
|
276
328
|
.catch((e) => new FetchError({ url: attachment.url, cause: e }))
|
|
277
329
|
if (response instanceof Error) {
|
|
278
|
-
return `<attachment
|
|
330
|
+
return `<attachment ${textAttachmentAttrs(attachment)} error="${response.message}" />`
|
|
279
331
|
}
|
|
280
332
|
if (!response.ok) {
|
|
281
|
-
return `<attachment
|
|
333
|
+
return `<attachment ${textAttachmentAttrs(attachment)} error="Failed to fetch: ${response.status}" />`
|
|
282
334
|
}
|
|
283
|
-
|
|
284
|
-
|
|
335
|
+
|
|
336
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
337
|
+
const savedPath = localAttachmentPath(attachment)
|
|
338
|
+
const written = await fs.promises
|
|
339
|
+
.writeFile(savedPath, buffer)
|
|
340
|
+
.catch((e) => new FilesystemOperationError({ operation: 'write attachment', cause: e }))
|
|
341
|
+
if (written instanceof Error) {
|
|
342
|
+
logger.error(`Failed to save attachment ${attachment.name}:`, written.message)
|
|
343
|
+
}
|
|
344
|
+
const pathAttr = written instanceof Error ? [] : [`path="${savedPath}"`]
|
|
345
|
+
|
|
346
|
+
if (!shouldInlineTextAttachment(attachment)) {
|
|
347
|
+
const sizeLabel = formatAttachmentSize(attachment.size)
|
|
348
|
+
return [
|
|
349
|
+
`<attachment ${textAttachmentAttrs(attachment, [...pathAttr, 'large="true"'])}>`,
|
|
350
|
+
`This file is large (${sizeLabel}, ${attachment.contentType || 'unknown type'}). Contents were not inlined to save context. Read the local path.`,
|
|
351
|
+
`</attachment>`,
|
|
352
|
+
].join('\n')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return `<attachment ${textAttachmentAttrs(attachment, pathAttr)}>\n${buffer.toString('utf8')}\n</attachment>`
|
|
285
356
|
}),
|
|
286
357
|
)
|
|
287
358
|
|
|
@@ -495,6 +566,22 @@ export function getToolSummaryText(part: Part): string {
|
|
|
495
566
|
return prompt ? `*${escapeInlineMarkdown(prompt.slice(0, 60))}*` : ''
|
|
496
567
|
}
|
|
497
568
|
|
|
569
|
+
// LESSON: tool parts are sent to Discord at `running` status (see
|
|
570
|
+
// shouldSendPart in thread-session-runtime), so `state.title` from the tool
|
|
571
|
+
// result is never rendered here. Everything shown must come from state.input.
|
|
572
|
+
// `until` is already absolute; `duration` is relative, so say "for 2h" —
|
|
573
|
+
// never "until 2h".
|
|
574
|
+
if (part.tool.endsWith('kimaki_sleep')) {
|
|
575
|
+
const until = (part.state.input?.until as string) || ''
|
|
576
|
+
const duration = (part.state.input?.duration as string) || ''
|
|
577
|
+
const reason = (part.state.input?.reason as string) || ''
|
|
578
|
+
const when = until ? `until ${until}` : duration ? `for ${duration}` : ''
|
|
579
|
+
const reasonText = reason ? `_${escapeInlineMarkdown(reason)}_` : ''
|
|
580
|
+
return [when && escapeInlineMarkdown(when), reasonText]
|
|
581
|
+
.filter(Boolean)
|
|
582
|
+
.join(' ')
|
|
583
|
+
}
|
|
584
|
+
|
|
498
585
|
if (!part.state.input) return ''
|
|
499
586
|
|
|
500
587
|
const inputFields = Object.entries(part.state.input)
|
|
@@ -536,6 +623,24 @@ export function formatTodoList(part: Part): string {
|
|
|
536
623
|
return `${num} **${escapeInlineMarkdown(content)}**`
|
|
537
624
|
}
|
|
538
625
|
|
|
626
|
+
export function formatTaskToolTitle(part: Extract<Part, { type: 'tool' }>): string {
|
|
627
|
+
// Running only. The child session can be created later when many tasks queue.
|
|
628
|
+
if (part.tool !== 'task' || part.state.status !== 'running') return ''
|
|
629
|
+
|
|
630
|
+
const description = part.state.input?.description
|
|
631
|
+
const stateTitle = part.state.title
|
|
632
|
+
const title = typeof description === 'string' && description
|
|
633
|
+
? description
|
|
634
|
+
: typeof stateTitle === 'string'
|
|
635
|
+
? stateTitle
|
|
636
|
+
: ''
|
|
637
|
+
if (!title) return ''
|
|
638
|
+
|
|
639
|
+
const subagentType = part.state.input?.subagent_type
|
|
640
|
+
const agent = typeof subagentType === 'string' ? subagentType : 'task'
|
|
641
|
+
return `┣ ${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`
|
|
642
|
+
}
|
|
643
|
+
|
|
539
644
|
export function formatPart(part: Part, prefix?: string): string {
|
|
540
645
|
const pfx = prefix ? `${prefix} ⋅ ` : ''
|
|
541
646
|
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LEGACY. Superseded by @subrouter/opencode, registered alongside the kimaki
|
|
3
|
+
* plugin in opencode.ts. This whole rotation path only cycles accounts inside
|
|
4
|
+
* one provider; subrouter also fails over to a different provider when every
|
|
5
|
+
* account of one is exhausted.
|
|
6
|
+
*
|
|
7
|
+
* Kept because opencode ships no Claude Pro/Max auth of its own, so deleting
|
|
8
|
+
* it would strand anyone still picking `anthropic/*` directly. To move to
|
|
9
|
+
* subrouter, log in again through /login and pick Subrouter.
|
|
10
|
+
*/
|
|
1
11
|
/**
|
|
2
12
|
* Shared utilities for multi-provider OAuth account rotation.
|
|
3
13
|
* Used by both anthropic-auth-state.ts and openai-auth-state.ts to avoid
|
|
@@ -245,6 +255,8 @@ export function shouldRotateAuth(status: number, bodyText: string) {
|
|
|
245
255
|
const haystack = bodyText.toLowerCase()
|
|
246
256
|
if (status === 429) return true
|
|
247
257
|
if (status === 401 || status === 403) return true
|
|
258
|
+
// xAI returns 402 Payment Required when Grok Build usage balance is exhausted
|
|
259
|
+
if (status === 402) return true
|
|
248
260
|
return (
|
|
249
261
|
haystack.includes('rate_limit') ||
|
|
250
262
|
haystack.includes('rate limit') ||
|
|
@@ -252,6 +264,7 @@ export function shouldRotateAuth(status: number, bodyText: string) {
|
|
|
252
264
|
haystack.includes('usage_limit') ||
|
|
253
265
|
haystack.includes('usage_limit_reached') ||
|
|
254
266
|
haystack.includes('usage_not_included') ||
|
|
267
|
+
haystack.includes('balance exhausted') ||
|
|
255
268
|
haystack.includes('invalid api key') ||
|
|
256
269
|
haystack.includes('authentication_error') ||
|
|
257
270
|
haystack.includes('permission_error')
|
|
@@ -262,11 +275,13 @@ export function isRateLimitRetryMessage(message: string) {
|
|
|
262
275
|
const haystack = message.toLowerCase()
|
|
263
276
|
return (
|
|
264
277
|
haystack.includes('429') ||
|
|
278
|
+
haystack.includes('402') ||
|
|
265
279
|
haystack.includes('usage limit') ||
|
|
266
280
|
haystack.includes('rate limit') ||
|
|
267
281
|
haystack.includes('rate_limit') ||
|
|
268
282
|
haystack.includes('usage_limit_reached') ||
|
|
269
|
-
haystack.includes('usage_not_included')
|
|
283
|
+
haystack.includes('usage_not_included') ||
|
|
284
|
+
haystack.includes('balance exhausted')
|
|
270
285
|
)
|
|
271
286
|
}
|
|
272
287
|
|
package/src/openai-auth-state.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
|
|
1
2
|
/**
|
|
2
3
|
* OpenAI OAuth account store and rotation.
|
|
3
4
|
* Mirrors anthropic-auth-state.ts but for OpenAI/Codex OAuth accounts.
|
|
@@ -183,6 +184,25 @@ export async function detectAndRememberNewOpenAIAccount(): Promise<AccountIdenti
|
|
|
183
184
|
return undefined
|
|
184
185
|
}
|
|
185
186
|
|
|
187
|
+
// Check if the identity matches an existing account even though tokens
|
|
188
|
+
// don't match (happens after token refresh rotates both tokens).
|
|
189
|
+
// Without this check we'd show a false "account added" toast.
|
|
190
|
+
if (identity.email || identity.accountId) {
|
|
191
|
+
const matchesByIdentity = store.accounts.some((account) => {
|
|
192
|
+
if (identity.accountId && account.accountId === identity.accountId) return true
|
|
193
|
+
if (identity.email && account.email?.toLowerCase() === identity.email.toLowerCase()) return true
|
|
194
|
+
return false
|
|
195
|
+
})
|
|
196
|
+
if (matchesByIdentity) {
|
|
197
|
+
await withAuthStateLock(async () => {
|
|
198
|
+
const freshStore = await loadOpenAIAccountStore()
|
|
199
|
+
upsertAccount(freshStore, { ...auth, ...identity })
|
|
200
|
+
await saveOpenAIAccountStore(freshStore)
|
|
201
|
+
})
|
|
202
|
+
return undefined
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
186
206
|
// New account: upsert with identity
|
|
187
207
|
await withAuthStateLock(async () => {
|
|
188
208
|
const freshStore = await loadOpenAIAccountStore()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Regression tests for Windows OpenCode command resolution and spawn args.
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
4
5
|
import os from 'node:os'
|
|
5
6
|
import path from 'node:path'
|
|
6
7
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
|
@@ -45,6 +46,86 @@ describe('selectResolvedCommand', () => {
|
|
|
45
46
|
})
|
|
46
47
|
})
|
|
47
48
|
|
|
49
|
+
describe('buildOpencodeServeArgs', () => {
|
|
50
|
+
test('always passes --hostname so opencode.json cannot bind 0.0.0.0', async () => {
|
|
51
|
+
const { buildOpencodeServeArgs } = await import('./opencode.js')
|
|
52
|
+
expect(buildOpencodeServeArgs({ port: 4096 })).toEqual([
|
|
53
|
+
'serve',
|
|
54
|
+
'--port',
|
|
55
|
+
'4096',
|
|
56
|
+
'--hostname',
|
|
57
|
+
'127.0.0.1',
|
|
58
|
+
'--print-logs',
|
|
59
|
+
'--log-level',
|
|
60
|
+
'WARN',
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('passes --hostname when set', async () => {
|
|
65
|
+
const { buildOpencodeServeArgs } = await import('./opencode.js')
|
|
66
|
+
expect(
|
|
67
|
+
buildOpencodeServeArgs({ port: 4096, hostname: '0.0.0.0' }),
|
|
68
|
+
).toEqual([
|
|
69
|
+
'serve',
|
|
70
|
+
'--port',
|
|
71
|
+
'4096',
|
|
72
|
+
'--hostname',
|
|
73
|
+
'0.0.0.0',
|
|
74
|
+
'--print-logs',
|
|
75
|
+
'--log-level',
|
|
76
|
+
'WARN',
|
|
77
|
+
])
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('published runtime artifacts', () => {
|
|
82
|
+
test('lists @subrouter/opencode as a runtime dependency', () => {
|
|
83
|
+
const pkg = JSON.parse(
|
|
84
|
+
fs.readFileSync(path.join(import.meta.dirname, '../package.json'), 'utf8'),
|
|
85
|
+
) as { dependencies?: Record<string, string> }
|
|
86
|
+
expect(pkg.dependencies?.['@subrouter/opencode']).toMatch(/^(workspace:\^|\^)/)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('resolveSubrouterPluginSpec', () => {
|
|
91
|
+
test('uses npm package identity in production for OpenCode deduplication', async () => {
|
|
92
|
+
const { resolveSubrouterPluginSpec } = await import('./opencode.js')
|
|
93
|
+
const require = createRequire(import.meta.url)
|
|
94
|
+
const packageJsonPath = require.resolve('@subrouter/opencode/package.json')
|
|
95
|
+
const version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version
|
|
96
|
+
expect(resolveSubrouterPluginSpec({ isDev: false })).toBe(
|
|
97
|
+
`@subrouter/opencode@${version}`,
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('loads workspace source directly in development', async () => {
|
|
102
|
+
const { resolveSubrouterPluginSpec } = await import('./opencode.js')
|
|
103
|
+
expect(resolveSubrouterPluginSpec({ isDev: true })).toMatch(
|
|
104
|
+
/^file:.*\/subrouter\/opencode\/dist\/index\.js$/,
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('publicOpencodeBindRequiresPassword', () => {
|
|
110
|
+
test('allows loopback without a password', async () => {
|
|
111
|
+
const { publicOpencodeBindRequiresPassword } = await import('./opencode.js')
|
|
112
|
+
expect(publicOpencodeBindRequiresPassword({ hostname: null })).toBe(false)
|
|
113
|
+
expect(publicOpencodeBindRequiresPassword({ hostname: '127.0.0.1' })).toBe(
|
|
114
|
+
false,
|
|
115
|
+
)
|
|
116
|
+
expect(publicOpencodeBindRequiresPassword({ hostname: 'localhost' })).toBe(
|
|
117
|
+
false,
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('requires a password for 0.0.0.0', async () => {
|
|
122
|
+
const { publicOpencodeBindRequiresPassword } = await import('./opencode.js')
|
|
123
|
+
expect(publicOpencodeBindRequiresPassword({ hostname: '0.0.0.0' })).toBe(
|
|
124
|
+
true,
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
48
129
|
describe('getSpawnCommandAndArgs', () => {
|
|
49
130
|
test('wraps windows cmd shims through cmd.exe without double-quoting by node', () => {
|
|
50
131
|
expect(
|