kimaki 0.4.18 → 0.4.20
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/README.md +0 -21
- package/dist/cli.js +46 -154
- package/dist/discordBot.js +308 -107
- package/dist/genai.js +1 -1
- package/dist/xai-realtime.js +95 -0
- package/package.json +7 -7
- package/src/cli.ts +52 -216
- package/src/discordBot.ts +369 -114
- package/src/genai-worker.ts +1 -1
- package/src/genai.ts +1 -1
- package/src/opencode-command-send-to-discord.md +0 -12
package/src/discordBot.ts
CHANGED
|
@@ -80,6 +80,8 @@ export function getOpencodeSystemMessage({ sessionId }: { sessionId: string }) {
|
|
|
80
80
|
return `
|
|
81
81
|
The user is reading your messages from inside Discord, via kimaki.xyz
|
|
82
82
|
|
|
83
|
+
The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
|
|
84
|
+
|
|
83
85
|
Your current OpenCode session ID is: ${sessionId}
|
|
84
86
|
|
|
85
87
|
## uploading files to discord
|
|
@@ -620,6 +622,7 @@ export function getDatabase(): Database.Database {
|
|
|
620
622
|
CREATE TABLE IF NOT EXISTS bot_api_keys (
|
|
621
623
|
app_id TEXT PRIMARY KEY,
|
|
622
624
|
gemini_api_key TEXT,
|
|
625
|
+
xai_api_key TEXT,
|
|
623
626
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
624
627
|
)
|
|
625
628
|
`)
|
|
@@ -877,14 +880,64 @@ async function processVoiceAttachment({
|
|
|
877
880
|
return transcription
|
|
878
881
|
}
|
|
879
882
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
+
const TEXT_MIME_TYPES = [
|
|
884
|
+
'text/',
|
|
885
|
+
'application/json',
|
|
886
|
+
'application/xml',
|
|
887
|
+
'application/javascript',
|
|
888
|
+
'application/typescript',
|
|
889
|
+
'application/x-yaml',
|
|
890
|
+
'application/toml',
|
|
891
|
+
]
|
|
892
|
+
|
|
893
|
+
function isTextMimeType(contentType: string | null): boolean {
|
|
894
|
+
if (!contentType) {
|
|
895
|
+
return false
|
|
896
|
+
}
|
|
897
|
+
return TEXT_MIME_TYPES.some((prefix) => contentType.startsWith(prefix))
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function getTextAttachments(message: Message): Promise<string> {
|
|
901
|
+
const textAttachments = Array.from(message.attachments.values()).filter(
|
|
902
|
+
(attachment) => isTextMimeType(attachment.contentType),
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
if (textAttachments.length === 0) {
|
|
906
|
+
return ''
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const textContents = await Promise.all(
|
|
910
|
+
textAttachments.map(async (attachment) => {
|
|
911
|
+
try {
|
|
912
|
+
const response = await fetch(attachment.url)
|
|
913
|
+
if (!response.ok) {
|
|
914
|
+
return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`
|
|
915
|
+
}
|
|
916
|
+
const text = await response.text()
|
|
917
|
+
return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`
|
|
918
|
+
} catch (error) {
|
|
919
|
+
const errMsg = error instanceof Error ? error.message : String(error)
|
|
920
|
+
return `<attachment filename="${attachment.name}" error="${errMsg}" />`
|
|
921
|
+
}
|
|
922
|
+
}),
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
return textContents.join('\n\n')
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function getFileAttachments(message: Message): FilePartInput[] {
|
|
929
|
+
const fileAttachments = Array.from(message.attachments.values()).filter(
|
|
930
|
+
(attachment) => {
|
|
931
|
+
const contentType = attachment.contentType || ''
|
|
932
|
+
return (
|
|
933
|
+
contentType.startsWith('image/') || contentType === 'application/pdf'
|
|
934
|
+
)
|
|
935
|
+
},
|
|
883
936
|
)
|
|
884
937
|
|
|
885
|
-
return
|
|
938
|
+
return fileAttachments.map((attachment) => ({
|
|
886
939
|
type: 'file' as const,
|
|
887
|
-
mime: attachment.contentType || '
|
|
940
|
+
mime: attachment.contentType || 'application/octet-stream',
|
|
888
941
|
filename: attachment.name,
|
|
889
942
|
url: attachment.url,
|
|
890
943
|
}))
|
|
@@ -1204,13 +1257,6 @@ export async function initializeOpencodeForDirectory(directory: string) {
|
|
|
1204
1257
|
|
|
1205
1258
|
function getToolSummaryText(part: Part): string {
|
|
1206
1259
|
if (part.type !== 'tool') return ''
|
|
1207
|
-
if (part.state.status !== 'completed' && part.state.status !== 'error') return ''
|
|
1208
|
-
|
|
1209
|
-
if (part.tool === 'bash') {
|
|
1210
|
-
const output = part.state.status === 'completed' ? part.state.output : part.state.error
|
|
1211
|
-
const lines = (output || '').split('\n').filter((l: string) => l.trim())
|
|
1212
|
-
return `(${lines.length} line${lines.length === 1 ? '' : 's'})`
|
|
1213
|
-
}
|
|
1214
1260
|
|
|
1215
1261
|
if (part.tool === 'edit') {
|
|
1216
1262
|
const newString = (part.state.input?.newString as string) || ''
|
|
@@ -1233,6 +1279,7 @@ function getToolSummaryText(part: Part): string {
|
|
|
1233
1279
|
}
|
|
1234
1280
|
|
|
1235
1281
|
if (
|
|
1282
|
+
part.tool === 'bash' ||
|
|
1236
1283
|
part.tool === 'read' ||
|
|
1237
1284
|
part.tool === 'list' ||
|
|
1238
1285
|
part.tool === 'glob' ||
|
|
@@ -1250,7 +1297,7 @@ function getToolSummaryText(part: Part): string {
|
|
|
1250
1297
|
.map(([key, value]) => {
|
|
1251
1298
|
if (value === null || value === undefined) return null
|
|
1252
1299
|
const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
|
|
1253
|
-
const truncatedValue = stringValue.length >
|
|
1300
|
+
const truncatedValue = stringValue.length > 300 ? stringValue.slice(0, 300) + '…' : stringValue
|
|
1254
1301
|
return `${key}: ${truncatedValue}`
|
|
1255
1302
|
})
|
|
1256
1303
|
.filter(Boolean)
|
|
@@ -1260,17 +1307,6 @@ function getToolSummaryText(part: Part): string {
|
|
|
1260
1307
|
return `(${inputFields.join(', ')})`
|
|
1261
1308
|
}
|
|
1262
1309
|
|
|
1263
|
-
function getToolOutputToDisplay(part: Part): string {
|
|
1264
|
-
if (part.type !== 'tool') return ''
|
|
1265
|
-
if (part.state.status !== 'completed' && part.state.status !== 'error') return ''
|
|
1266
|
-
|
|
1267
|
-
if (part.state.status === 'error') {
|
|
1268
|
-
return part.state.error || 'Unknown error'
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
return ''
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
1310
|
function formatTodoList(part: Part): string {
|
|
1275
1311
|
if (part.type !== 'tool' || part.tool !== 'todowrite') return ''
|
|
1276
1312
|
const todos =
|
|
@@ -1324,36 +1360,35 @@ function formatPart(part: Part): string {
|
|
|
1324
1360
|
return formatTodoList(part)
|
|
1325
1361
|
}
|
|
1326
1362
|
|
|
1327
|
-
if (part.state.status
|
|
1363
|
+
if (part.state.status === 'pending') {
|
|
1328
1364
|
return ''
|
|
1329
1365
|
}
|
|
1330
1366
|
|
|
1331
1367
|
const summaryText = getToolSummaryText(part)
|
|
1332
|
-
const
|
|
1368
|
+
const stateTitle = 'title' in part.state ? part.state.title : undefined
|
|
1333
1369
|
|
|
1334
1370
|
let toolTitle = ''
|
|
1335
1371
|
if (part.state.status === 'error') {
|
|
1336
|
-
toolTitle = 'error'
|
|
1372
|
+
toolTitle = part.state.error || 'error'
|
|
1337
1373
|
} else if (part.tool === 'bash') {
|
|
1338
1374
|
const command = (part.state.input?.command as string) || ''
|
|
1339
1375
|
const isSingleLine = !command.includes('\n')
|
|
1340
1376
|
const hasBackticks = command.includes('`')
|
|
1341
1377
|
if (isSingleLine && command.length <= 120 && !hasBackticks) {
|
|
1342
|
-
toolTitle =
|
|
1378
|
+
toolTitle = `_${command}_`
|
|
1343
1379
|
} else {
|
|
1344
|
-
toolTitle =
|
|
1380
|
+
toolTitle = stateTitle ? `_${stateTitle}_` : ''
|
|
1345
1381
|
}
|
|
1346
|
-
} else if (part.
|
|
1347
|
-
|
|
1382
|
+
} else if (part.tool === 'edit' || part.tool === 'write') {
|
|
1383
|
+
const filePath = (part.state.input?.filePath as string) || ''
|
|
1384
|
+
const fileName = filePath.split('/').pop() || filePath
|
|
1385
|
+
toolTitle = fileName ? `_${fileName}_` : ''
|
|
1386
|
+
} else if (stateTitle) {
|
|
1387
|
+
toolTitle = `_${stateTitle}_`
|
|
1348
1388
|
}
|
|
1349
1389
|
|
|
1350
|
-
const icon = part.state.status === '
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
if (outputToDisplay) {
|
|
1354
|
-
return title + '\n\n' + outputToDisplay
|
|
1355
|
-
}
|
|
1356
|
-
return title
|
|
1390
|
+
const icon = part.state.status === 'error' ? '⨯' : '◼︎'
|
|
1391
|
+
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`
|
|
1357
1392
|
}
|
|
1358
1393
|
|
|
1359
1394
|
discordLogger.warn('Unknown part type:', part)
|
|
@@ -1399,16 +1434,6 @@ async function handleOpencodeSession({
|
|
|
1399
1434
|
// Track session start time
|
|
1400
1435
|
const sessionStartTime = Date.now()
|
|
1401
1436
|
|
|
1402
|
-
// Add processing reaction to original message
|
|
1403
|
-
if (originalMessage) {
|
|
1404
|
-
try {
|
|
1405
|
-
await originalMessage.react('⏳')
|
|
1406
|
-
discordLogger.log(`Added processing reaction to message`)
|
|
1407
|
-
} catch (e) {
|
|
1408
|
-
discordLogger.log(`Could not add processing reaction:`, e)
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
1437
|
// Use default directory if not specified
|
|
1413
1438
|
const directory = projectDirectory || process.cwd()
|
|
1414
1439
|
sessionLogger.log(`Using directory: ${directory}`)
|
|
@@ -1444,11 +1469,12 @@ async function handleOpencodeSession({
|
|
|
1444
1469
|
}
|
|
1445
1470
|
|
|
1446
1471
|
if (!session) {
|
|
1472
|
+
const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80)
|
|
1447
1473
|
voiceLogger.log(
|
|
1448
|
-
`[SESSION] Creating new session with title: "${
|
|
1474
|
+
`[SESSION] Creating new session with title: "${sessionTitle}"`,
|
|
1449
1475
|
)
|
|
1450
1476
|
const sessionResponse = await getClient().session.create({
|
|
1451
|
-
body: { title:
|
|
1477
|
+
body: { title: sessionTitle },
|
|
1452
1478
|
})
|
|
1453
1479
|
session = sessionResponse.data
|
|
1454
1480
|
sessionLogger.log(`Created new session ${session?.id}`)
|
|
@@ -1475,46 +1501,46 @@ async function handleOpencodeSession({
|
|
|
1475
1501
|
existingController.abort(new Error('New request started'))
|
|
1476
1502
|
}
|
|
1477
1503
|
|
|
1478
|
-
if (abortControllers.has(session.id)) {
|
|
1479
|
-
abortControllers.get(session.id)?.abort(new Error('new reply'))
|
|
1480
|
-
}
|
|
1481
1504
|
const abortController = new AbortController()
|
|
1482
|
-
// Store this controller for this session
|
|
1483
1505
|
abortControllers.set(session.id, abortController)
|
|
1484
1506
|
|
|
1507
|
+
if (existingController) {
|
|
1508
|
+
await new Promise((resolve) => { setTimeout(resolve, 200) })
|
|
1509
|
+
if (abortController.signal.aborted) {
|
|
1510
|
+
sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`)
|
|
1511
|
+
return
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
if (abortController.signal.aborted) {
|
|
1516
|
+
sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`)
|
|
1517
|
+
return
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1485
1520
|
const eventsResult = await getClient().event.subscribe({
|
|
1486
1521
|
signal: abortController.signal,
|
|
1487
1522
|
})
|
|
1523
|
+
|
|
1524
|
+
if (abortController.signal.aborted) {
|
|
1525
|
+
sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`)
|
|
1526
|
+
return
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1488
1529
|
const events = eventsResult.stream
|
|
1489
1530
|
sessionLogger.log(`Subscribed to OpenCode events`)
|
|
1490
1531
|
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
.all(thread.id) as { part_id: string; message_id: string }[]
|
|
1498
|
-
|
|
1499
|
-
// Pre-populate map with existing messages
|
|
1500
|
-
for (const row of existingParts) {
|
|
1501
|
-
try {
|
|
1502
|
-
const message = await thread.messages.fetch(row.message_id)
|
|
1503
|
-
if (message) {
|
|
1504
|
-
partIdToMessage.set(row.part_id, message)
|
|
1505
|
-
}
|
|
1506
|
-
} catch (error) {
|
|
1507
|
-
voiceLogger.log(
|
|
1508
|
-
`Could not fetch message ${row.message_id} for part ${row.part_id}`,
|
|
1509
|
-
)
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1532
|
+
const sentPartIds = new Set<string>(
|
|
1533
|
+
(getDatabase()
|
|
1534
|
+
.prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
|
|
1535
|
+
.all(thread.id) as { part_id: string }[])
|
|
1536
|
+
.map((row) => row.part_id)
|
|
1537
|
+
)
|
|
1512
1538
|
|
|
1513
1539
|
let currentParts: Part[] = []
|
|
1514
1540
|
let stopTyping: (() => void) | null = null
|
|
1515
1541
|
let usedModel: string | undefined
|
|
1516
1542
|
let usedProviderID: string | undefined
|
|
1517
|
-
let
|
|
1543
|
+
let tokensUsedInSession = 0
|
|
1518
1544
|
|
|
1519
1545
|
const sendPartMessage = async (part: Part) => {
|
|
1520
1546
|
const content = formatPart(part) + '\n\n'
|
|
@@ -1524,13 +1550,13 @@ async function handleOpencodeSession({
|
|
|
1524
1550
|
}
|
|
1525
1551
|
|
|
1526
1552
|
// Skip if already sent
|
|
1527
|
-
if (
|
|
1553
|
+
if (sentPartIds.has(part.id)) {
|
|
1528
1554
|
return
|
|
1529
1555
|
}
|
|
1530
1556
|
|
|
1531
1557
|
try {
|
|
1532
1558
|
const firstMessage = await sendThreadMessage(thread, content)
|
|
1533
|
-
|
|
1559
|
+
sentPartIds.add(part.id)
|
|
1534
1560
|
|
|
1535
1561
|
// Store part-message mapping in database
|
|
1536
1562
|
getDatabase()
|
|
@@ -1603,22 +1629,27 @@ async function handleOpencodeSession({
|
|
|
1603
1629
|
if (event.type === 'message.updated') {
|
|
1604
1630
|
const msg = event.properties.info
|
|
1605
1631
|
|
|
1632
|
+
|
|
1633
|
+
|
|
1606
1634
|
if (msg.sessionID !== session.id) {
|
|
1607
1635
|
continue
|
|
1608
1636
|
}
|
|
1609
1637
|
|
|
1610
1638
|
// Track assistant message ID
|
|
1611
1639
|
if (msg.role === 'assistant') {
|
|
1640
|
+
const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
|
1641
|
+
if (newTokensTotal > 0) {
|
|
1642
|
+
tokensUsedInSession = newTokensTotal
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1612
1645
|
assistantMessageId = msg.id
|
|
1613
1646
|
usedModel = msg.modelID
|
|
1614
1647
|
usedProviderID = msg.providerID
|
|
1615
|
-
if (msg.tokens.input > 0) {
|
|
1616
|
-
inputTokens = msg.tokens.input
|
|
1617
|
-
}
|
|
1618
1648
|
}
|
|
1619
1649
|
} else if (event.type === 'message.part.updated') {
|
|
1620
1650
|
const part = event.properties.part
|
|
1621
1651
|
|
|
1652
|
+
|
|
1622
1653
|
if (part.sessionID !== session.id) {
|
|
1623
1654
|
continue
|
|
1624
1655
|
}
|
|
@@ -1644,13 +1675,19 @@ async function handleOpencodeSession({
|
|
|
1644
1675
|
stopTyping = startTyping(thread)
|
|
1645
1676
|
}
|
|
1646
1677
|
|
|
1678
|
+
// Send tool parts immediately when they start running
|
|
1679
|
+
if (part.type === 'tool' && part.state.status === 'running') {
|
|
1680
|
+
await sendPartMessage(part)
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Send reasoning parts immediately (shows "◼︎ thinking" indicator early)
|
|
1684
|
+
if (part.type === 'reasoning') {
|
|
1685
|
+
await sendPartMessage(part)
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1647
1688
|
// Check if this is a step-finish part
|
|
1648
1689
|
if (part.type === 'step-finish') {
|
|
1649
|
-
|
|
1650
|
-
if (part.tokens?.input && part.tokens.input > 0) {
|
|
1651
|
-
inputTokens = part.tokens.input
|
|
1652
|
-
voiceLogger.log(`[STEP-FINISH] Captured tokens: ${inputTokens}`)
|
|
1653
|
-
}
|
|
1690
|
+
|
|
1654
1691
|
// Send all parts accumulated so far to Discord
|
|
1655
1692
|
for (const p of currentParts) {
|
|
1656
1693
|
// Skip step-start and step-finish parts as they have no visual content
|
|
@@ -1752,7 +1789,7 @@ async function handleOpencodeSession({
|
|
|
1752
1789
|
} finally {
|
|
1753
1790
|
// Send any remaining parts that weren't sent
|
|
1754
1791
|
for (const part of currentParts) {
|
|
1755
|
-
if (!
|
|
1792
|
+
if (!sentPartIds.has(part.id)) {
|
|
1756
1793
|
try {
|
|
1757
1794
|
await sendPartMessage(part)
|
|
1758
1795
|
} catch (error) {
|
|
@@ -1778,21 +1815,22 @@ async function handleOpencodeSession({
|
|
|
1778
1815
|
const attachCommand = port ? ` ⋅ ${session.id}` : ''
|
|
1779
1816
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
|
|
1780
1817
|
let contextInfo = ''
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
}
|
|
1790
|
-
} catch (e) {
|
|
1791
|
-
sessionLogger.error('Failed to fetch provider info for context percentage:', e)
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
try {
|
|
1821
|
+
const providersResponse = await getClient().provider.list({ query: { directory } })
|
|
1822
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
1823
|
+
const model = provider?.models?.[usedModel || '']
|
|
1824
|
+
if (model?.limit?.context) {
|
|
1825
|
+
const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
|
|
1826
|
+
contextInfo = ` ⋅ ${percentage}%`
|
|
1792
1827
|
}
|
|
1828
|
+
} catch (e) {
|
|
1829
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', e)
|
|
1793
1830
|
}
|
|
1831
|
+
|
|
1794
1832
|
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`)
|
|
1795
|
-
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${
|
|
1833
|
+
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`)
|
|
1796
1834
|
} else {
|
|
1797
1835
|
sessionLogger.log(
|
|
1798
1836
|
`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
|
|
@@ -1802,10 +1840,22 @@ async function handleOpencodeSession({
|
|
|
1802
1840
|
}
|
|
1803
1841
|
|
|
1804
1842
|
try {
|
|
1805
|
-
// Start the event handler
|
|
1806
1843
|
const eventHandlerPromise = eventHandler()
|
|
1807
1844
|
|
|
1808
|
-
|
|
1845
|
+
if (abortController.signal.aborted) {
|
|
1846
|
+
sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`)
|
|
1847
|
+
return
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
if (originalMessage) {
|
|
1851
|
+
try {
|
|
1852
|
+
await originalMessage.react('⏳')
|
|
1853
|
+
} catch (e) {
|
|
1854
|
+
discordLogger.log(`Could not add processing reaction:`, e)
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
let response: { data?: unknown; error?: unknown; response: Response }
|
|
1809
1859
|
if (parsedCommand?.isCommand) {
|
|
1810
1860
|
sessionLogger.log(
|
|
1811
1861
|
`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`,
|
|
@@ -1838,18 +1888,33 @@ async function handleOpencodeSession({
|
|
|
1838
1888
|
signal: abortController.signal,
|
|
1839
1889
|
})
|
|
1840
1890
|
}
|
|
1891
|
+
|
|
1892
|
+
if (response.error) {
|
|
1893
|
+
const errorMessage = (() => {
|
|
1894
|
+
const err = response.error
|
|
1895
|
+
if (err && typeof err === 'object') {
|
|
1896
|
+
if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
|
|
1897
|
+
return String(err.data.message)
|
|
1898
|
+
}
|
|
1899
|
+
if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
|
|
1900
|
+
return JSON.stringify(err.errors)
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
return JSON.stringify(err)
|
|
1904
|
+
})()
|
|
1905
|
+
throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1841
1908
|
abortController.abort('finished')
|
|
1842
1909
|
|
|
1843
1910
|
sessionLogger.log(`Successfully sent prompt, got response`)
|
|
1844
1911
|
|
|
1845
|
-
// Update reaction to success
|
|
1846
1912
|
if (originalMessage) {
|
|
1847
1913
|
try {
|
|
1848
1914
|
await originalMessage.reactions.removeAll()
|
|
1849
1915
|
await originalMessage.react('✅')
|
|
1850
|
-
discordLogger.log(`Added success reaction to message`)
|
|
1851
1916
|
} catch (e) {
|
|
1852
|
-
discordLogger.log(`Could not update
|
|
1917
|
+
discordLogger.log(`Could not update reactions:`, e)
|
|
1853
1918
|
}
|
|
1854
1919
|
}
|
|
1855
1920
|
|
|
@@ -2091,14 +2156,18 @@ export async function startDiscordBot({
|
|
|
2091
2156
|
messageContent = transcription
|
|
2092
2157
|
}
|
|
2093
2158
|
|
|
2094
|
-
const
|
|
2159
|
+
const fileAttachments = getFileAttachments(message)
|
|
2160
|
+
const textAttachmentsContent = await getTextAttachments(message)
|
|
2161
|
+
const promptWithAttachments = textAttachmentsContent
|
|
2162
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
2163
|
+
: messageContent
|
|
2095
2164
|
const parsedCommand = parseSlashCommand(messageContent)
|
|
2096
2165
|
await handleOpencodeSession({
|
|
2097
|
-
prompt:
|
|
2166
|
+
prompt: promptWithAttachments,
|
|
2098
2167
|
thread,
|
|
2099
2168
|
projectDirectory,
|
|
2100
2169
|
originalMessage: message,
|
|
2101
|
-
images,
|
|
2170
|
+
images: fileAttachments,
|
|
2102
2171
|
parsedCommand,
|
|
2103
2172
|
})
|
|
2104
2173
|
return
|
|
@@ -2188,14 +2257,18 @@ export async function startDiscordBot({
|
|
|
2188
2257
|
messageContent = transcription
|
|
2189
2258
|
}
|
|
2190
2259
|
|
|
2191
|
-
const
|
|
2260
|
+
const fileAttachments = getFileAttachments(message)
|
|
2261
|
+
const textAttachmentsContent = await getTextAttachments(message)
|
|
2262
|
+
const promptWithAttachments = textAttachmentsContent
|
|
2263
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
2264
|
+
: messageContent
|
|
2192
2265
|
const parsedCommand = parseSlashCommand(messageContent)
|
|
2193
2266
|
await handleOpencodeSession({
|
|
2194
|
-
prompt:
|
|
2267
|
+
prompt: promptWithAttachments,
|
|
2195
2268
|
thread,
|
|
2196
2269
|
projectDirectory,
|
|
2197
2270
|
originalMessage: message,
|
|
2198
|
-
images,
|
|
2271
|
+
images: fileAttachments,
|
|
2199
2272
|
parsedCommand,
|
|
2200
2273
|
})
|
|
2201
2274
|
} else {
|
|
@@ -2783,6 +2856,94 @@ export async function startDiscordBot({
|
|
|
2783
2856
|
`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2784
2857
|
)
|
|
2785
2858
|
}
|
|
2859
|
+
} else if (command.commandName === 'add-new-project') {
|
|
2860
|
+
await command.deferReply({ ephemeral: false })
|
|
2861
|
+
|
|
2862
|
+
const projectName = command.options.getString('name', true)
|
|
2863
|
+
const guild = command.guild
|
|
2864
|
+
const channel = command.channel
|
|
2865
|
+
|
|
2866
|
+
if (!guild) {
|
|
2867
|
+
await command.editReply('This command can only be used in a guild')
|
|
2868
|
+
return
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
2872
|
+
await command.editReply('This command can only be used in a text channel')
|
|
2873
|
+
return
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
const sanitizedName = projectName
|
|
2877
|
+
.toLowerCase()
|
|
2878
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
2879
|
+
.replace(/-+/g, '-')
|
|
2880
|
+
.replace(/^-|-$/g, '')
|
|
2881
|
+
.slice(0, 100)
|
|
2882
|
+
|
|
2883
|
+
if (!sanitizedName) {
|
|
2884
|
+
await command.editReply('Invalid project name')
|
|
2885
|
+
return
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
const kimakiDir = path.join(os.homedir(), 'kimaki')
|
|
2889
|
+
const projectDirectory = path.join(kimakiDir, sanitizedName)
|
|
2890
|
+
|
|
2891
|
+
try {
|
|
2892
|
+
if (!fs.existsSync(kimakiDir)) {
|
|
2893
|
+
fs.mkdirSync(kimakiDir, { recursive: true })
|
|
2894
|
+
discordLogger.log(`Created kimaki directory: ${kimakiDir}`)
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
if (fs.existsSync(projectDirectory)) {
|
|
2898
|
+
await command.editReply(`Project directory already exists: ${projectDirectory}`)
|
|
2899
|
+
return
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
fs.mkdirSync(projectDirectory, { recursive: true })
|
|
2903
|
+
discordLogger.log(`Created project directory: ${projectDirectory}`)
|
|
2904
|
+
|
|
2905
|
+
const { execSync } = await import('node:child_process')
|
|
2906
|
+
execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
|
|
2907
|
+
discordLogger.log(`Initialized git in: ${projectDirectory}`)
|
|
2908
|
+
|
|
2909
|
+
const { textChannelId, voiceChannelId, channelName } =
|
|
2910
|
+
await createProjectChannels({
|
|
2911
|
+
guild,
|
|
2912
|
+
projectDirectory,
|
|
2913
|
+
appId: currentAppId!,
|
|
2914
|
+
})
|
|
2915
|
+
|
|
2916
|
+
const textChannel = await guild.channels.fetch(textChannelId) as TextChannel
|
|
2917
|
+
|
|
2918
|
+
await command.editReply(
|
|
2919
|
+
`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`,
|
|
2920
|
+
)
|
|
2921
|
+
|
|
2922
|
+
const starterMessage = await textChannel.send({
|
|
2923
|
+
content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
|
|
2924
|
+
})
|
|
2925
|
+
|
|
2926
|
+
const thread = await starterMessage.startThread({
|
|
2927
|
+
name: `Init: ${sanitizedName}`,
|
|
2928
|
+
autoArchiveDuration: 1440,
|
|
2929
|
+
reason: 'New project session',
|
|
2930
|
+
})
|
|
2931
|
+
|
|
2932
|
+
await handleOpencodeSession({
|
|
2933
|
+
prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
|
|
2934
|
+
thread,
|
|
2935
|
+
projectDirectory,
|
|
2936
|
+
})
|
|
2937
|
+
|
|
2938
|
+
discordLogger.log(
|
|
2939
|
+
`Created new project ${channelName} at ${projectDirectory}`,
|
|
2940
|
+
)
|
|
2941
|
+
} catch (error) {
|
|
2942
|
+
voiceLogger.error('[ADD-NEW-PROJECT] Error:', error)
|
|
2943
|
+
await command.editReply(
|
|
2944
|
+
`Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2945
|
+
)
|
|
2946
|
+
}
|
|
2786
2947
|
} else if (
|
|
2787
2948
|
command.commandName === 'accept' ||
|
|
2788
2949
|
command.commandName === 'accept-always'
|
|
@@ -2976,6 +3137,79 @@ export async function startDiscordBot({
|
|
|
2976
3137
|
ephemeral: true,
|
|
2977
3138
|
})
|
|
2978
3139
|
}
|
|
3140
|
+
} else if (command.commandName === 'share') {
|
|
3141
|
+
const channel = command.channel
|
|
3142
|
+
|
|
3143
|
+
if (!channel) {
|
|
3144
|
+
await command.reply({
|
|
3145
|
+
content: 'This command can only be used in a channel',
|
|
3146
|
+
ephemeral: true,
|
|
3147
|
+
})
|
|
3148
|
+
return
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
const isThread = [
|
|
3152
|
+
ChannelType.PublicThread,
|
|
3153
|
+
ChannelType.PrivateThread,
|
|
3154
|
+
ChannelType.AnnouncementThread,
|
|
3155
|
+
].includes(channel.type)
|
|
3156
|
+
|
|
3157
|
+
if (!isThread) {
|
|
3158
|
+
await command.reply({
|
|
3159
|
+
content: 'This command can only be used in a thread with an active session',
|
|
3160
|
+
ephemeral: true,
|
|
3161
|
+
})
|
|
3162
|
+
return
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
const textChannel = resolveTextChannel(channel as ThreadChannel)
|
|
3166
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
3167
|
+
|
|
3168
|
+
if (!directory) {
|
|
3169
|
+
await command.reply({
|
|
3170
|
+
content: 'Could not determine project directory for this channel',
|
|
3171
|
+
ephemeral: true,
|
|
3172
|
+
})
|
|
3173
|
+
return
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
const row = getDatabase()
|
|
3177
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
3178
|
+
.get(channel.id) as { session_id: string } | undefined
|
|
3179
|
+
|
|
3180
|
+
if (!row?.session_id) {
|
|
3181
|
+
await command.reply({
|
|
3182
|
+
content: 'No active session in this thread',
|
|
3183
|
+
ephemeral: true,
|
|
3184
|
+
})
|
|
3185
|
+
return
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
const sessionId = row.session_id
|
|
3189
|
+
|
|
3190
|
+
try {
|
|
3191
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
3192
|
+
const response = await getClient().session.share({
|
|
3193
|
+
path: { id: sessionId },
|
|
3194
|
+
})
|
|
3195
|
+
|
|
3196
|
+
if (!response.data?.share?.url) {
|
|
3197
|
+
await command.reply({
|
|
3198
|
+
content: 'Failed to generate share URL',
|
|
3199
|
+
ephemeral: true,
|
|
3200
|
+
})
|
|
3201
|
+
return
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
await command.reply(`🔗 **Session shared:** ${response.data.share.url}`)
|
|
3205
|
+
sessionLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`)
|
|
3206
|
+
} catch (error) {
|
|
3207
|
+
voiceLogger.error('[SHARE] Error:', error)
|
|
3208
|
+
await command.reply({
|
|
3209
|
+
content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
3210
|
+
ephemeral: true,
|
|
3211
|
+
})
|
|
3212
|
+
}
|
|
2979
3213
|
}
|
|
2980
3214
|
}
|
|
2981
3215
|
} catch (error) {
|
|
@@ -3261,7 +3495,7 @@ export async function startDiscordBot({
|
|
|
3261
3495
|
|
|
3262
3496
|
await discordClient.login(token)
|
|
3263
3497
|
|
|
3264
|
-
const handleShutdown = async (signal: string) => {
|
|
3498
|
+
const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
|
|
3265
3499
|
discordLogger.log(`Received ${signal}, cleaning up...`)
|
|
3266
3500
|
|
|
3267
3501
|
// Prevent multiple shutdown calls
|
|
@@ -3310,11 +3544,15 @@ export async function startDiscordBot({
|
|
|
3310
3544
|
discordLogger.log('Destroying Discord client...')
|
|
3311
3545
|
discordClient.destroy()
|
|
3312
3546
|
|
|
3313
|
-
discordLogger.log('Cleanup complete
|
|
3314
|
-
|
|
3547
|
+
discordLogger.log('Cleanup complete.')
|
|
3548
|
+
if (!skipExit) {
|
|
3549
|
+
process.exit(0)
|
|
3550
|
+
}
|
|
3315
3551
|
} catch (error) {
|
|
3316
3552
|
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
|
|
3317
|
-
|
|
3553
|
+
if (!skipExit) {
|
|
3554
|
+
process.exit(1)
|
|
3555
|
+
}
|
|
3318
3556
|
}
|
|
3319
3557
|
}
|
|
3320
3558
|
|
|
@@ -3337,6 +3575,23 @@ export async function startDiscordBot({
|
|
|
3337
3575
|
}
|
|
3338
3576
|
})
|
|
3339
3577
|
|
|
3578
|
+
process.on('SIGUSR2', async () => {
|
|
3579
|
+
discordLogger.log('Received SIGUSR2, restarting after cleanup...')
|
|
3580
|
+
try {
|
|
3581
|
+
await handleShutdown('SIGUSR2', { skipExit: true })
|
|
3582
|
+
} catch (error) {
|
|
3583
|
+
voiceLogger.error('[SIGUSR2] Error during shutdown:', error)
|
|
3584
|
+
}
|
|
3585
|
+
const { spawn } = await import('node:child_process')
|
|
3586
|
+
spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
|
|
3587
|
+
stdio: 'inherit',
|
|
3588
|
+
detached: true,
|
|
3589
|
+
cwd: process.cwd(),
|
|
3590
|
+
env: process.env,
|
|
3591
|
+
}).unref()
|
|
3592
|
+
process.exit(0)
|
|
3593
|
+
})
|
|
3594
|
+
|
|
3340
3595
|
// Prevent unhandled promise rejections from crashing the process during shutdown
|
|
3341
3596
|
process.on('unhandledRejection', (reason, promise) => {
|
|
3342
3597
|
if ((global as any).shuttingDown) {
|