kimaki 0.4.22 → 0.4.23

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.
@@ -0,0 +1,601 @@
1
+ import type { Part, FilePartInput, Permission } from '@opencode-ai/sdk'
2
+ import type { Message, ThreadChannel } from 'discord.js'
3
+ import prettyMilliseconds from 'pretty-ms'
4
+ import { getDatabase, getSessionModel, getChannelModel } from './database.js'
5
+ import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js'
6
+ import { sendThreadMessage } from './discord-utils.js'
7
+ import { formatPart } from './message-formatting.js'
8
+ import { getOpencodeSystemMessage } from './system-message.js'
9
+ import { createLogger } from './logger.js'
10
+ import { isAbortError } from './utils.js'
11
+
12
+ const sessionLogger = createLogger('SESSION')
13
+ const voiceLogger = createLogger('VOICE')
14
+ const discordLogger = createLogger('DISCORD')
15
+
16
+ export type ParsedCommand = {
17
+ isCommand: true
18
+ command: string
19
+ arguments: string
20
+ } | {
21
+ isCommand: false
22
+ }
23
+
24
+ export function parseSlashCommand(text: string): ParsedCommand {
25
+ const trimmed = text.trim()
26
+ if (!trimmed.startsWith('/')) {
27
+ return { isCommand: false }
28
+ }
29
+ const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/)
30
+ if (!match) {
31
+ return { isCommand: false }
32
+ }
33
+ const command = match[1]!
34
+ const args = match[2]?.trim() || ''
35
+ return { isCommand: true, command, arguments: args }
36
+ }
37
+
38
+ export const abortControllers = new Map<string, AbortController>()
39
+
40
+ export const pendingPermissions = new Map<
41
+ string,
42
+ { permission: Permission; messageId: string; directory: string }
43
+ >()
44
+
45
+ export async function handleOpencodeSession({
46
+ prompt,
47
+ thread,
48
+ projectDirectory,
49
+ originalMessage,
50
+ images = [],
51
+ parsedCommand,
52
+ channelId,
53
+ }: {
54
+ prompt: string
55
+ thread: ThreadChannel
56
+ projectDirectory?: string
57
+ originalMessage?: Message
58
+ images?: FilePartInput[]
59
+ parsedCommand?: ParsedCommand
60
+ channelId?: string
61
+ }): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
62
+ voiceLogger.log(
63
+ `[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
64
+ )
65
+
66
+ const sessionStartTime = Date.now()
67
+
68
+ const directory = projectDirectory || process.cwd()
69
+ sessionLogger.log(`Using directory: ${directory}`)
70
+
71
+ const getClient = await initializeOpencodeForDirectory(directory)
72
+
73
+ const serverEntry = getOpencodeServers().get(directory)
74
+ const port = serverEntry?.port
75
+
76
+ const row = getDatabase()
77
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
78
+ .get(thread.id) as { session_id: string } | undefined
79
+ let sessionId = row?.session_id
80
+ let session
81
+
82
+ if (sessionId) {
83
+ sessionLogger.log(`Attempting to reuse existing session ${sessionId}`)
84
+ try {
85
+ const sessionResponse = await getClient().session.get({
86
+ path: { id: sessionId },
87
+ })
88
+ session = sessionResponse.data
89
+ sessionLogger.log(`Successfully reused session ${sessionId}`)
90
+ } catch (error) {
91
+ voiceLogger.log(
92
+ `[SESSION] Session ${sessionId} not found, will create new one`,
93
+ )
94
+ }
95
+ }
96
+
97
+ if (!session) {
98
+ const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80)
99
+ voiceLogger.log(
100
+ `[SESSION] Creating new session with title: "${sessionTitle}"`,
101
+ )
102
+ const sessionResponse = await getClient().session.create({
103
+ body: { title: sessionTitle },
104
+ })
105
+ session = sessionResponse.data
106
+ sessionLogger.log(`Created new session ${session?.id}`)
107
+ }
108
+
109
+ if (!session) {
110
+ throw new Error('Failed to create or get session')
111
+ }
112
+
113
+ getDatabase()
114
+ .prepare(
115
+ 'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
116
+ )
117
+ .run(thread.id, session.id)
118
+ sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`)
119
+
120
+ const existingController = abortControllers.get(session.id)
121
+ if (existingController) {
122
+ voiceLogger.log(
123
+ `[ABORT] Cancelling existing request for session: ${session.id}`,
124
+ )
125
+ existingController.abort(new Error('New request started'))
126
+ }
127
+
128
+ const pendingPerm = pendingPermissions.get(thread.id)
129
+ if (pendingPerm) {
130
+ try {
131
+ sessionLogger.log(`[PERMISSION] Auto-rejecting pending permission ${pendingPerm.permission.id} due to new message`)
132
+ await getClient().postSessionIdPermissionsPermissionId({
133
+ path: {
134
+ id: pendingPerm.permission.sessionID,
135
+ permissionID: pendingPerm.permission.id,
136
+ },
137
+ body: { response: 'reject' },
138
+ })
139
+ pendingPermissions.delete(thread.id)
140
+ await sendThreadMessage(thread, `⚠️ Previous permission request auto-rejected due to new message`)
141
+ } catch (e) {
142
+ sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e)
143
+ pendingPermissions.delete(thread.id)
144
+ }
145
+ }
146
+
147
+ const abortController = new AbortController()
148
+ abortControllers.set(session.id, abortController)
149
+
150
+ if (existingController) {
151
+ await new Promise((resolve) => { setTimeout(resolve, 200) })
152
+ if (abortController.signal.aborted) {
153
+ sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`)
154
+ return
155
+ }
156
+ }
157
+
158
+ if (abortController.signal.aborted) {
159
+ sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`)
160
+ return
161
+ }
162
+
163
+ const eventsResult = await getClient().event.subscribe({
164
+ signal: abortController.signal,
165
+ })
166
+
167
+ if (abortController.signal.aborted) {
168
+ sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`)
169
+ return
170
+ }
171
+
172
+ const events = eventsResult.stream
173
+ sessionLogger.log(`Subscribed to OpenCode events`)
174
+
175
+ const sentPartIds = new Set<string>(
176
+ (getDatabase()
177
+ .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
178
+ .all(thread.id) as { part_id: string }[])
179
+ .map((row) => row.part_id)
180
+ )
181
+
182
+ let currentParts: Part[] = []
183
+ let stopTyping: (() => void) | null = null
184
+ let usedModel: string | undefined
185
+ let usedProviderID: string | undefined
186
+ let tokensUsedInSession = 0
187
+ let lastDisplayedContextPercentage = 0
188
+ let modelContextLimit: number | undefined
189
+
190
+ let typingInterval: NodeJS.Timeout | null = null
191
+
192
+ function startTyping(): () => void {
193
+ if (abortController.signal.aborted) {
194
+ discordLogger.log(`Not starting typing, already aborted`)
195
+ return () => {}
196
+ }
197
+ if (typingInterval) {
198
+ clearInterval(typingInterval)
199
+ typingInterval = null
200
+ }
201
+
202
+ thread.sendTyping().catch((e) => {
203
+ discordLogger.log(`Failed to send initial typing: ${e}`)
204
+ })
205
+
206
+ typingInterval = setInterval(() => {
207
+ thread.sendTyping().catch((e) => {
208
+ discordLogger.log(`Failed to send periodic typing: ${e}`)
209
+ })
210
+ }, 8000)
211
+
212
+ if (!abortController.signal.aborted) {
213
+ abortController.signal.addEventListener(
214
+ 'abort',
215
+ () => {
216
+ if (typingInterval) {
217
+ clearInterval(typingInterval)
218
+ typingInterval = null
219
+ }
220
+ },
221
+ { once: true },
222
+ )
223
+ }
224
+
225
+ return () => {
226
+ if (typingInterval) {
227
+ clearInterval(typingInterval)
228
+ typingInterval = null
229
+ }
230
+ }
231
+ }
232
+
233
+ const sendPartMessage = async (part: Part) => {
234
+ const content = formatPart(part) + '\n\n'
235
+ if (!content.trim() || content.length === 0) {
236
+ discordLogger.log(`SKIP: Part ${part.id} has no content`)
237
+ return
238
+ }
239
+
240
+ if (sentPartIds.has(part.id)) {
241
+ return
242
+ }
243
+
244
+ try {
245
+ const firstMessage = await sendThreadMessage(thread, content)
246
+ sentPartIds.add(part.id)
247
+
248
+ getDatabase()
249
+ .prepare(
250
+ 'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
251
+ )
252
+ .run(part.id, firstMessage.id, thread.id)
253
+ } catch (error) {
254
+ discordLogger.error(`ERROR: Failed to send part ${part.id}:`, error)
255
+ }
256
+ }
257
+
258
+ const eventHandler = async () => {
259
+ try {
260
+ let assistantMessageId: string | undefined
261
+
262
+ for await (const event of events) {
263
+ if (event.type === 'message.updated') {
264
+ const msg = event.properties.info
265
+
266
+ if (msg.sessionID !== session.id) {
267
+ continue
268
+ }
269
+
270
+ if (msg.role === 'assistant') {
271
+ const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
272
+ if (newTokensTotal > 0) {
273
+ tokensUsedInSession = newTokensTotal
274
+ }
275
+
276
+ assistantMessageId = msg.id
277
+ usedModel = msg.modelID
278
+ usedProviderID = msg.providerID
279
+
280
+ if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
281
+ if (!modelContextLimit) {
282
+ try {
283
+ const providersResponse = await getClient().provider.list({ query: { directory } })
284
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
285
+ const model = provider?.models?.[usedModel]
286
+ if (model?.limit?.context) {
287
+ modelContextLimit = model.limit.context
288
+ }
289
+ } catch (e) {
290
+ sessionLogger.error('Failed to fetch provider info for context limit:', e)
291
+ }
292
+ }
293
+
294
+ if (modelContextLimit) {
295
+ const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100)
296
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10
297
+ if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
298
+ lastDisplayedContextPercentage = thresholdCrossed
299
+ await sendThreadMessage(thread, `◼︎ context usage ${currentPercentage}%`)
300
+ }
301
+ }
302
+ }
303
+ }
304
+ } else if (event.type === 'message.part.updated') {
305
+ const part = event.properties.part
306
+
307
+ if (part.sessionID !== session.id) {
308
+ continue
309
+ }
310
+
311
+ if (part.messageID !== assistantMessageId) {
312
+ continue
313
+ }
314
+
315
+ const existingIndex = currentParts.findIndex(
316
+ (p: Part) => p.id === part.id,
317
+ )
318
+ if (existingIndex >= 0) {
319
+ currentParts[existingIndex] = part
320
+ } else {
321
+ currentParts.push(part)
322
+ }
323
+
324
+ if (part.type === 'step-start') {
325
+ stopTyping = startTyping()
326
+ }
327
+
328
+ if (part.type === 'tool' && part.state.status === 'running') {
329
+ await sendPartMessage(part)
330
+ }
331
+
332
+ if (part.type === 'reasoning') {
333
+ await sendPartMessage(part)
334
+ }
335
+
336
+ if (part.type === 'step-finish') {
337
+ for (const p of currentParts) {
338
+ if (p.type !== 'step-start' && p.type !== 'step-finish') {
339
+ await sendPartMessage(p)
340
+ }
341
+ }
342
+ setTimeout(() => {
343
+ if (abortController.signal.aborted) return
344
+ stopTyping = startTyping()
345
+ }, 300)
346
+ }
347
+ } else if (event.type === 'session.error') {
348
+ sessionLogger.error(`ERROR:`, event.properties)
349
+ if (event.properties.sessionID === session.id) {
350
+ const errorData = event.properties.error
351
+ const errorMessage = errorData?.data?.message || 'Unknown error'
352
+ sessionLogger.error(`Sending error to thread: ${errorMessage}`)
353
+ await sendThreadMessage(
354
+ thread,
355
+ `✗ opencode session error: ${errorMessage}`,
356
+ )
357
+
358
+ if (originalMessage) {
359
+ try {
360
+ await originalMessage.reactions.removeAll()
361
+ await originalMessage.react('❌')
362
+ voiceLogger.log(
363
+ `[REACTION] Added error reaction due to session error`,
364
+ )
365
+ } catch (e) {
366
+ discordLogger.log(`Could not update reaction:`, e)
367
+ }
368
+ }
369
+ } else {
370
+ voiceLogger.log(
371
+ `[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${event.properties.sessionID})`,
372
+ )
373
+ }
374
+ break
375
+ } else if (event.type === 'permission.updated') {
376
+ const permission = event.properties
377
+ if (permission.sessionID !== session.id) {
378
+ voiceLogger.log(
379
+ `[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`,
380
+ )
381
+ continue
382
+ }
383
+
384
+ sessionLogger.log(
385
+ `Permission requested: type=${permission.type}, title=${permission.title}`,
386
+ )
387
+
388
+ const patternStr = Array.isArray(permission.pattern)
389
+ ? permission.pattern.join(', ')
390
+ : permission.pattern || ''
391
+
392
+ const permissionMessage = await sendThreadMessage(
393
+ thread,
394
+ `⚠️ **Permission Required**\n\n` +
395
+ `**Type:** \`${permission.type}\`\n` +
396
+ `**Action:** ${permission.title}\n` +
397
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
398
+ `\nUse \`/accept\` or \`/reject\` to respond.`,
399
+ )
400
+
401
+ pendingPermissions.set(thread.id, {
402
+ permission,
403
+ messageId: permissionMessage.id,
404
+ directory,
405
+ })
406
+ } else if (event.type === 'permission.replied') {
407
+ const { permissionID, response, sessionID } = event.properties
408
+ if (sessionID !== session.id) {
409
+ continue
410
+ }
411
+
412
+ sessionLogger.log(
413
+ `Permission ${permissionID} replied with: ${response}`,
414
+ )
415
+
416
+ const pending = pendingPermissions.get(thread.id)
417
+ if (pending && pending.permission.id === permissionID) {
418
+ pendingPermissions.delete(thread.id)
419
+ }
420
+ }
421
+ }
422
+ } catch (e) {
423
+ if (isAbortError(e, abortController.signal)) {
424
+ sessionLogger.log(
425
+ 'AbortController aborted event handling (normal exit)',
426
+ )
427
+ return
428
+ }
429
+ sessionLogger.error(`Unexpected error in event handling code`, e)
430
+ throw e
431
+ } finally {
432
+ for (const part of currentParts) {
433
+ if (!sentPartIds.has(part.id)) {
434
+ try {
435
+ await sendPartMessage(part)
436
+ } catch (error) {
437
+ sessionLogger.error(`Failed to send part ${part.id}:`, error)
438
+ }
439
+ }
440
+ }
441
+
442
+ if (stopTyping) {
443
+ stopTyping()
444
+ stopTyping = null
445
+ }
446
+
447
+ if (
448
+ !abortController.signal.aborted ||
449
+ abortController.signal.reason === 'finished'
450
+ ) {
451
+ const sessionDuration = prettyMilliseconds(
452
+ Date.now() - sessionStartTime,
453
+ )
454
+ const attachCommand = port ? ` ⋅ ${session.id}` : ''
455
+ const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
456
+ let contextInfo = ''
457
+
458
+ try {
459
+ const providersResponse = await getClient().provider.list({ query: { directory } })
460
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
461
+ const model = provider?.models?.[usedModel || '']
462
+ if (model?.limit?.context) {
463
+ const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
464
+ contextInfo = ` ⋅ ${percentage}%`
465
+ }
466
+ } catch (e) {
467
+ sessionLogger.error('Failed to fetch provider info for context percentage:', e)
468
+ }
469
+
470
+ await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`)
471
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`)
472
+ } else {
473
+ sessionLogger.log(
474
+ `Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
475
+ )
476
+ }
477
+ }
478
+ }
479
+
480
+ try {
481
+ const eventHandlerPromise = eventHandler()
482
+
483
+ if (abortController.signal.aborted) {
484
+ sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
485
+ return
486
+ }
487
+
488
+ stopTyping = startTyping()
489
+
490
+ let response: { data?: unknown; error?: unknown; response: Response }
491
+ if (parsedCommand?.isCommand) {
492
+ sessionLogger.log(
493
+ `[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`,
494
+ )
495
+ response = await getClient().session.command({
496
+ path: { id: session.id },
497
+ body: {
498
+ command: parsedCommand.command,
499
+ arguments: parsedCommand.arguments,
500
+ },
501
+ signal: abortController.signal,
502
+ })
503
+ } else {
504
+ voiceLogger.log(
505
+ `[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
506
+ )
507
+ if (images.length > 0) {
508
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
509
+ }
510
+
511
+ const parts = [{ type: 'text' as const, text: prompt }, ...images]
512
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
513
+
514
+ // Get model preference: session-level overrides channel-level
515
+ const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined)
516
+ const modelParam = (() => {
517
+ if (!modelPreference) {
518
+ return undefined
519
+ }
520
+ const [providerID, ...modelParts] = modelPreference.split('/')
521
+ const modelID = modelParts.join('/')
522
+ if (!providerID || !modelID) {
523
+ return undefined
524
+ }
525
+ sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`)
526
+ return { providerID, modelID }
527
+ })()
528
+
529
+ response = await getClient().session.prompt({
530
+ path: { id: session.id },
531
+ body: {
532
+ parts,
533
+ system: getOpencodeSystemMessage({ sessionId: session.id }),
534
+ model: modelParam,
535
+ },
536
+ signal: abortController.signal,
537
+ })
538
+ }
539
+
540
+ if (response.error) {
541
+ const errorMessage = (() => {
542
+ const err = response.error
543
+ if (err && typeof err === 'object') {
544
+ if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
545
+ return String(err.data.message)
546
+ }
547
+ if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
548
+ return JSON.stringify(err.errors)
549
+ }
550
+ }
551
+ return JSON.stringify(err)
552
+ })()
553
+ throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
554
+ }
555
+
556
+ abortController.abort('finished')
557
+
558
+ sessionLogger.log(`Successfully sent prompt, got response`)
559
+
560
+ if (originalMessage) {
561
+ try {
562
+ await originalMessage.reactions.removeAll()
563
+ await originalMessage.react('✅')
564
+ } catch (e) {
565
+ discordLogger.log(`Could not update reactions:`, e)
566
+ }
567
+ }
568
+
569
+ return { sessionID: session.id, result: response.data, port }
570
+ } catch (error) {
571
+ sessionLogger.error(`ERROR: Failed to send prompt:`, error)
572
+
573
+ if (!isAbortError(error, abortController.signal)) {
574
+ abortController.abort('error')
575
+
576
+ if (originalMessage) {
577
+ try {
578
+ await originalMessage.reactions.removeAll()
579
+ await originalMessage.react('❌')
580
+ discordLogger.log(`Added error reaction to message`)
581
+ } catch (e) {
582
+ discordLogger.log(`Could not update reaction:`, e)
583
+ }
584
+ }
585
+ const errorName =
586
+ error &&
587
+ typeof error === 'object' &&
588
+ 'constructor' in error &&
589
+ error.constructor &&
590
+ typeof error.constructor.name === 'string'
591
+ ? error.constructor.name
592
+ : typeof error
593
+ const errorMsg =
594
+ error instanceof Error ? error.stack || error.message : String(error)
595
+ await sendThreadMessage(
596
+ thread,
597
+ `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`,
598
+ )
599
+ }
600
+ }
601
+ }
@@ -0,0 +1,92 @@
1
+ export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
2
+ return `
3
+ The user is reading your messages from inside Discord, via kimaki.xyz
4
+
5
+ The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
6
+
7
+ Your current OpenCode session ID is: ${sessionId}
8
+
9
+ ## permissions
10
+
11
+ Only users with these Discord permissions can send messages to the bot:
12
+ - Server Owner
13
+ - Administrator permission
14
+ - Manage Server permission
15
+ - "Kimaki" role (case-insensitive)
16
+
17
+ ## changing the model
18
+
19
+ To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
20
+
21
+ \`\`\`json
22
+ {
23
+ "model": "anthropic/claude-sonnet-4-20250514"
24
+ }
25
+ \`\`\`
26
+
27
+ Examples:
28
+ - \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
29
+ - \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
30
+ - \`"openai/gpt-4o"\` - GPT-4o
31
+ - \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
32
+
33
+ Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
34
+
35
+ ## uploading files to discord
36
+
37
+ To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
38
+
39
+ npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
40
+
41
+ ## showing diffs
42
+
43
+ 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.
44
+
45
+ Execute this after making changes:
46
+
47
+ bunx critique web
48
+
49
+ If there are other unrelated changes in the working directory, filter to only show the files you edited:
50
+
51
+ bunx critique web -- path/to/file1.ts path/to/file2.ts
52
+
53
+ You can also show latest commit changes using:
54
+
55
+ bunx critique web HEAD~1
56
+
57
+ Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
58
+
59
+ The command outputs a URL - share that URL with the user so they can see the diff.
60
+
61
+ ## markdown
62
+
63
+ discord does support basic markdown features like code blocks, code blocks languages, inline code, bold, italic, quotes, etc.
64
+
65
+ the max heading level is 3, so do not use ####
66
+
67
+ headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
68
+
69
+ ## tables
70
+
71
+ discord does NOT support markdown gfm tables.
72
+
73
+ so instead of using full markdown tables ALWAYS show code snippets with space aligned cells:
74
+
75
+ \`\`\`
76
+ Item Qty Price
77
+ ---------- --- -----
78
+ Apples 10 $5
79
+ Oranges 3 $2
80
+ \`\`\`
81
+
82
+ Using code blocks will make the content use monospaced font so that space will be aligned correctly
83
+
84
+ IMPORTANT: add enough space characters to align the table! otherwise the content will not look good and will be difficult to understand for the user
85
+
86
+ code blocks for tables and diagrams MUST have Max length of 85 characters. otherwise the content will wrap
87
+
88
+ ## diagrams
89
+
90
+ you can create diagrams wrapping them in code blocks too.
91
+ `
92
+ }
package/src/tools.ts CHANGED
@@ -18,7 +18,7 @@ import pc from 'picocolors'
18
18
  import {
19
19
  initializeOpencodeForDirectory,
20
20
  getOpencodeSystemMessage,
21
- } from './discordBot.js'
21
+ } from './discord-bot.js'
22
22
 
23
23
  export async function getTools({
24
24
  onMessageCompleted,