kimaki 0.4.29 → 0.4.31
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/LICENSE +21 -0
- package/dist/cli.js +215 -37
- package/dist/commands/ask-question.js +49 -16
- package/dist/discord-bot.js +83 -1
- package/dist/discord-utils.js +4 -1
- package/dist/escape-backticks.test.js +11 -3
- package/dist/session-handler.js +18 -4
- package/dist/system-message.js +14 -6
- package/dist/unnest-code-blocks.js +110 -0
- package/dist/unnest-code-blocks.test.js +213 -0
- package/dist/utils.js +1 -0
- package/package.json +11 -12
- package/src/cli.ts +282 -46
- package/src/commands/ask-question.ts +57 -22
- package/src/discord-bot.ts +97 -1
- package/src/discord-utils.ts +4 -1
- package/src/escape-backticks.test.ts +11 -3
- package/src/session-handler.ts +20 -4
- package/src/system-message.ts +14 -6
- package/src/unnest-code-blocks.test.ts +225 -0
- package/src/unnest-code-blocks.ts +127 -0
- package/src/utils.ts +1 -0
package/src/session-handler.ts
CHANGED
|
@@ -8,12 +8,12 @@ import type { Message, ThreadChannel } from 'discord.js'
|
|
|
8
8
|
import prettyMilliseconds from 'pretty-ms'
|
|
9
9
|
import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js'
|
|
10
10
|
import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js'
|
|
11
|
-
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js'
|
|
11
|
+
import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js'
|
|
12
12
|
import { formatPart } from './message-formatting.js'
|
|
13
13
|
import { getOpencodeSystemMessage } from './system-message.js'
|
|
14
14
|
import { createLogger } from './logger.js'
|
|
15
15
|
import { isAbortError } from './utils.js'
|
|
16
|
-
import { showAskUserQuestionDropdowns } from './commands/ask-question.js'
|
|
16
|
+
import { showAskUserQuestionDropdowns, cancelPendingQuestion } from './commands/ask-question.js'
|
|
17
17
|
import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js'
|
|
18
18
|
|
|
19
19
|
const sessionLogger = createLogger('SESSION')
|
|
@@ -239,6 +239,13 @@ export async function handleOpencodeSession({
|
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Cancel any pending question tool if user sends a new message
|
|
243
|
+
const questionCancelled = await cancelPendingQuestion(thread.id)
|
|
244
|
+
if (questionCancelled) {
|
|
245
|
+
sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`)
|
|
246
|
+
await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`)
|
|
247
|
+
}
|
|
248
|
+
|
|
242
249
|
const abortController = new AbortController()
|
|
243
250
|
abortControllers.set(session.id, abortController)
|
|
244
251
|
|
|
@@ -399,7 +406,8 @@ export async function handleOpencodeSession({
|
|
|
399
406
|
const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
|
|
400
407
|
if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
|
|
401
408
|
lastDisplayedContextPercentage = thresholdCrossed
|
|
402
|
-
|
|
409
|
+
const chunk = `⬦ context usage ${currentPercentage}%`
|
|
410
|
+
await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
403
411
|
}
|
|
404
412
|
}
|
|
405
413
|
}
|
|
@@ -530,6 +538,14 @@ export async function handleOpencodeSession({
|
|
|
530
538
|
`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
|
|
531
539
|
)
|
|
532
540
|
|
|
541
|
+
// Flush any pending text/reasoning parts before showing the dropdown
|
|
542
|
+
// This ensures text the LLM generated before the question tool is shown first
|
|
543
|
+
for (const p of currentParts) {
|
|
544
|
+
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
545
|
+
await sendPartMessage(p)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
533
549
|
await showAskUserQuestionDropdowns({
|
|
534
550
|
thread,
|
|
535
551
|
sessionId: session.id,
|
|
@@ -690,7 +706,7 @@ export async function handleOpencodeSession({
|
|
|
690
706
|
path: { id: session.id },
|
|
691
707
|
body: {
|
|
692
708
|
parts,
|
|
693
|
-
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
709
|
+
system: getOpencodeSystemMessage({ sessionId: session.id, channelId }),
|
|
694
710
|
model: modelParam,
|
|
695
711
|
agent: agentPreference,
|
|
696
712
|
},
|
package/src/system-message.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
// Creates the system message injected into every OpenCode session,
|
|
3
3
|
// including Discord-specific formatting rules, diff commands, and permissions info.
|
|
4
4
|
|
|
5
|
-
export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
|
|
5
|
+
export function getOpencodeSystemMessage({ sessionId, channelId }: { sessionId: string; channelId?: string }) {
|
|
6
6
|
return `
|
|
7
7
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
8
8
|
|
|
9
9
|
The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
|
|
10
10
|
|
|
11
|
-
Your current OpenCode session ID is: ${sessionId}
|
|
11
|
+
Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}
|
|
12
12
|
|
|
13
13
|
## permissions
|
|
14
14
|
|
|
@@ -23,24 +23,32 @@ Only users with these Discord permissions can send messages to the bot:
|
|
|
23
23
|
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
24
24
|
|
|
25
25
|
npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
|
|
26
|
+
${channelId ? `
|
|
27
|
+
## starting new sessions from CLI
|
|
26
28
|
|
|
29
|
+
To start a new thread/session in this channel programmatically, run:
|
|
30
|
+
|
|
31
|
+
npx -y kimaki start-session --channel ${channelId} --prompt "your prompt here"
|
|
32
|
+
|
|
33
|
+
This is useful for automation (cron jobs, GitHub webhooks, n8n, etc.)
|
|
34
|
+
` : ''}
|
|
27
35
|
## showing diffs
|
|
28
36
|
|
|
29
37
|
IMPORTANT: After editing any files, you MUST execute the critique command using the Bash tool to get a diff URL, then share that URL with the user.
|
|
30
38
|
|
|
31
39
|
Execute this after making changes:
|
|
32
40
|
|
|
33
|
-
bunx critique web
|
|
41
|
+
bunx critique web --title "Add user authentication flow"
|
|
34
42
|
|
|
35
43
|
If there are other unrelated changes in the working directory, filter to only show the files you edited:
|
|
36
44
|
|
|
37
|
-
bunx critique web -- path/to/file1.ts path/to/file2.ts
|
|
45
|
+
bunx critique web --title "Fix database connection retry" -- path/to/file1.ts path/to/file2.ts
|
|
38
46
|
|
|
39
47
|
You can also show latest commit changes using:
|
|
40
48
|
|
|
41
|
-
bunx critique web HEAD
|
|
49
|
+
bunx critique web --title "Refactor API endpoints" HEAD
|
|
42
50
|
|
|
43
|
-
bunx critique web HEAD~1 to get the one before last
|
|
51
|
+
bunx critique web --title "Update dependencies" HEAD~1 to get the one before last
|
|
44
52
|
|
|
45
53
|
Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
|
|
46
54
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { test, expect } from 'vitest'
|
|
2
|
+
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
|
|
3
|
+
|
|
4
|
+
test('basic - single item with code block', () => {
|
|
5
|
+
const input = `- Item 1
|
|
6
|
+
\`\`\`js
|
|
7
|
+
const x = 1
|
|
8
|
+
\`\`\``
|
|
9
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
10
|
+
expect(result).toMatchInlineSnapshot(`
|
|
11
|
+
"- Item 1
|
|
12
|
+
|
|
13
|
+
\`\`\`js
|
|
14
|
+
const x = 1
|
|
15
|
+
\`\`\`
|
|
16
|
+
"
|
|
17
|
+
`)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('multiple items - code in middle item only', () => {
|
|
21
|
+
const input = `- Item 1
|
|
22
|
+
- Item 2
|
|
23
|
+
\`\`\`js
|
|
24
|
+
const x = 1
|
|
25
|
+
\`\`\`
|
|
26
|
+
- Item 3`
|
|
27
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
28
|
+
expect(result).toMatchInlineSnapshot(`
|
|
29
|
+
"- Item 1
|
|
30
|
+
- Item 2
|
|
31
|
+
|
|
32
|
+
\`\`\`js
|
|
33
|
+
const x = 1
|
|
34
|
+
\`\`\`
|
|
35
|
+
- Item 3"
|
|
36
|
+
`)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('multiple code blocks in one item', () => {
|
|
40
|
+
const input = `- Item with two code blocks
|
|
41
|
+
\`\`\`js
|
|
42
|
+
const a = 1
|
|
43
|
+
\`\`\`
|
|
44
|
+
\`\`\`python
|
|
45
|
+
b = 2
|
|
46
|
+
\`\`\``
|
|
47
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
48
|
+
expect(result).toMatchInlineSnapshot(`
|
|
49
|
+
"- Item with two code blocks
|
|
50
|
+
|
|
51
|
+
\`\`\`js
|
|
52
|
+
const a = 1
|
|
53
|
+
\`\`\`
|
|
54
|
+
\`\`\`python
|
|
55
|
+
b = 2
|
|
56
|
+
\`\`\`
|
|
57
|
+
"
|
|
58
|
+
`)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('nested list with code', () => {
|
|
62
|
+
const input = `- Item 1
|
|
63
|
+
- Nested item
|
|
64
|
+
\`\`\`js
|
|
65
|
+
const x = 1
|
|
66
|
+
\`\`\`
|
|
67
|
+
- Item 2`
|
|
68
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
69
|
+
expect(result).toMatchInlineSnapshot(`
|
|
70
|
+
"- Item 1
|
|
71
|
+
- Nested item
|
|
72
|
+
|
|
73
|
+
\`\`\`js
|
|
74
|
+
const x = 1
|
|
75
|
+
\`\`\`
|
|
76
|
+
- Item 2"
|
|
77
|
+
`)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('ordered list preserves numbering', () => {
|
|
81
|
+
const input = `1. First item
|
|
82
|
+
\`\`\`js
|
|
83
|
+
const a = 1
|
|
84
|
+
\`\`\`
|
|
85
|
+
2. Second item
|
|
86
|
+
3. Third item`
|
|
87
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
88
|
+
expect(result).toMatchInlineSnapshot(`
|
|
89
|
+
"1. First item
|
|
90
|
+
|
|
91
|
+
\`\`\`js
|
|
92
|
+
const a = 1
|
|
93
|
+
\`\`\`
|
|
94
|
+
2. Second item
|
|
95
|
+
3. Third item"
|
|
96
|
+
`)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('list without code blocks unchanged', () => {
|
|
100
|
+
const input = `- Item 1
|
|
101
|
+
- Item 2
|
|
102
|
+
- Item 3`
|
|
103
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
104
|
+
expect(result).toMatchInlineSnapshot(`
|
|
105
|
+
"- Item 1
|
|
106
|
+
- Item 2
|
|
107
|
+
- Item 3"
|
|
108
|
+
`)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('mixed - some items have code, some dont', () => {
|
|
112
|
+
const input = `- Normal item
|
|
113
|
+
- Item with code
|
|
114
|
+
\`\`\`js
|
|
115
|
+
const x = 1
|
|
116
|
+
\`\`\`
|
|
117
|
+
- Another normal item
|
|
118
|
+
- Another with code
|
|
119
|
+
\`\`\`python
|
|
120
|
+
y = 2
|
|
121
|
+
\`\`\``
|
|
122
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
123
|
+
expect(result).toMatchInlineSnapshot(`
|
|
124
|
+
"- Normal item
|
|
125
|
+
- Item with code
|
|
126
|
+
|
|
127
|
+
\`\`\`js
|
|
128
|
+
const x = 1
|
|
129
|
+
\`\`\`
|
|
130
|
+
- Another normal item
|
|
131
|
+
- Another with code
|
|
132
|
+
|
|
133
|
+
\`\`\`python
|
|
134
|
+
y = 2
|
|
135
|
+
\`\`\`
|
|
136
|
+
"
|
|
137
|
+
`)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('text before and after code in same item', () => {
|
|
141
|
+
const input = `- Start text
|
|
142
|
+
\`\`\`js
|
|
143
|
+
const x = 1
|
|
144
|
+
\`\`\`
|
|
145
|
+
End text`
|
|
146
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
147
|
+
expect(result).toMatchInlineSnapshot(`
|
|
148
|
+
"- Start text
|
|
149
|
+
|
|
150
|
+
\`\`\`js
|
|
151
|
+
const x = 1
|
|
152
|
+
\`\`\`
|
|
153
|
+
- End text
|
|
154
|
+
"
|
|
155
|
+
`)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('preserves content outside lists', () => {
|
|
159
|
+
const input = `# Heading
|
|
160
|
+
|
|
161
|
+
Some paragraph text.
|
|
162
|
+
|
|
163
|
+
- List item
|
|
164
|
+
\`\`\`js
|
|
165
|
+
const x = 1
|
|
166
|
+
\`\`\`
|
|
167
|
+
|
|
168
|
+
More text after.`
|
|
169
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
170
|
+
expect(result).toMatchInlineSnapshot(`
|
|
171
|
+
"# Heading
|
|
172
|
+
|
|
173
|
+
Some paragraph text.
|
|
174
|
+
|
|
175
|
+
- List item
|
|
176
|
+
|
|
177
|
+
\`\`\`js
|
|
178
|
+
const x = 1
|
|
179
|
+
\`\`\`
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
More text after."
|
|
183
|
+
`)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('code block at root level unchanged', () => {
|
|
187
|
+
const input = `\`\`\`js
|
|
188
|
+
const x = 1
|
|
189
|
+
\`\`\``
|
|
190
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
191
|
+
expect(result).toMatchInlineSnapshot(`
|
|
192
|
+
"\`\`\`js
|
|
193
|
+
const x = 1
|
|
194
|
+
\`\`\`"
|
|
195
|
+
`)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('handles code block without language', () => {
|
|
199
|
+
const input = `- Item
|
|
200
|
+
\`\`\`
|
|
201
|
+
plain code
|
|
202
|
+
\`\`\``
|
|
203
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
204
|
+
expect(result).toMatchInlineSnapshot(`
|
|
205
|
+
"- Item
|
|
206
|
+
|
|
207
|
+
\`\`\`
|
|
208
|
+
plain code
|
|
209
|
+
\`\`\`
|
|
210
|
+
"
|
|
211
|
+
`)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('handles empty list item with code', () => {
|
|
215
|
+
const input = `- \`\`\`js
|
|
216
|
+
const x = 1
|
|
217
|
+
\`\`\``
|
|
218
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
219
|
+
expect(result).toMatchInlineSnapshot(`
|
|
220
|
+
"\`\`\`js
|
|
221
|
+
const x = 1
|
|
222
|
+
\`\`\`
|
|
223
|
+
"
|
|
224
|
+
`)
|
|
225
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Unnest code blocks from list items for Discord.
|
|
2
|
+
// Discord doesn't render code blocks inside lists, so this hoists them
|
|
3
|
+
// to root level while preserving list structure.
|
|
4
|
+
|
|
5
|
+
import { Lexer, type Token, type Tokens } from 'marked'
|
|
6
|
+
|
|
7
|
+
type Segment =
|
|
8
|
+
| { type: 'list-item'; prefix: string; content: string }
|
|
9
|
+
| { type: 'code'; content: string }
|
|
10
|
+
|
|
11
|
+
export function unnestCodeBlocksFromLists(markdown: string): string {
|
|
12
|
+
const lexer = new Lexer()
|
|
13
|
+
const tokens = lexer.lex(markdown)
|
|
14
|
+
|
|
15
|
+
const result: string[] = []
|
|
16
|
+
for (const token of tokens) {
|
|
17
|
+
if (token.type === 'list') {
|
|
18
|
+
const segments = processListToken(token as Tokens.List)
|
|
19
|
+
result.push(renderSegments(segments))
|
|
20
|
+
} else {
|
|
21
|
+
result.push(token.raw)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result.join('')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function processListToken(list: Tokens.List): Segment[] {
|
|
28
|
+
const segments: Segment[] = []
|
|
29
|
+
const start = typeof list.start === 'number' ? list.start : parseInt(list.start, 10) || 1
|
|
30
|
+
const prefix = list.ordered ? (i: number) => `${start + i}. ` : () => '- '
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < list.items.length; i++) {
|
|
33
|
+
const item = list.items[i]!
|
|
34
|
+
const itemSegments = processListItem(item, prefix(i))
|
|
35
|
+
segments.push(...itemSegments)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return segments
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function processListItem(item: Tokens.ListItem, prefix: string): Segment[] {
|
|
42
|
+
const segments: Segment[] = []
|
|
43
|
+
let currentText: string[] = []
|
|
44
|
+
|
|
45
|
+
const flushText = (): void => {
|
|
46
|
+
const text = currentText.join('').trim()
|
|
47
|
+
if (text) {
|
|
48
|
+
segments.push({ type: 'list-item', prefix, content: text })
|
|
49
|
+
}
|
|
50
|
+
currentText = []
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const token of item.tokens) {
|
|
54
|
+
if (token.type === 'code') {
|
|
55
|
+
flushText()
|
|
56
|
+
const codeToken = token as Tokens.Code
|
|
57
|
+
const lang = codeToken.lang || ''
|
|
58
|
+
segments.push({
|
|
59
|
+
type: 'code',
|
|
60
|
+
content: '```' + lang + '\n' + codeToken.text + '\n```\n',
|
|
61
|
+
})
|
|
62
|
+
} else if (token.type === 'list') {
|
|
63
|
+
flushText()
|
|
64
|
+
// Recursively process nested list - segments bubble up
|
|
65
|
+
const nestedSegments = processListToken(token as Tokens.List)
|
|
66
|
+
segments.push(...nestedSegments)
|
|
67
|
+
} else {
|
|
68
|
+
currentText.push(extractText(token))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
flushText()
|
|
73
|
+
|
|
74
|
+
// If no segments were created (empty item), return empty
|
|
75
|
+
if (segments.length === 0) {
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// If item had no code blocks (all segments are list-items from this level),
|
|
80
|
+
// return original raw to preserve formatting
|
|
81
|
+
const hasCode = segments.some((s) => s.type === 'code')
|
|
82
|
+
if (!hasCode) {
|
|
83
|
+
return [{ type: 'list-item', prefix: '', content: item.raw }]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return segments
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractText(token: Token): string {
|
|
90
|
+
if (token.type === 'text') {
|
|
91
|
+
return (token as Tokens.Text).text
|
|
92
|
+
}
|
|
93
|
+
if (token.type === 'space') {
|
|
94
|
+
return ''
|
|
95
|
+
}
|
|
96
|
+
if ('raw' in token) {
|
|
97
|
+
return token.raw
|
|
98
|
+
}
|
|
99
|
+
return ''
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderSegments(segments: Segment[]): string {
|
|
103
|
+
const result: string[] = []
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < segments.length; i++) {
|
|
106
|
+
const segment = segments[i]!
|
|
107
|
+
const prev = segments[i - 1]
|
|
108
|
+
|
|
109
|
+
if (segment.type === 'code') {
|
|
110
|
+
// Add newline before code if previous was a list item
|
|
111
|
+
if (prev && prev.type === 'list-item') {
|
|
112
|
+
result.push('\n')
|
|
113
|
+
}
|
|
114
|
+
result.push(segment.content)
|
|
115
|
+
} else {
|
|
116
|
+
// list-item
|
|
117
|
+
if (segment.prefix) {
|
|
118
|
+
result.push(segment.prefix + segment.content + '\n')
|
|
119
|
+
} else {
|
|
120
|
+
// Raw content (no prefix means it's original raw)
|
|
121
|
+
result.push(segment.content)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return result.join('')
|
|
127
|
+
}
|
package/src/utils.ts
CHANGED