kimaki 0.4.12 → 0.4.14
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/cli.js +81 -12
- package/dist/discordBot.js +225 -114
- package/dist/tools.js +3 -3
- package/package.json +11 -12
- package/src/cli.ts +108 -11
- package/src/discordBot.ts +268 -164
- package/src/opencode-command-send-to-discord.md +12 -0
- package/src/opencode-command-upload-to-discord.md +22 -0
- package/src/tools.ts +3 -3
- package/src/opencode-command.md +0 -4
- package/src/opencode-plugin.ts +0 -75
package/src/discordBot.ts
CHANGED
|
@@ -54,9 +54,34 @@ import { setGlobalDispatcher, Agent } from 'undici'
|
|
|
54
54
|
// disables the automatic 5 minutes abort after no body
|
|
55
55
|
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 }))
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
type ParsedCommand = {
|
|
58
|
+
isCommand: true
|
|
59
|
+
command: string
|
|
60
|
+
arguments: string
|
|
61
|
+
} | {
|
|
62
|
+
isCommand: false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseSlashCommand(text: string): ParsedCommand {
|
|
66
|
+
const trimmed = text.trim()
|
|
67
|
+
if (!trimmed.startsWith('/')) {
|
|
68
|
+
return { isCommand: false }
|
|
69
|
+
}
|
|
70
|
+
const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/)
|
|
71
|
+
if (!match) {
|
|
72
|
+
return { isCommand: false }
|
|
73
|
+
}
|
|
74
|
+
const command = match[1]!
|
|
75
|
+
const args = match[2]?.trim() || ''
|
|
76
|
+
return { isCommand: true, command, arguments: args }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
|
|
80
|
+
return `
|
|
58
81
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
59
82
|
|
|
83
|
+
Your current OpenCode session ID is: ${sessionId}
|
|
84
|
+
|
|
60
85
|
After each message, if you implemented changes, you can show the user a diff via an url running the command, to show the changes in working directory:
|
|
61
86
|
|
|
62
87
|
bunx critique web
|
|
@@ -98,6 +123,7 @@ code blocks for tables and diagrams MUST have Max length of 85 characters. other
|
|
|
98
123
|
|
|
99
124
|
you can create diagrams wrapping them in code blocks too.
|
|
100
125
|
`
|
|
126
|
+
}
|
|
101
127
|
|
|
102
128
|
const discordLogger = createLogger('DISCORD')
|
|
103
129
|
const voiceLogger = createLogger('VOICE')
|
|
@@ -213,11 +239,13 @@ async function setupVoiceHandling({
|
|
|
213
239
|
guildId,
|
|
214
240
|
channelId,
|
|
215
241
|
appId,
|
|
242
|
+
discordClient,
|
|
216
243
|
}: {
|
|
217
244
|
connection: VoiceConnection
|
|
218
245
|
guildId: string
|
|
219
246
|
channelId: string
|
|
220
247
|
appId: string
|
|
248
|
+
discordClient: Client
|
|
221
249
|
}) {
|
|
222
250
|
voiceLogger.log(
|
|
223
251
|
`Setting up voice handling for guild ${guildId}, channel ${channelId}`,
|
|
@@ -330,8 +358,28 @@ async function setupVoiceHandling({
|
|
|
330
358
|
|
|
331
359
|
genAiWorker.sendTextInput(text)
|
|
332
360
|
},
|
|
333
|
-
onError(error) {
|
|
361
|
+
async onError(error) {
|
|
334
362
|
voiceLogger.error('GenAI worker error:', error)
|
|
363
|
+
const textChannelRow = getDatabase()
|
|
364
|
+
.prepare(
|
|
365
|
+
`SELECT cd2.channel_id FROM channel_directories cd1
|
|
366
|
+
JOIN channel_directories cd2 ON cd1.directory = cd2.directory
|
|
367
|
+
WHERE cd1.channel_id = ? AND cd1.channel_type = 'voice' AND cd2.channel_type = 'text'`,
|
|
368
|
+
)
|
|
369
|
+
.get(channelId) as { channel_id: string } | undefined
|
|
370
|
+
|
|
371
|
+
if (textChannelRow) {
|
|
372
|
+
try {
|
|
373
|
+
const textChannel = await discordClient.channels.fetch(
|
|
374
|
+
textChannelRow.channel_id,
|
|
375
|
+
)
|
|
376
|
+
if (textChannel?.isTextBased() && 'send' in textChannel) {
|
|
377
|
+
await textChannel.send(`⚠️ Voice session error: ${error}`)
|
|
378
|
+
}
|
|
379
|
+
} catch (e) {
|
|
380
|
+
voiceLogger.error('Failed to send error to text channel:', e)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
335
383
|
},
|
|
336
384
|
})
|
|
337
385
|
|
|
@@ -1212,30 +1260,31 @@ function getToolOutputToDisplay(part: Part): string {
|
|
|
1212
1260
|
return part.state.error || 'Unknown error'
|
|
1213
1261
|
}
|
|
1214
1262
|
|
|
1215
|
-
if (part.tool === 'todowrite') {
|
|
1216
|
-
const todos =
|
|
1217
|
-
(part.state.input?.todos as {
|
|
1218
|
-
content: string
|
|
1219
|
-
status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
|
1220
|
-
}[]) || []
|
|
1221
|
-
return todos
|
|
1222
|
-
.map((todo) => {
|
|
1223
|
-
let statusIcon = '▢'
|
|
1224
|
-
if (todo.status === 'in_progress') {
|
|
1225
|
-
statusIcon = '●'
|
|
1226
|
-
}
|
|
1227
|
-
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
1228
|
-
statusIcon = '■'
|
|
1229
|
-
}
|
|
1230
|
-
return `\`${statusIcon}\` ${todo.content}`
|
|
1231
|
-
})
|
|
1232
|
-
.filter(Boolean)
|
|
1233
|
-
.join('\n')
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
1263
|
return ''
|
|
1237
1264
|
}
|
|
1238
1265
|
|
|
1266
|
+
function formatTodoList(part: Part): string {
|
|
1267
|
+
if (part.type !== 'tool' || part.tool !== 'todowrite') return ''
|
|
1268
|
+
const todos =
|
|
1269
|
+
(part.state.input?.todos as {
|
|
1270
|
+
content: string
|
|
1271
|
+
status: 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
|
1272
|
+
}[]) || []
|
|
1273
|
+
if (todos.length === 0) return ''
|
|
1274
|
+
return todos
|
|
1275
|
+
.map((todo, i) => {
|
|
1276
|
+
const num = `${i + 1}.`
|
|
1277
|
+
if (todo.status === 'in_progress') {
|
|
1278
|
+
return `${num} **${todo.content}**`
|
|
1279
|
+
}
|
|
1280
|
+
if (todo.status === 'completed' || todo.status === 'cancelled') {
|
|
1281
|
+
return `${num} ~~${todo.content}~~`
|
|
1282
|
+
}
|
|
1283
|
+
return `${num} ${todo.content}`
|
|
1284
|
+
})
|
|
1285
|
+
.join('\n')
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1239
1288
|
function formatPart(part: Part): string {
|
|
1240
1289
|
if (part.type === 'text') {
|
|
1241
1290
|
return part.text || ''
|
|
@@ -1263,6 +1312,10 @@ function formatPart(part: Part): string {
|
|
|
1263
1312
|
}
|
|
1264
1313
|
|
|
1265
1314
|
if (part.type === 'tool') {
|
|
1315
|
+
if (part.tool === 'todowrite') {
|
|
1316
|
+
return formatTodoList(part)
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1266
1319
|
if (part.state.status !== 'completed' && part.state.status !== 'error') {
|
|
1267
1320
|
return ''
|
|
1268
1321
|
}
|
|
@@ -1270,9 +1323,20 @@ function formatPart(part: Part): string {
|
|
|
1270
1323
|
const summaryText = getToolSummaryText(part)
|
|
1271
1324
|
const outputToDisplay = getToolOutputToDisplay(part)
|
|
1272
1325
|
|
|
1273
|
-
let toolTitle =
|
|
1274
|
-
if (
|
|
1275
|
-
toolTitle =
|
|
1326
|
+
let toolTitle = ''
|
|
1327
|
+
if (part.state.status === 'error') {
|
|
1328
|
+
toolTitle = 'error'
|
|
1329
|
+
} else if (part.tool === 'bash') {
|
|
1330
|
+
const command = (part.state.input?.command as string) || ''
|
|
1331
|
+
const isSingleLine = !command.includes('\n')
|
|
1332
|
+
const hasBackticks = command.includes('`')
|
|
1333
|
+
if (isSingleLine && command.length <= 120 && !hasBackticks) {
|
|
1334
|
+
toolTitle = `\`${command}\``
|
|
1335
|
+
} else {
|
|
1336
|
+
toolTitle = part.state.title ? `*${part.state.title}*` : ''
|
|
1337
|
+
}
|
|
1338
|
+
} else if (part.state.title) {
|
|
1339
|
+
toolTitle = `*${part.state.title}*`
|
|
1276
1340
|
}
|
|
1277
1341
|
|
|
1278
1342
|
const icon = part.state.status === 'completed' ? '◼︎' : part.state.status === 'error' ? '⨯' : ''
|
|
@@ -1311,12 +1375,14 @@ async function handleOpencodeSession({
|
|
|
1311
1375
|
projectDirectory,
|
|
1312
1376
|
originalMessage,
|
|
1313
1377
|
images = [],
|
|
1378
|
+
parsedCommand,
|
|
1314
1379
|
}: {
|
|
1315
1380
|
prompt: string
|
|
1316
1381
|
thread: ThreadChannel
|
|
1317
1382
|
projectDirectory?: string
|
|
1318
1383
|
originalMessage?: Message
|
|
1319
1384
|
images?: FilePartInput[]
|
|
1385
|
+
parsedCommand?: ParsedCommand
|
|
1320
1386
|
}): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
|
|
1321
1387
|
voiceLogger.log(
|
|
1322
1388
|
`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
|
|
@@ -1439,6 +1505,8 @@ async function handleOpencodeSession({
|
|
|
1439
1505
|
let currentParts: Part[] = []
|
|
1440
1506
|
let stopTyping: (() => void) | null = null
|
|
1441
1507
|
let usedModel: string | undefined
|
|
1508
|
+
let usedProviderID: string | undefined
|
|
1509
|
+
let inputTokens = 0
|
|
1442
1510
|
|
|
1443
1511
|
const sendPartMessage = async (part: Part) => {
|
|
1444
1512
|
const content = formatPart(part) + '\n\n'
|
|
@@ -1449,22 +1517,12 @@ async function handleOpencodeSession({
|
|
|
1449
1517
|
|
|
1450
1518
|
// Skip if already sent
|
|
1451
1519
|
if (partIdToMessage.has(part.id)) {
|
|
1452
|
-
voiceLogger.log(
|
|
1453
|
-
`[SEND SKIP] Part ${part.id} already sent as message ${partIdToMessage.get(part.id)?.id}`,
|
|
1454
|
-
)
|
|
1455
1520
|
return
|
|
1456
1521
|
}
|
|
1457
1522
|
|
|
1458
1523
|
try {
|
|
1459
|
-
voiceLogger.log(
|
|
1460
|
-
`[SEND] Sending part ${part.id} (type: ${part.type}) to Discord, content length: ${content.length}`,
|
|
1461
|
-
)
|
|
1462
|
-
|
|
1463
1524
|
const firstMessage = await sendThreadMessage(thread, content)
|
|
1464
1525
|
partIdToMessage.set(part.id, firstMessage)
|
|
1465
|
-
voiceLogger.log(
|
|
1466
|
-
`[SEND SUCCESS] Part ${part.id} sent as message ${firstMessage.id}`,
|
|
1467
|
-
)
|
|
1468
1526
|
|
|
1469
1527
|
// Store part-message mapping in database
|
|
1470
1528
|
getDatabase()
|
|
@@ -1487,13 +1545,10 @@ async function handleOpencodeSession({
|
|
|
1487
1545
|
discordLogger.log(`Not starting typing, already aborted`)
|
|
1488
1546
|
return () => {}
|
|
1489
1547
|
}
|
|
1490
|
-
discordLogger.log(`Starting typing for thread ${thread.id}`)
|
|
1491
|
-
|
|
1492
1548
|
// Clear any previous typing interval
|
|
1493
1549
|
if (typingInterval) {
|
|
1494
1550
|
clearInterval(typingInterval)
|
|
1495
1551
|
typingInterval = null
|
|
1496
|
-
discordLogger.log(`Cleared previous typing interval`)
|
|
1497
1552
|
}
|
|
1498
1553
|
|
|
1499
1554
|
// Send initial typing
|
|
@@ -1529,7 +1584,6 @@ async function handleOpencodeSession({
|
|
|
1529
1584
|
if (typingInterval) {
|
|
1530
1585
|
clearInterval(typingInterval)
|
|
1531
1586
|
typingInterval = null
|
|
1532
|
-
discordLogger.log(`Stopped typing for thread ${thread.id}`)
|
|
1533
1587
|
}
|
|
1534
1588
|
}
|
|
1535
1589
|
}
|
|
@@ -1538,45 +1592,31 @@ async function handleOpencodeSession({
|
|
|
1538
1592
|
let assistantMessageId: string | undefined
|
|
1539
1593
|
|
|
1540
1594
|
for await (const event of events) {
|
|
1541
|
-
sessionLogger.log(`Received: ${event.type}`)
|
|
1542
1595
|
if (event.type === 'message.updated') {
|
|
1543
1596
|
const msg = event.properties.info
|
|
1544
1597
|
|
|
1545
1598
|
if (msg.sessionID !== session.id) {
|
|
1546
|
-
voiceLogger.log(
|
|
1547
|
-
`[EVENT IGNORED] Message from different session (expected: ${session.id}, got: ${msg.sessionID})`,
|
|
1548
|
-
)
|
|
1549
1599
|
continue
|
|
1550
1600
|
}
|
|
1551
1601
|
|
|
1552
1602
|
// Track assistant message ID
|
|
1553
1603
|
if (msg.role === 'assistant') {
|
|
1554
1604
|
assistantMessageId = msg.id
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
1605
|
usedModel = msg.modelID
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
} else {
|
|
1563
|
-
sessionLogger.log(`Message role: ${msg.role}`)
|
|
1606
|
+
usedProviderID = msg.providerID
|
|
1607
|
+
if (msg.tokens.input > 0) {
|
|
1608
|
+
inputTokens = msg.tokens.input
|
|
1609
|
+
}
|
|
1564
1610
|
}
|
|
1565
1611
|
} else if (event.type === 'message.part.updated') {
|
|
1566
1612
|
const part = event.properties.part
|
|
1567
1613
|
|
|
1568
1614
|
if (part.sessionID !== session.id) {
|
|
1569
|
-
voiceLogger.log(
|
|
1570
|
-
`[EVENT IGNORED] Part from different session (expected: ${session.id}, got: ${part.sessionID})`,
|
|
1571
|
-
)
|
|
1572
1615
|
continue
|
|
1573
1616
|
}
|
|
1574
1617
|
|
|
1575
1618
|
// Only process parts from assistant messages
|
|
1576
1619
|
if (part.messageID !== assistantMessageId) {
|
|
1577
|
-
voiceLogger.log(
|
|
1578
|
-
`[EVENT IGNORED] Part from non-assistant message (expected: ${assistantMessageId}, got: ${part.messageID})`,
|
|
1579
|
-
)
|
|
1580
1620
|
continue
|
|
1581
1621
|
}
|
|
1582
1622
|
|
|
@@ -1589,9 +1629,7 @@ async function handleOpencodeSession({
|
|
|
1589
1629
|
currentParts.push(part)
|
|
1590
1630
|
}
|
|
1591
1631
|
|
|
1592
|
-
|
|
1593
|
-
`[PART] Update: id=${part.id}, type=${part.type}, text=${'text' in part && typeof part.text === 'string' ? part.text.slice(0, 50) : ''}`,
|
|
1594
|
-
)
|
|
1632
|
+
|
|
1595
1633
|
|
|
1596
1634
|
// Start typing on step-start
|
|
1597
1635
|
if (part.type === 'step-start') {
|
|
@@ -1600,10 +1638,12 @@ async function handleOpencodeSession({
|
|
|
1600
1638
|
|
|
1601
1639
|
// Check if this is a step-finish part
|
|
1602
1640
|
if (part.type === 'step-finish') {
|
|
1641
|
+
// Track tokens from step-finish part
|
|
1642
|
+
if (part.tokens?.input && part.tokens.input > 0) {
|
|
1643
|
+
inputTokens = part.tokens.input
|
|
1644
|
+
voiceLogger.log(`[STEP-FINISH] Captured tokens: ${inputTokens}`)
|
|
1645
|
+
}
|
|
1603
1646
|
// Send all parts accumulated so far to Discord
|
|
1604
|
-
voiceLogger.log(
|
|
1605
|
-
`[STEP-FINISH] Sending ${currentParts.length} parts to Discord`,
|
|
1606
|
-
)
|
|
1607
1647
|
for (const p of currentParts) {
|
|
1608
1648
|
// Skip step-start and step-finish parts as they have no visual content
|
|
1609
1649
|
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
@@ -1690,10 +1730,6 @@ async function handleOpencodeSession({
|
|
|
1690
1730
|
if (pending && pending.permission.id === permissionID) {
|
|
1691
1731
|
pendingPermissions.delete(thread.id)
|
|
1692
1732
|
}
|
|
1693
|
-
} else if (event.type === 'file.edited') {
|
|
1694
|
-
sessionLogger.log(`File edited event received`)
|
|
1695
|
-
} else {
|
|
1696
|
-
sessionLogger.log(`Unhandled event type: ${event.type}`)
|
|
1697
1733
|
}
|
|
1698
1734
|
}
|
|
1699
1735
|
} catch (e) {
|
|
@@ -1707,37 +1743,20 @@ async function handleOpencodeSession({
|
|
|
1707
1743
|
throw e
|
|
1708
1744
|
} finally {
|
|
1709
1745
|
// Send any remaining parts that weren't sent
|
|
1710
|
-
voiceLogger.log(
|
|
1711
|
-
`[CLEANUP] Checking ${currentParts.length} parts for unsent messages`,
|
|
1712
|
-
)
|
|
1713
|
-
let unsentCount = 0
|
|
1714
1746
|
for (const part of currentParts) {
|
|
1715
1747
|
if (!partIdToMessage.has(part.id)) {
|
|
1716
|
-
unsentCount++
|
|
1717
|
-
voiceLogger.log(
|
|
1718
|
-
`[CLEANUP] Sending unsent part: id=${part.id}, type=${part.type}`,
|
|
1719
|
-
)
|
|
1720
1748
|
try {
|
|
1721
1749
|
await sendPartMessage(part)
|
|
1722
1750
|
} catch (error) {
|
|
1723
|
-
sessionLogger.
|
|
1724
|
-
`Failed to send part ${part.id} during cleanup:`,
|
|
1725
|
-
error,
|
|
1726
|
-
)
|
|
1751
|
+
sessionLogger.error(`Failed to send part ${part.id}:`, error)
|
|
1727
1752
|
}
|
|
1728
1753
|
}
|
|
1729
1754
|
}
|
|
1730
|
-
if (unsentCount === 0) {
|
|
1731
|
-
sessionLogger.log(`All parts were already sent`)
|
|
1732
|
-
} else {
|
|
1733
|
-
sessionLogger.log(`Sent ${unsentCount} previously unsent parts`)
|
|
1734
|
-
}
|
|
1735
1755
|
|
|
1736
1756
|
// Stop typing when session ends
|
|
1737
1757
|
if (stopTyping) {
|
|
1738
1758
|
stopTyping()
|
|
1739
1759
|
stopTyping = null
|
|
1740
|
-
sessionLogger.log(`Stopped typing for session`)
|
|
1741
1760
|
}
|
|
1742
1761
|
|
|
1743
1762
|
// Only send duration message if request was not aborted or was aborted with 'finished' reason
|
|
@@ -1750,8 +1769,22 @@ async function handleOpencodeSession({
|
|
|
1750
1769
|
)
|
|
1751
1770
|
const attachCommand = port ? ` ⋅ ${session.id}` : ''
|
|
1752
1771
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
|
|
1753
|
-
|
|
1754
|
-
|
|
1772
|
+
let contextInfo = ''
|
|
1773
|
+
if (inputTokens > 0 && usedProviderID && usedModel) {
|
|
1774
|
+
try {
|
|
1775
|
+
const providersResponse = await getClient().provider.list({ query: { directory } })
|
|
1776
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
1777
|
+
const model = provider?.models?.[usedModel]
|
|
1778
|
+
if (model?.limit?.context) {
|
|
1779
|
+
const percentage = Math.round((inputTokens / model.limit.context) * 100)
|
|
1780
|
+
contextInfo = ` ⋅ ${percentage}%`
|
|
1781
|
+
}
|
|
1782
|
+
} catch (e) {
|
|
1783
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', e)
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`)
|
|
1787
|
+
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${inputTokens}`)
|
|
1755
1788
|
} else {
|
|
1756
1789
|
sessionLogger.log(
|
|
1757
1790
|
`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
|
|
@@ -1761,27 +1794,42 @@ async function handleOpencodeSession({
|
|
|
1761
1794
|
}
|
|
1762
1795
|
|
|
1763
1796
|
try {
|
|
1764
|
-
voiceLogger.log(
|
|
1765
|
-
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
1766
|
-
)
|
|
1767
|
-
if (images.length > 0) {
|
|
1768
|
-
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
1797
|
// Start the event handler
|
|
1772
1798
|
const eventHandlerPromise = eventHandler()
|
|
1773
1799
|
|
|
1774
|
-
|
|
1775
|
-
|
|
1800
|
+
let response: { data?: unknown }
|
|
1801
|
+
if (parsedCommand?.isCommand) {
|
|
1802
|
+
sessionLogger.log(
|
|
1803
|
+
`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`,
|
|
1804
|
+
)
|
|
1805
|
+
response = await getClient().session.command({
|
|
1806
|
+
path: { id: session.id },
|
|
1807
|
+
body: {
|
|
1808
|
+
command: parsedCommand.command,
|
|
1809
|
+
arguments: parsedCommand.arguments,
|
|
1810
|
+
},
|
|
1811
|
+
signal: abortController.signal,
|
|
1812
|
+
})
|
|
1813
|
+
} else {
|
|
1814
|
+
voiceLogger.log(
|
|
1815
|
+
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
1816
|
+
)
|
|
1817
|
+
if (images.length > 0) {
|
|
1818
|
+
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
const parts = [{ type: 'text' as const, text: prompt }, ...images]
|
|
1822
|
+
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
|
|
1776
1823
|
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1824
|
+
response = await getClient().session.prompt({
|
|
1825
|
+
path: { id: session.id },
|
|
1826
|
+
body: {
|
|
1827
|
+
parts,
|
|
1828
|
+
system: getOpencodeSystemMessage({ sessionId: session.id }),
|
|
1829
|
+
},
|
|
1830
|
+
signal: abortController.signal,
|
|
1831
|
+
})
|
|
1832
|
+
}
|
|
1785
1833
|
abortController.abort('finished')
|
|
1786
1834
|
|
|
1787
1835
|
sessionLogger.log(`Successfully sent prompt, got response`)
|
|
@@ -1938,9 +1986,6 @@ export async function startDiscordBot({
|
|
|
1938
1986
|
discordClient.on(Events.MessageCreate, async (message: Message) => {
|
|
1939
1987
|
try {
|
|
1940
1988
|
if (message.author?.bot) {
|
|
1941
|
-
voiceLogger.log(
|
|
1942
|
-
`[IGNORED] Bot message from ${message.author.tag} in channel ${message.channelId}`,
|
|
1943
|
-
)
|
|
1944
1989
|
return
|
|
1945
1990
|
}
|
|
1946
1991
|
if (message.partial) {
|
|
@@ -1964,15 +2009,8 @@ export async function startDiscordBot({
|
|
|
1964
2009
|
)
|
|
1965
2010
|
|
|
1966
2011
|
if (!isOwner && !isAdmin) {
|
|
1967
|
-
voiceLogger.log(
|
|
1968
|
-
`[IGNORED] Non-authoritative user ${message.author.tag} (ID: ${message.author.id}) - not owner or admin`,
|
|
1969
|
-
)
|
|
1970
2012
|
return
|
|
1971
2013
|
}
|
|
1972
|
-
|
|
1973
|
-
voiceLogger.log(
|
|
1974
|
-
`[AUTHORIZED] Message from ${message.author.tag} (Owner: ${isOwner}, Admin: ${isAdmin})`,
|
|
1975
|
-
)
|
|
1976
2014
|
}
|
|
1977
2015
|
|
|
1978
2016
|
const channel = message.channel
|
|
@@ -2046,12 +2084,14 @@ export async function startDiscordBot({
|
|
|
2046
2084
|
}
|
|
2047
2085
|
|
|
2048
2086
|
const images = getImageAttachments(message)
|
|
2087
|
+
const parsedCommand = parseSlashCommand(messageContent)
|
|
2049
2088
|
await handleOpencodeSession({
|
|
2050
2089
|
prompt: messageContent,
|
|
2051
2090
|
thread,
|
|
2052
2091
|
projectDirectory,
|
|
2053
2092
|
originalMessage: message,
|
|
2054
2093
|
images,
|
|
2094
|
+
parsedCommand,
|
|
2055
2095
|
})
|
|
2056
2096
|
return
|
|
2057
2097
|
}
|
|
@@ -2141,12 +2181,14 @@ export async function startDiscordBot({
|
|
|
2141
2181
|
}
|
|
2142
2182
|
|
|
2143
2183
|
const images = getImageAttachments(message)
|
|
2184
|
+
const parsedCommand = parseSlashCommand(messageContent)
|
|
2144
2185
|
await handleOpencodeSession({
|
|
2145
2186
|
prompt: messageContent,
|
|
2146
2187
|
thread,
|
|
2147
2188
|
projectDirectory,
|
|
2148
2189
|
originalMessage: message,
|
|
2149
2190
|
images,
|
|
2191
|
+
parsedCommand,
|
|
2150
2192
|
})
|
|
2151
2193
|
} else {
|
|
2152
2194
|
discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
@@ -2482,10 +2524,12 @@ export async function startDiscordBot({
|
|
|
2482
2524
|
)
|
|
2483
2525
|
|
|
2484
2526
|
// Start the OpenCode session
|
|
2527
|
+
const parsedCommand = parseSlashCommand(fullPrompt)
|
|
2485
2528
|
await handleOpencodeSession({
|
|
2486
2529
|
prompt: fullPrompt,
|
|
2487
2530
|
thread,
|
|
2488
2531
|
projectDirectory,
|
|
2532
|
+
parsedCommand,
|
|
2489
2533
|
})
|
|
2490
2534
|
} catch (error) {
|
|
2491
2535
|
voiceLogger.error('[SESSION] Error:', error)
|
|
@@ -2600,68 +2644,56 @@ export async function startDiscordBot({
|
|
|
2600
2644
|
`📂 **Resumed session:** ${sessionTitle}\n📅 **Created:** ${new Date(sessionResponse.data.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
|
|
2601
2645
|
)
|
|
2602
2646
|
|
|
2603
|
-
//
|
|
2604
|
-
|
|
2647
|
+
// Collect all assistant parts first, then only render the last 30
|
|
2648
|
+
const allAssistantParts: { id: string; content: string }[] = []
|
|
2605
2649
|
for (const message of messages) {
|
|
2606
|
-
if (message.info.role === '
|
|
2607
|
-
// Render user messages
|
|
2608
|
-
const userParts = message.parts.filter(
|
|
2609
|
-
(p) => p.type === 'text' && !p.synthetic,
|
|
2610
|
-
)
|
|
2611
|
-
const userTexts = userParts
|
|
2612
|
-
.map((p) => {
|
|
2613
|
-
if (p.type === 'text') {
|
|
2614
|
-
return p.text
|
|
2615
|
-
}
|
|
2616
|
-
return ''
|
|
2617
|
-
})
|
|
2618
|
-
.filter((t) => t.trim())
|
|
2619
|
-
|
|
2620
|
-
const userText = userTexts.join('\n\n')
|
|
2621
|
-
if (userText) {
|
|
2622
|
-
// Escape backticks in user messages to prevent formatting issues
|
|
2623
|
-
const escapedText = escapeDiscordFormatting(userText)
|
|
2624
|
-
await sendThreadMessage(thread, `**User:**\n${escapedText}`)
|
|
2625
|
-
}
|
|
2626
|
-
} else if (message.info.role === 'assistant') {
|
|
2627
|
-
// Render assistant parts
|
|
2628
|
-
const partsToRender: { id: string; content: string }[] = []
|
|
2629
|
-
|
|
2650
|
+
if (message.info.role === 'assistant') {
|
|
2630
2651
|
for (const part of message.parts) {
|
|
2631
2652
|
const content = formatPart(part)
|
|
2632
2653
|
if (content.trim()) {
|
|
2633
|
-
|
|
2654
|
+
allAssistantParts.push({ id: part.id, content })
|
|
2634
2655
|
}
|
|
2635
2656
|
}
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2636
2659
|
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
.map((p) => p.content)
|
|
2640
|
-
.join('\n\n')
|
|
2660
|
+
const partsToRender = allAssistantParts.slice(-30)
|
|
2661
|
+
const skippedCount = allAssistantParts.length - partsToRender.length
|
|
2641
2662
|
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2663
|
+
if (skippedCount > 0) {
|
|
2664
|
+
await sendThreadMessage(
|
|
2665
|
+
thread,
|
|
2666
|
+
`*Skipped ${skippedCount} older assistant parts...*`,
|
|
2667
|
+
)
|
|
2668
|
+
}
|
|
2646
2669
|
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2670
|
+
if (partsToRender.length > 0) {
|
|
2671
|
+
const combinedContent = partsToRender
|
|
2672
|
+
.map((p) => p.content)
|
|
2673
|
+
.join('\n\n')
|
|
2650
2674
|
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
}
|
|
2656
|
-
},
|
|
2657
|
-
)
|
|
2675
|
+
const discordMessage = await sendThreadMessage(
|
|
2676
|
+
thread,
|
|
2677
|
+
combinedContent,
|
|
2678
|
+
)
|
|
2658
2679
|
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2680
|
+
const stmt = getDatabase().prepare(
|
|
2681
|
+
'INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)',
|
|
2682
|
+
)
|
|
2683
|
+
|
|
2684
|
+
const transaction = getDatabase().transaction(
|
|
2685
|
+
(parts: { id: string }[]) => {
|
|
2686
|
+
for (const part of parts) {
|
|
2687
|
+
stmt.run(part.id, discordMessage.id, thread.id)
|
|
2688
|
+
}
|
|
2689
|
+
},
|
|
2690
|
+
)
|
|
2691
|
+
|
|
2692
|
+
transaction(partsToRender)
|
|
2663
2693
|
}
|
|
2664
2694
|
|
|
2695
|
+
const messageCount = messages.length
|
|
2696
|
+
|
|
2665
2697
|
await sendThreadMessage(
|
|
2666
2698
|
thread,
|
|
2667
2699
|
`✅ **Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
|
|
@@ -2865,6 +2897,77 @@ export async function startDiscordBot({
|
|
|
2865
2897
|
ephemeral: true,
|
|
2866
2898
|
})
|
|
2867
2899
|
}
|
|
2900
|
+
} else if (command.commandName === 'abort') {
|
|
2901
|
+
const channel = command.channel
|
|
2902
|
+
|
|
2903
|
+
if (!channel) {
|
|
2904
|
+
await command.reply({
|
|
2905
|
+
content: 'This command can only be used in a channel',
|
|
2906
|
+
ephemeral: true,
|
|
2907
|
+
})
|
|
2908
|
+
return
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
const isThread = [
|
|
2912
|
+
ChannelType.PublicThread,
|
|
2913
|
+
ChannelType.PrivateThread,
|
|
2914
|
+
ChannelType.AnnouncementThread,
|
|
2915
|
+
].includes(channel.type)
|
|
2916
|
+
|
|
2917
|
+
if (!isThread) {
|
|
2918
|
+
await command.reply({
|
|
2919
|
+
content: 'This command can only be used in a thread with an active session',
|
|
2920
|
+
ephemeral: true,
|
|
2921
|
+
})
|
|
2922
|
+
return
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
const textChannel = resolveTextChannel(channel as ThreadChannel)
|
|
2926
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
2927
|
+
|
|
2928
|
+
if (!directory) {
|
|
2929
|
+
await command.reply({
|
|
2930
|
+
content: 'Could not determine project directory for this channel',
|
|
2931
|
+
ephemeral: true,
|
|
2932
|
+
})
|
|
2933
|
+
return
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
const row = getDatabase()
|
|
2937
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
2938
|
+
.get(channel.id) as { session_id: string } | undefined
|
|
2939
|
+
|
|
2940
|
+
if (!row?.session_id) {
|
|
2941
|
+
await command.reply({
|
|
2942
|
+
content: 'No active session in this thread',
|
|
2943
|
+
ephemeral: true,
|
|
2944
|
+
})
|
|
2945
|
+
return
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
const sessionId = row.session_id
|
|
2949
|
+
|
|
2950
|
+
try {
|
|
2951
|
+
const existingController = abortControllers.get(sessionId)
|
|
2952
|
+
if (existingController) {
|
|
2953
|
+
existingController.abort(new Error('User requested abort'))
|
|
2954
|
+
abortControllers.delete(sessionId)
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
2958
|
+
await getClient().session.abort({
|
|
2959
|
+
path: { id: sessionId },
|
|
2960
|
+
})
|
|
2961
|
+
|
|
2962
|
+
await command.reply(`🛑 Request **aborted**`)
|
|
2963
|
+
sessionLogger.log(`Session ${sessionId} aborted by user`)
|
|
2964
|
+
} catch (error) {
|
|
2965
|
+
voiceLogger.error('[ABORT] Error:', error)
|
|
2966
|
+
await command.reply({
|
|
2967
|
+
content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2968
|
+
ephemeral: true,
|
|
2969
|
+
})
|
|
2970
|
+
}
|
|
2868
2971
|
}
|
|
2869
2972
|
}
|
|
2870
2973
|
} catch (error) {
|
|
@@ -3101,6 +3204,7 @@ export async function startDiscordBot({
|
|
|
3101
3204
|
guildId: newState.guild.id,
|
|
3102
3205
|
channelId: voiceChannel.id,
|
|
3103
3206
|
appId: currentAppId!,
|
|
3207
|
+
discordClient,
|
|
3104
3208
|
})
|
|
3105
3209
|
|
|
3106
3210
|
// Handle connection state changes
|