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.
@@ -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
- await sendThreadMessage(thread, `⬥ context usage ${currentPercentage}%`)
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
  },
@@ -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
@@ -29,6 +29,7 @@ export function generateBotInstallUrl({
29
29
  PermissionsBitField.Flags.AttachFiles,
30
30
  PermissionsBitField.Flags.Connect,
31
31
  PermissionsBitField.Flags.Speak,
32
+ PermissionsBitField.Flags.ManageRoles,
32
33
  ],
33
34
  scopes = ['bot'],
34
35
  guildId,