kimaki 0.4.13 → 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 +144 -55
- package/dist/tools.js +3 -3
- package/package.json +11 -12
- package/src/cli.ts +108 -11
- package/src/discordBot.ts +167 -90
- 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/cli.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
REST,
|
|
34
34
|
Routes,
|
|
35
35
|
SlashCommandBuilder,
|
|
36
|
+
AttachmentBuilder,
|
|
36
37
|
} from 'discord.js'
|
|
37
38
|
import path from 'node:path'
|
|
38
39
|
import fs from 'node:fs'
|
|
@@ -134,12 +135,16 @@ async function registerCommands(token: string, appId: string) {
|
|
|
134
135
|
.toJSON(),
|
|
135
136
|
new SlashCommandBuilder()
|
|
136
137
|
.setName('accept-always')
|
|
137
|
-
.setDescription('Accept and auto-approve future requests matching this pattern
|
|
138
|
+
.setDescription('Accept and auto-approve future requests matching this pattern')
|
|
138
139
|
.toJSON(),
|
|
139
140
|
new SlashCommandBuilder()
|
|
140
141
|
.setName('reject')
|
|
141
142
|
.setDescription('Reject a pending permission request')
|
|
142
143
|
.toJSON(),
|
|
144
|
+
new SlashCommandBuilder()
|
|
145
|
+
.setName('abort')
|
|
146
|
+
.setDescription('Abort the current OpenCode request in this thread')
|
|
147
|
+
.toJSON(),
|
|
143
148
|
]
|
|
144
149
|
|
|
145
150
|
const rest = new REST().setToken(token)
|
|
@@ -832,28 +837,120 @@ cli
|
|
|
832
837
|
})
|
|
833
838
|
|
|
834
839
|
cli
|
|
835
|
-
.command('
|
|
840
|
+
.command('upload-to-discord [...files]', 'Upload files to a Discord thread for a session')
|
|
841
|
+
.option('-s, --session <sessionId>', 'OpenCode session ID')
|
|
842
|
+
.action(async (files: string[], options: { session?: string }) => {
|
|
843
|
+
try {
|
|
844
|
+
const { session: sessionId } = options
|
|
845
|
+
|
|
846
|
+
if (!sessionId) {
|
|
847
|
+
cliLogger.error('Session ID is required. Use --session <sessionId>')
|
|
848
|
+
process.exit(EXIT_NO_RESTART)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (!files || files.length === 0) {
|
|
852
|
+
cliLogger.error('At least one file path is required')
|
|
853
|
+
process.exit(EXIT_NO_RESTART)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const resolvedFiles = files.map((f) => path.resolve(f))
|
|
857
|
+
for (const file of resolvedFiles) {
|
|
858
|
+
if (!fs.existsSync(file)) {
|
|
859
|
+
cliLogger.error(`File not found: ${file}`)
|
|
860
|
+
process.exit(EXIT_NO_RESTART)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const db = getDatabase()
|
|
865
|
+
|
|
866
|
+
const threadRow = db
|
|
867
|
+
.prepare('SELECT thread_id FROM thread_sessions WHERE session_id = ?')
|
|
868
|
+
.get(sessionId) as { thread_id: string } | undefined
|
|
869
|
+
|
|
870
|
+
if (!threadRow) {
|
|
871
|
+
cliLogger.error(`No Discord thread found for session: ${sessionId}`)
|
|
872
|
+
cliLogger.error('Make sure the session has been sent to Discord first using /send-to-kimaki-discord')
|
|
873
|
+
process.exit(EXIT_NO_RESTART)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const botRow = db
|
|
877
|
+
.prepare(
|
|
878
|
+
'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
|
|
879
|
+
)
|
|
880
|
+
.get() as { app_id: string; token: string } | undefined
|
|
881
|
+
|
|
882
|
+
if (!botRow) {
|
|
883
|
+
cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.')
|
|
884
|
+
process.exit(EXIT_NO_RESTART)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const s = spinner()
|
|
888
|
+
s.start(`Uploading ${resolvedFiles.length} file(s)...`)
|
|
889
|
+
|
|
890
|
+
for (const file of resolvedFiles) {
|
|
891
|
+
const buffer = fs.readFileSync(file)
|
|
892
|
+
|
|
893
|
+
const formData = new FormData()
|
|
894
|
+
formData.append('payload_json', JSON.stringify({
|
|
895
|
+
attachments: [{ id: 0, filename: path.basename(file) }]
|
|
896
|
+
}))
|
|
897
|
+
formData.append('files[0]', new Blob([buffer]), path.basename(file))
|
|
898
|
+
|
|
899
|
+
const response = await fetch(
|
|
900
|
+
`https://discord.com/api/v10/channels/${threadRow.thread_id}/messages`,
|
|
901
|
+
{
|
|
902
|
+
method: 'POST',
|
|
903
|
+
headers: {
|
|
904
|
+
'Authorization': `Bot ${botRow.token}`,
|
|
905
|
+
},
|
|
906
|
+
body: formData,
|
|
907
|
+
}
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
if (!response.ok) {
|
|
911
|
+
const error = await response.text()
|
|
912
|
+
throw new Error(`Discord API error: ${response.status} - ${error}`)
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
s.stop(`Uploaded ${resolvedFiles.length} file(s)!`)
|
|
917
|
+
|
|
918
|
+
note(
|
|
919
|
+
`Files uploaded to Discord thread!\n\nFiles: ${resolvedFiles.map((f) => path.basename(f)).join(', ')}`,
|
|
920
|
+
'✅ Success',
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
process.exit(0)
|
|
924
|
+
} catch (error) {
|
|
925
|
+
cliLogger.error(
|
|
926
|
+
'Error:',
|
|
927
|
+
error instanceof Error ? error.message : String(error),
|
|
928
|
+
)
|
|
929
|
+
process.exit(EXIT_NO_RESTART)
|
|
930
|
+
}
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
cli
|
|
934
|
+
.command('install-plugin', 'Install the OpenCode commands for kimaki Discord integration')
|
|
836
935
|
.action(async () => {
|
|
837
936
|
try {
|
|
838
937
|
const require = createRequire(import.meta.url)
|
|
839
|
-
const
|
|
840
|
-
const
|
|
938
|
+
const sendCommandSrc = require.resolve('./opencode-command-send-to-discord.md')
|
|
939
|
+
const uploadCommandSrc = require.resolve('./opencode-command-upload-to-discord.md')
|
|
841
940
|
|
|
842
941
|
const opencodeConfig = path.join(os.homedir(), '.config', 'opencode')
|
|
843
|
-
const pluginDir = path.join(opencodeConfig, 'plugin')
|
|
844
942
|
const commandDir = path.join(opencodeConfig, 'command')
|
|
845
943
|
|
|
846
|
-
fs.mkdirSync(pluginDir, { recursive: true })
|
|
847
944
|
fs.mkdirSync(commandDir, { recursive: true })
|
|
848
945
|
|
|
849
|
-
const
|
|
850
|
-
const
|
|
946
|
+
const sendCommandDest = path.join(commandDir, 'send-to-kimaki-discord.md')
|
|
947
|
+
const uploadCommandDest = path.join(commandDir, 'upload-to-discord.md')
|
|
851
948
|
|
|
852
|
-
fs.copyFileSync(
|
|
853
|
-
fs.copyFileSync(
|
|
949
|
+
fs.copyFileSync(sendCommandSrc, sendCommandDest)
|
|
950
|
+
fs.copyFileSync(uploadCommandSrc, uploadCommandDest)
|
|
854
951
|
|
|
855
952
|
note(
|
|
856
|
-
`
|
|
953
|
+
`Commands installed:\n- ${sendCommandDest}\n- ${uploadCommandDest}\n\nUse /send-to-kimaki-discord to send session to Discord.\nUse /upload-to-discord to upload files to the thread.`,
|
|
857
954
|
'✅ Installed',
|
|
858
955
|
)
|
|
859
956
|
|
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')
|
|
@@ -1349,12 +1375,14 @@ async function handleOpencodeSession({
|
|
|
1349
1375
|
projectDirectory,
|
|
1350
1376
|
originalMessage,
|
|
1351
1377
|
images = [],
|
|
1378
|
+
parsedCommand,
|
|
1352
1379
|
}: {
|
|
1353
1380
|
prompt: string
|
|
1354
1381
|
thread: ThreadChannel
|
|
1355
1382
|
projectDirectory?: string
|
|
1356
1383
|
originalMessage?: Message
|
|
1357
1384
|
images?: FilePartInput[]
|
|
1385
|
+
parsedCommand?: ParsedCommand
|
|
1358
1386
|
}): Promise<{ sessionID: string; result: any; port?: number } | undefined> {
|
|
1359
1387
|
voiceLogger.log(
|
|
1360
1388
|
`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
|
|
@@ -1477,6 +1505,8 @@ async function handleOpencodeSession({
|
|
|
1477
1505
|
let currentParts: Part[] = []
|
|
1478
1506
|
let stopTyping: (() => void) | null = null
|
|
1479
1507
|
let usedModel: string | undefined
|
|
1508
|
+
let usedProviderID: string | undefined
|
|
1509
|
+
let inputTokens = 0
|
|
1480
1510
|
|
|
1481
1511
|
const sendPartMessage = async (part: Part) => {
|
|
1482
1512
|
const content = formatPart(part) + '\n\n'
|
|
@@ -1487,22 +1517,12 @@ async function handleOpencodeSession({
|
|
|
1487
1517
|
|
|
1488
1518
|
// Skip if already sent
|
|
1489
1519
|
if (partIdToMessage.has(part.id)) {
|
|
1490
|
-
voiceLogger.log(
|
|
1491
|
-
`[SEND SKIP] Part ${part.id} already sent as message ${partIdToMessage.get(part.id)?.id}`,
|
|
1492
|
-
)
|
|
1493
1520
|
return
|
|
1494
1521
|
}
|
|
1495
1522
|
|
|
1496
1523
|
try {
|
|
1497
|
-
voiceLogger.log(
|
|
1498
|
-
`[SEND] Sending part ${part.id} (type: ${part.type}) to Discord, content length: ${content.length}`,
|
|
1499
|
-
)
|
|
1500
|
-
|
|
1501
1524
|
const firstMessage = await sendThreadMessage(thread, content)
|
|
1502
1525
|
partIdToMessage.set(part.id, firstMessage)
|
|
1503
|
-
voiceLogger.log(
|
|
1504
|
-
`[SEND SUCCESS] Part ${part.id} sent as message ${firstMessage.id}`,
|
|
1505
|
-
)
|
|
1506
1526
|
|
|
1507
1527
|
// Store part-message mapping in database
|
|
1508
1528
|
getDatabase()
|
|
@@ -1525,13 +1545,10 @@ async function handleOpencodeSession({
|
|
|
1525
1545
|
discordLogger.log(`Not starting typing, already aborted`)
|
|
1526
1546
|
return () => {}
|
|
1527
1547
|
}
|
|
1528
|
-
discordLogger.log(`Starting typing for thread ${thread.id}`)
|
|
1529
|
-
|
|
1530
1548
|
// Clear any previous typing interval
|
|
1531
1549
|
if (typingInterval) {
|
|
1532
1550
|
clearInterval(typingInterval)
|
|
1533
1551
|
typingInterval = null
|
|
1534
|
-
discordLogger.log(`Cleared previous typing interval`)
|
|
1535
1552
|
}
|
|
1536
1553
|
|
|
1537
1554
|
// Send initial typing
|
|
@@ -1567,7 +1584,6 @@ async function handleOpencodeSession({
|
|
|
1567
1584
|
if (typingInterval) {
|
|
1568
1585
|
clearInterval(typingInterval)
|
|
1569
1586
|
typingInterval = null
|
|
1570
|
-
discordLogger.log(`Stopped typing for thread ${thread.id}`)
|
|
1571
1587
|
}
|
|
1572
1588
|
}
|
|
1573
1589
|
}
|
|
@@ -1576,45 +1592,31 @@ async function handleOpencodeSession({
|
|
|
1576
1592
|
let assistantMessageId: string | undefined
|
|
1577
1593
|
|
|
1578
1594
|
for await (const event of events) {
|
|
1579
|
-
sessionLogger.log(`Received: ${event.type}`)
|
|
1580
1595
|
if (event.type === 'message.updated') {
|
|
1581
1596
|
const msg = event.properties.info
|
|
1582
1597
|
|
|
1583
1598
|
if (msg.sessionID !== session.id) {
|
|
1584
|
-
voiceLogger.log(
|
|
1585
|
-
`[EVENT IGNORED] Message from different session (expected: ${session.id}, got: ${msg.sessionID})`,
|
|
1586
|
-
)
|
|
1587
1599
|
continue
|
|
1588
1600
|
}
|
|
1589
1601
|
|
|
1590
1602
|
// Track assistant message ID
|
|
1591
1603
|
if (msg.role === 'assistant') {
|
|
1592
1604
|
assistantMessageId = msg.id
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
1605
|
usedModel = msg.modelID
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
} else {
|
|
1601
|
-
sessionLogger.log(`Message role: ${msg.role}`)
|
|
1606
|
+
usedProviderID = msg.providerID
|
|
1607
|
+
if (msg.tokens.input > 0) {
|
|
1608
|
+
inputTokens = msg.tokens.input
|
|
1609
|
+
}
|
|
1602
1610
|
}
|
|
1603
1611
|
} else if (event.type === 'message.part.updated') {
|
|
1604
1612
|
const part = event.properties.part
|
|
1605
1613
|
|
|
1606
1614
|
if (part.sessionID !== session.id) {
|
|
1607
|
-
voiceLogger.log(
|
|
1608
|
-
`[EVENT IGNORED] Part from different session (expected: ${session.id}, got: ${part.sessionID})`,
|
|
1609
|
-
)
|
|
1610
1615
|
continue
|
|
1611
1616
|
}
|
|
1612
1617
|
|
|
1613
1618
|
// Only process parts from assistant messages
|
|
1614
1619
|
if (part.messageID !== assistantMessageId) {
|
|
1615
|
-
voiceLogger.log(
|
|
1616
|
-
`[EVENT IGNORED] Part from non-assistant message (expected: ${assistantMessageId}, got: ${part.messageID})`,
|
|
1617
|
-
)
|
|
1618
1620
|
continue
|
|
1619
1621
|
}
|
|
1620
1622
|
|
|
@@ -1627,9 +1629,7 @@ async function handleOpencodeSession({
|
|
|
1627
1629
|
currentParts.push(part)
|
|
1628
1630
|
}
|
|
1629
1631
|
|
|
1630
|
-
|
|
1631
|
-
`[PART] Update: id=${part.id}, type=${part.type}, text=${'text' in part && typeof part.text === 'string' ? part.text.slice(0, 50) : ''}`,
|
|
1632
|
-
)
|
|
1632
|
+
|
|
1633
1633
|
|
|
1634
1634
|
// Start typing on step-start
|
|
1635
1635
|
if (part.type === 'step-start') {
|
|
@@ -1638,10 +1638,12 @@ async function handleOpencodeSession({
|
|
|
1638
1638
|
|
|
1639
1639
|
// Check if this is a step-finish part
|
|
1640
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
|
+
}
|
|
1641
1646
|
// Send all parts accumulated so far to Discord
|
|
1642
|
-
voiceLogger.log(
|
|
1643
|
-
`[STEP-FINISH] Sending ${currentParts.length} parts to Discord`,
|
|
1644
|
-
)
|
|
1645
1647
|
for (const p of currentParts) {
|
|
1646
1648
|
// Skip step-start and step-finish parts as they have no visual content
|
|
1647
1649
|
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
@@ -1728,10 +1730,6 @@ async function handleOpencodeSession({
|
|
|
1728
1730
|
if (pending && pending.permission.id === permissionID) {
|
|
1729
1731
|
pendingPermissions.delete(thread.id)
|
|
1730
1732
|
}
|
|
1731
|
-
} else if (event.type === 'file.edited') {
|
|
1732
|
-
sessionLogger.log(`File edited event received`)
|
|
1733
|
-
} else {
|
|
1734
|
-
sessionLogger.log(`Unhandled event type: ${event.type}`)
|
|
1735
1733
|
}
|
|
1736
1734
|
}
|
|
1737
1735
|
} catch (e) {
|
|
@@ -1745,37 +1743,20 @@ async function handleOpencodeSession({
|
|
|
1745
1743
|
throw e
|
|
1746
1744
|
} finally {
|
|
1747
1745
|
// Send any remaining parts that weren't sent
|
|
1748
|
-
voiceLogger.log(
|
|
1749
|
-
`[CLEANUP] Checking ${currentParts.length} parts for unsent messages`,
|
|
1750
|
-
)
|
|
1751
|
-
let unsentCount = 0
|
|
1752
1746
|
for (const part of currentParts) {
|
|
1753
1747
|
if (!partIdToMessage.has(part.id)) {
|
|
1754
|
-
unsentCount++
|
|
1755
|
-
voiceLogger.log(
|
|
1756
|
-
`[CLEANUP] Sending unsent part: id=${part.id}, type=${part.type}`,
|
|
1757
|
-
)
|
|
1758
1748
|
try {
|
|
1759
1749
|
await sendPartMessage(part)
|
|
1760
1750
|
} catch (error) {
|
|
1761
|
-
sessionLogger.
|
|
1762
|
-
`Failed to send part ${part.id} during cleanup:`,
|
|
1763
|
-
error,
|
|
1764
|
-
)
|
|
1751
|
+
sessionLogger.error(`Failed to send part ${part.id}:`, error)
|
|
1765
1752
|
}
|
|
1766
1753
|
}
|
|
1767
1754
|
}
|
|
1768
|
-
if (unsentCount === 0) {
|
|
1769
|
-
sessionLogger.log(`All parts were already sent`)
|
|
1770
|
-
} else {
|
|
1771
|
-
sessionLogger.log(`Sent ${unsentCount} previously unsent parts`)
|
|
1772
|
-
}
|
|
1773
1755
|
|
|
1774
1756
|
// Stop typing when session ends
|
|
1775
1757
|
if (stopTyping) {
|
|
1776
1758
|
stopTyping()
|
|
1777
1759
|
stopTyping = null
|
|
1778
|
-
sessionLogger.log(`Stopped typing for session`)
|
|
1779
1760
|
}
|
|
1780
1761
|
|
|
1781
1762
|
// Only send duration message if request was not aborted or was aborted with 'finished' reason
|
|
@@ -1788,8 +1769,22 @@ async function handleOpencodeSession({
|
|
|
1788
1769
|
)
|
|
1789
1770
|
const attachCommand = port ? ` ⋅ ${session.id}` : ''
|
|
1790
1771
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
|
|
1791
|
-
|
|
1792
|
-
|
|
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}`)
|
|
1793
1788
|
} else {
|
|
1794
1789
|
sessionLogger.log(
|
|
1795
1790
|
`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
|
|
@@ -1799,27 +1794,42 @@ async function handleOpencodeSession({
|
|
|
1799
1794
|
}
|
|
1800
1795
|
|
|
1801
1796
|
try {
|
|
1802
|
-
voiceLogger.log(
|
|
1803
|
-
`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`,
|
|
1804
|
-
)
|
|
1805
|
-
if (images.length > 0) {
|
|
1806
|
-
sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })))
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
1797
|
// Start the event handler
|
|
1810
1798
|
const eventHandlerPromise = eventHandler()
|
|
1811
1799
|
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
+
}
|
|
1814
1820
|
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1821
|
+
const parts = [{ type: 'text' as const, text: prompt }, ...images]
|
|
1822
|
+
sessionLogger.log(`[PROMPT] Parts to send:`, parts.length)
|
|
1823
|
+
|
|
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
|
+
}
|
|
1823
1833
|
abortController.abort('finished')
|
|
1824
1834
|
|
|
1825
1835
|
sessionLogger.log(`Successfully sent prompt, got response`)
|
|
@@ -1976,9 +1986,6 @@ export async function startDiscordBot({
|
|
|
1976
1986
|
discordClient.on(Events.MessageCreate, async (message: Message) => {
|
|
1977
1987
|
try {
|
|
1978
1988
|
if (message.author?.bot) {
|
|
1979
|
-
voiceLogger.log(
|
|
1980
|
-
`[IGNORED] Bot message from ${message.author.tag} in channel ${message.channelId}`,
|
|
1981
|
-
)
|
|
1982
1989
|
return
|
|
1983
1990
|
}
|
|
1984
1991
|
if (message.partial) {
|
|
@@ -2002,15 +2009,8 @@ export async function startDiscordBot({
|
|
|
2002
2009
|
)
|
|
2003
2010
|
|
|
2004
2011
|
if (!isOwner && !isAdmin) {
|
|
2005
|
-
voiceLogger.log(
|
|
2006
|
-
`[IGNORED] Non-authoritative user ${message.author.tag} (ID: ${message.author.id}) - not owner or admin`,
|
|
2007
|
-
)
|
|
2008
2012
|
return
|
|
2009
2013
|
}
|
|
2010
|
-
|
|
2011
|
-
voiceLogger.log(
|
|
2012
|
-
`[AUTHORIZED] Message from ${message.author.tag} (Owner: ${isOwner}, Admin: ${isAdmin})`,
|
|
2013
|
-
)
|
|
2014
2014
|
}
|
|
2015
2015
|
|
|
2016
2016
|
const channel = message.channel
|
|
@@ -2084,12 +2084,14 @@ export async function startDiscordBot({
|
|
|
2084
2084
|
}
|
|
2085
2085
|
|
|
2086
2086
|
const images = getImageAttachments(message)
|
|
2087
|
+
const parsedCommand = parseSlashCommand(messageContent)
|
|
2087
2088
|
await handleOpencodeSession({
|
|
2088
2089
|
prompt: messageContent,
|
|
2089
2090
|
thread,
|
|
2090
2091
|
projectDirectory,
|
|
2091
2092
|
originalMessage: message,
|
|
2092
2093
|
images,
|
|
2094
|
+
parsedCommand,
|
|
2093
2095
|
})
|
|
2094
2096
|
return
|
|
2095
2097
|
}
|
|
@@ -2179,12 +2181,14 @@ export async function startDiscordBot({
|
|
|
2179
2181
|
}
|
|
2180
2182
|
|
|
2181
2183
|
const images = getImageAttachments(message)
|
|
2184
|
+
const parsedCommand = parseSlashCommand(messageContent)
|
|
2182
2185
|
await handleOpencodeSession({
|
|
2183
2186
|
prompt: messageContent,
|
|
2184
2187
|
thread,
|
|
2185
2188
|
projectDirectory,
|
|
2186
2189
|
originalMessage: message,
|
|
2187
2190
|
images,
|
|
2191
|
+
parsedCommand,
|
|
2188
2192
|
})
|
|
2189
2193
|
} else {
|
|
2190
2194
|
discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
@@ -2520,10 +2524,12 @@ export async function startDiscordBot({
|
|
|
2520
2524
|
)
|
|
2521
2525
|
|
|
2522
2526
|
// Start the OpenCode session
|
|
2527
|
+
const parsedCommand = parseSlashCommand(fullPrompt)
|
|
2523
2528
|
await handleOpencodeSession({
|
|
2524
2529
|
prompt: fullPrompt,
|
|
2525
2530
|
thread,
|
|
2526
2531
|
projectDirectory,
|
|
2532
|
+
parsedCommand,
|
|
2527
2533
|
})
|
|
2528
2534
|
} catch (error) {
|
|
2529
2535
|
voiceLogger.error('[SESSION] Error:', error)
|
|
@@ -2891,6 +2897,77 @@ export async function startDiscordBot({
|
|
|
2891
2897
|
ephemeral: true,
|
|
2892
2898
|
})
|
|
2893
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
|
+
}
|
|
2894
2971
|
}
|
|
2895
2972
|
}
|
|
2896
2973
|
} catch (error) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Send current session to Discord
|
|
3
|
+
---
|
|
4
|
+
Run the following command to send this session to Discord:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx -y kimaki send-to-discord <sessionId>
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Replace `<sessionId>` with your current OpenCode session ID (available in the system prompt).
|
|
11
|
+
|
|
12
|
+
The command will create a Discord thread with your session history and return the Discord URL.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Upload files to Discord thread
|
|
3
|
+
---
|
|
4
|
+
Upload files to the current Discord thread by running:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx -y kimaki upload-to-discord --session <sessionId> <file1> [file2] [file3] ...
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Replace `<sessionId>` with your current OpenCode session ID (available in the system prompt).
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# Upload a single file
|
|
16
|
+
npx -y kimaki upload-to-discord --session ses_abc123 ./screenshot.png
|
|
17
|
+
|
|
18
|
+
# Upload multiple files
|
|
19
|
+
npx -y kimaki upload-to-discord --session ses_abc123 ./image1.png ./image2.jpg ./document.pdf
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The session must have been sent to Discord first using `/send-to-kimaki-discord`.
|
package/src/tools.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { ShareMarkdown } from './markdown.js'
|
|
|
17
17
|
import pc from 'picocolors'
|
|
18
18
|
import {
|
|
19
19
|
initializeOpencodeForDirectory,
|
|
20
|
-
|
|
20
|
+
getOpencodeSystemMessage,
|
|
21
21
|
} from './discordBot.js'
|
|
22
22
|
|
|
23
23
|
export async function getTools({
|
|
@@ -78,7 +78,7 @@ export async function getTools({
|
|
|
78
78
|
body: {
|
|
79
79
|
parts: [{ type: 'text', text: message }],
|
|
80
80
|
model: sessionModel,
|
|
81
|
-
system:
|
|
81
|
+
system: getOpencodeSystemMessage({ sessionId }),
|
|
82
82
|
},
|
|
83
83
|
})
|
|
84
84
|
.then(async (response) => {
|
|
@@ -152,7 +152,7 @@ export async function getTools({
|
|
|
152
152
|
path: { id: session.data.id },
|
|
153
153
|
body: {
|
|
154
154
|
parts: [{ type: 'text', text: message }],
|
|
155
|
-
system:
|
|
155
|
+
system: getOpencodeSystemMessage({ sessionId: session.data.id }),
|
|
156
156
|
},
|
|
157
157
|
})
|
|
158
158
|
.then(async (response) => {
|
package/src/opencode-command.md
DELETED