kimaki 0.4.17 → 0.4.19
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 +266 -73
- 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 +319 -75
- 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,11 +80,13 @@ 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
|
|
86
88
|
|
|
87
|
-
To upload files (images, screenshots,
|
|
89
|
+
To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
|
|
88
90
|
|
|
89
91
|
npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
|
|
90
92
|
|
|
@@ -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),
|
|
883
903
|
)
|
|
884
904
|
|
|
885
|
-
|
|
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
|
+
},
|
|
936
|
+
)
|
|
937
|
+
|
|
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,16 +1360,16 @@ 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')
|
|
@@ -1341,19 +1377,14 @@ function formatPart(part: Part): string {
|
|
|
1341
1377
|
if (isSingleLine && command.length <= 120 && !hasBackticks) {
|
|
1342
1378
|
toolTitle = `\`${command}\``
|
|
1343
1379
|
} else {
|
|
1344
|
-
toolTitle =
|
|
1380
|
+
toolTitle = stateTitle ? `*${stateTitle}*` : ''
|
|
1345
1381
|
}
|
|
1346
|
-
} else if (
|
|
1347
|
-
toolTitle = `*${
|
|
1382
|
+
} else if (stateTitle) {
|
|
1383
|
+
toolTitle = `*${stateTitle}*`
|
|
1348
1384
|
}
|
|
1349
1385
|
|
|
1350
|
-
const icon = part.state.status === '
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
if (outputToDisplay) {
|
|
1354
|
-
return title + '\n\n' + outputToDisplay
|
|
1355
|
-
}
|
|
1356
|
-
return title
|
|
1386
|
+
const icon = part.state.status === 'error' ? '⨯' : '◼︎'
|
|
1387
|
+
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`
|
|
1357
1388
|
}
|
|
1358
1389
|
|
|
1359
1390
|
discordLogger.warn('Unknown part type:', part)
|
|
@@ -1444,11 +1475,12 @@ async function handleOpencodeSession({
|
|
|
1444
1475
|
}
|
|
1445
1476
|
|
|
1446
1477
|
if (!session) {
|
|
1478
|
+
const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80)
|
|
1447
1479
|
voiceLogger.log(
|
|
1448
|
-
`[SESSION] Creating new session with title: "${
|
|
1480
|
+
`[SESSION] Creating new session with title: "${sessionTitle}"`,
|
|
1449
1481
|
)
|
|
1450
1482
|
const sessionResponse = await getClient().session.create({
|
|
1451
|
-
body: { title:
|
|
1483
|
+
body: { title: sessionTitle },
|
|
1452
1484
|
})
|
|
1453
1485
|
session = sessionResponse.data
|
|
1454
1486
|
sessionLogger.log(`Created new session ${session?.id}`)
|
|
@@ -1514,7 +1546,7 @@ async function handleOpencodeSession({
|
|
|
1514
1546
|
let stopTyping: (() => void) | null = null
|
|
1515
1547
|
let usedModel: string | undefined
|
|
1516
1548
|
let usedProviderID: string | undefined
|
|
1517
|
-
let
|
|
1549
|
+
let tokensUsedInSession = 0
|
|
1518
1550
|
|
|
1519
1551
|
const sendPartMessage = async (part: Part) => {
|
|
1520
1552
|
const content = formatPart(part) + '\n\n'
|
|
@@ -1603,22 +1635,27 @@ async function handleOpencodeSession({
|
|
|
1603
1635
|
if (event.type === 'message.updated') {
|
|
1604
1636
|
const msg = event.properties.info
|
|
1605
1637
|
|
|
1638
|
+
|
|
1639
|
+
|
|
1606
1640
|
if (msg.sessionID !== session.id) {
|
|
1607
1641
|
continue
|
|
1608
1642
|
}
|
|
1609
1643
|
|
|
1610
1644
|
// Track assistant message ID
|
|
1611
1645
|
if (msg.role === 'assistant') {
|
|
1646
|
+
const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
|
1647
|
+
if (newTokensTotal > 0) {
|
|
1648
|
+
tokensUsedInSession = newTokensTotal
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1612
1651
|
assistantMessageId = msg.id
|
|
1613
1652
|
usedModel = msg.modelID
|
|
1614
1653
|
usedProviderID = msg.providerID
|
|
1615
|
-
if (msg.tokens.input > 0) {
|
|
1616
|
-
inputTokens = msg.tokens.input
|
|
1617
|
-
}
|
|
1618
1654
|
}
|
|
1619
1655
|
} else if (event.type === 'message.part.updated') {
|
|
1620
1656
|
const part = event.properties.part
|
|
1621
1657
|
|
|
1658
|
+
|
|
1622
1659
|
if (part.sessionID !== session.id) {
|
|
1623
1660
|
continue
|
|
1624
1661
|
}
|
|
@@ -1644,13 +1681,14 @@ async function handleOpencodeSession({
|
|
|
1644
1681
|
stopTyping = startTyping(thread)
|
|
1645
1682
|
}
|
|
1646
1683
|
|
|
1684
|
+
// Send tool parts immediately when they start running
|
|
1685
|
+
if (part.type === 'tool' && part.state.status === 'running') {
|
|
1686
|
+
await sendPartMessage(part)
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1647
1689
|
// Check if this is a step-finish part
|
|
1648
1690
|
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
|
-
}
|
|
1691
|
+
|
|
1654
1692
|
// Send all parts accumulated so far to Discord
|
|
1655
1693
|
for (const p of currentParts) {
|
|
1656
1694
|
// Skip step-start and step-finish parts as they have no visual content
|
|
@@ -1778,21 +1816,22 @@ async function handleOpencodeSession({
|
|
|
1778
1816
|
const attachCommand = port ? ` ⋅ ${session.id}` : ''
|
|
1779
1817
|
const modelInfo = usedModel ? ` ⋅ ${usedModel}` : ''
|
|
1780
1818
|
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)
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
try {
|
|
1822
|
+
const providersResponse = await getClient().provider.list({ query: { directory } })
|
|
1823
|
+
const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID)
|
|
1824
|
+
const model = provider?.models?.[usedModel || '']
|
|
1825
|
+
if (model?.limit?.context) {
|
|
1826
|
+
const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100)
|
|
1827
|
+
contextInfo = ` ⋅ ${percentage}%`
|
|
1792
1828
|
}
|
|
1829
|
+
} catch (e) {
|
|
1830
|
+
sessionLogger.error('Failed to fetch provider info for context percentage:', e)
|
|
1793
1831
|
}
|
|
1832
|
+
|
|
1794
1833
|
await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`)
|
|
1795
|
-
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${
|
|
1834
|
+
sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`)
|
|
1796
1835
|
} else {
|
|
1797
1836
|
sessionLogger.log(
|
|
1798
1837
|
`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`,
|
|
@@ -1805,7 +1844,7 @@ async function handleOpencodeSession({
|
|
|
1805
1844
|
// Start the event handler
|
|
1806
1845
|
const eventHandlerPromise = eventHandler()
|
|
1807
1846
|
|
|
1808
|
-
let response: { data?: unknown }
|
|
1847
|
+
let response: { data?: unknown; error?: unknown; response: Response }
|
|
1809
1848
|
if (parsedCommand?.isCommand) {
|
|
1810
1849
|
sessionLogger.log(
|
|
1811
1850
|
`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`,
|
|
@@ -1838,18 +1877,33 @@ async function handleOpencodeSession({
|
|
|
1838
1877
|
signal: abortController.signal,
|
|
1839
1878
|
})
|
|
1840
1879
|
}
|
|
1880
|
+
|
|
1881
|
+
if (response.error) {
|
|
1882
|
+
const errorMessage = (() => {
|
|
1883
|
+
const err = response.error
|
|
1884
|
+
if (err && typeof err === 'object') {
|
|
1885
|
+
if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
|
|
1886
|
+
return String(err.data.message)
|
|
1887
|
+
}
|
|
1888
|
+
if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
|
|
1889
|
+
return JSON.stringify(err.errors)
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
return JSON.stringify(err)
|
|
1893
|
+
})()
|
|
1894
|
+
throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`)
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1841
1897
|
abortController.abort('finished')
|
|
1842
1898
|
|
|
1843
1899
|
sessionLogger.log(`Successfully sent prompt, got response`)
|
|
1844
1900
|
|
|
1845
|
-
// Update reaction to success
|
|
1846
1901
|
if (originalMessage) {
|
|
1847
1902
|
try {
|
|
1848
1903
|
await originalMessage.reactions.removeAll()
|
|
1849
1904
|
await originalMessage.react('✅')
|
|
1850
|
-
discordLogger.log(`Added success reaction to message`)
|
|
1851
1905
|
} catch (e) {
|
|
1852
|
-
discordLogger.log(`Could not update
|
|
1906
|
+
discordLogger.log(`Could not update reactions:`, e)
|
|
1853
1907
|
}
|
|
1854
1908
|
}
|
|
1855
1909
|
|
|
@@ -2091,14 +2145,18 @@ export async function startDiscordBot({
|
|
|
2091
2145
|
messageContent = transcription
|
|
2092
2146
|
}
|
|
2093
2147
|
|
|
2094
|
-
const
|
|
2148
|
+
const fileAttachments = getFileAttachments(message)
|
|
2149
|
+
const textAttachmentsContent = await getTextAttachments(message)
|
|
2150
|
+
const promptWithAttachments = textAttachmentsContent
|
|
2151
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
2152
|
+
: messageContent
|
|
2095
2153
|
const parsedCommand = parseSlashCommand(messageContent)
|
|
2096
2154
|
await handleOpencodeSession({
|
|
2097
|
-
prompt:
|
|
2155
|
+
prompt: promptWithAttachments,
|
|
2098
2156
|
thread,
|
|
2099
2157
|
projectDirectory,
|
|
2100
2158
|
originalMessage: message,
|
|
2101
|
-
images,
|
|
2159
|
+
images: fileAttachments,
|
|
2102
2160
|
parsedCommand,
|
|
2103
2161
|
})
|
|
2104
2162
|
return
|
|
@@ -2188,14 +2246,18 @@ export async function startDiscordBot({
|
|
|
2188
2246
|
messageContent = transcription
|
|
2189
2247
|
}
|
|
2190
2248
|
|
|
2191
|
-
const
|
|
2249
|
+
const fileAttachments = getFileAttachments(message)
|
|
2250
|
+
const textAttachmentsContent = await getTextAttachments(message)
|
|
2251
|
+
const promptWithAttachments = textAttachmentsContent
|
|
2252
|
+
? `${messageContent}\n\n${textAttachmentsContent}`
|
|
2253
|
+
: messageContent
|
|
2192
2254
|
const parsedCommand = parseSlashCommand(messageContent)
|
|
2193
2255
|
await handleOpencodeSession({
|
|
2194
|
-
prompt:
|
|
2256
|
+
prompt: promptWithAttachments,
|
|
2195
2257
|
thread,
|
|
2196
2258
|
projectDirectory,
|
|
2197
2259
|
originalMessage: message,
|
|
2198
|
-
images,
|
|
2260
|
+
images: fileAttachments,
|
|
2199
2261
|
parsedCommand,
|
|
2200
2262
|
})
|
|
2201
2263
|
} else {
|
|
@@ -2783,6 +2845,94 @@ export async function startDiscordBot({
|
|
|
2783
2845
|
`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2784
2846
|
)
|
|
2785
2847
|
}
|
|
2848
|
+
} else if (command.commandName === 'add-new-project') {
|
|
2849
|
+
await command.deferReply({ ephemeral: false })
|
|
2850
|
+
|
|
2851
|
+
const projectName = command.options.getString('name', true)
|
|
2852
|
+
const guild = command.guild
|
|
2853
|
+
const channel = command.channel
|
|
2854
|
+
|
|
2855
|
+
if (!guild) {
|
|
2856
|
+
await command.editReply('This command can only be used in a guild')
|
|
2857
|
+
return
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
2861
|
+
await command.editReply('This command can only be used in a text channel')
|
|
2862
|
+
return
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
const sanitizedName = projectName
|
|
2866
|
+
.toLowerCase()
|
|
2867
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
2868
|
+
.replace(/-+/g, '-')
|
|
2869
|
+
.replace(/^-|-$/g, '')
|
|
2870
|
+
.slice(0, 100)
|
|
2871
|
+
|
|
2872
|
+
if (!sanitizedName) {
|
|
2873
|
+
await command.editReply('Invalid project name')
|
|
2874
|
+
return
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
const kimakiDir = path.join(os.homedir(), 'kimaki')
|
|
2878
|
+
const projectDirectory = path.join(kimakiDir, sanitizedName)
|
|
2879
|
+
|
|
2880
|
+
try {
|
|
2881
|
+
if (!fs.existsSync(kimakiDir)) {
|
|
2882
|
+
fs.mkdirSync(kimakiDir, { recursive: true })
|
|
2883
|
+
discordLogger.log(`Created kimaki directory: ${kimakiDir}`)
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
if (fs.existsSync(projectDirectory)) {
|
|
2887
|
+
await command.editReply(`Project directory already exists: ${projectDirectory}`)
|
|
2888
|
+
return
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
fs.mkdirSync(projectDirectory, { recursive: true })
|
|
2892
|
+
discordLogger.log(`Created project directory: ${projectDirectory}`)
|
|
2893
|
+
|
|
2894
|
+
const { execSync } = await import('node:child_process')
|
|
2895
|
+
execSync('git init', { cwd: projectDirectory, stdio: 'pipe' })
|
|
2896
|
+
discordLogger.log(`Initialized git in: ${projectDirectory}`)
|
|
2897
|
+
|
|
2898
|
+
const { textChannelId, voiceChannelId, channelName } =
|
|
2899
|
+
await createProjectChannels({
|
|
2900
|
+
guild,
|
|
2901
|
+
projectDirectory,
|
|
2902
|
+
appId: currentAppId!,
|
|
2903
|
+
})
|
|
2904
|
+
|
|
2905
|
+
const textChannel = await guild.channels.fetch(textChannelId) as TextChannel
|
|
2906
|
+
|
|
2907
|
+
await command.editReply(
|
|
2908
|
+
`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`,
|
|
2909
|
+
)
|
|
2910
|
+
|
|
2911
|
+
const starterMessage = await textChannel.send({
|
|
2912
|
+
content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
|
|
2913
|
+
})
|
|
2914
|
+
|
|
2915
|
+
const thread = await starterMessage.startThread({
|
|
2916
|
+
name: `Init: ${sanitizedName}`,
|
|
2917
|
+
autoArchiveDuration: 1440,
|
|
2918
|
+
reason: 'New project session',
|
|
2919
|
+
})
|
|
2920
|
+
|
|
2921
|
+
await handleOpencodeSession({
|
|
2922
|
+
prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
|
|
2923
|
+
thread,
|
|
2924
|
+
projectDirectory,
|
|
2925
|
+
})
|
|
2926
|
+
|
|
2927
|
+
discordLogger.log(
|
|
2928
|
+
`Created new project ${channelName} at ${projectDirectory}`,
|
|
2929
|
+
)
|
|
2930
|
+
} catch (error) {
|
|
2931
|
+
voiceLogger.error('[ADD-NEW-PROJECT] Error:', error)
|
|
2932
|
+
await command.editReply(
|
|
2933
|
+
`Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
2934
|
+
)
|
|
2935
|
+
}
|
|
2786
2936
|
} else if (
|
|
2787
2937
|
command.commandName === 'accept' ||
|
|
2788
2938
|
command.commandName === 'accept-always'
|
|
@@ -2976,6 +3126,79 @@ export async function startDiscordBot({
|
|
|
2976
3126
|
ephemeral: true,
|
|
2977
3127
|
})
|
|
2978
3128
|
}
|
|
3129
|
+
} else if (command.commandName === 'share') {
|
|
3130
|
+
const channel = command.channel
|
|
3131
|
+
|
|
3132
|
+
if (!channel) {
|
|
3133
|
+
await command.reply({
|
|
3134
|
+
content: 'This command can only be used in a channel',
|
|
3135
|
+
ephemeral: true,
|
|
3136
|
+
})
|
|
3137
|
+
return
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
const isThread = [
|
|
3141
|
+
ChannelType.PublicThread,
|
|
3142
|
+
ChannelType.PrivateThread,
|
|
3143
|
+
ChannelType.AnnouncementThread,
|
|
3144
|
+
].includes(channel.type)
|
|
3145
|
+
|
|
3146
|
+
if (!isThread) {
|
|
3147
|
+
await command.reply({
|
|
3148
|
+
content: 'This command can only be used in a thread with an active session',
|
|
3149
|
+
ephemeral: true,
|
|
3150
|
+
})
|
|
3151
|
+
return
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
const textChannel = resolveTextChannel(channel as ThreadChannel)
|
|
3155
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
3156
|
+
|
|
3157
|
+
if (!directory) {
|
|
3158
|
+
await command.reply({
|
|
3159
|
+
content: 'Could not determine project directory for this channel',
|
|
3160
|
+
ephemeral: true,
|
|
3161
|
+
})
|
|
3162
|
+
return
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
const row = getDatabase()
|
|
3166
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
3167
|
+
.get(channel.id) as { session_id: string } | undefined
|
|
3168
|
+
|
|
3169
|
+
if (!row?.session_id) {
|
|
3170
|
+
await command.reply({
|
|
3171
|
+
content: 'No active session in this thread',
|
|
3172
|
+
ephemeral: true,
|
|
3173
|
+
})
|
|
3174
|
+
return
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
const sessionId = row.session_id
|
|
3178
|
+
|
|
3179
|
+
try {
|
|
3180
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
3181
|
+
const response = await getClient().session.share({
|
|
3182
|
+
path: { id: sessionId },
|
|
3183
|
+
})
|
|
3184
|
+
|
|
3185
|
+
if (!response.data?.share?.url) {
|
|
3186
|
+
await command.reply({
|
|
3187
|
+
content: 'Failed to generate share URL',
|
|
3188
|
+
ephemeral: true,
|
|
3189
|
+
})
|
|
3190
|
+
return
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
await command.reply(`🔗 **Session shared:** ${response.data.share.url}`)
|
|
3194
|
+
sessionLogger.log(`Session ${sessionId} shared: ${response.data.share.url}`)
|
|
3195
|
+
} catch (error) {
|
|
3196
|
+
voiceLogger.error('[SHARE] Error:', error)
|
|
3197
|
+
await command.reply({
|
|
3198
|
+
content: `Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
3199
|
+
ephemeral: true,
|
|
3200
|
+
})
|
|
3201
|
+
}
|
|
2979
3202
|
}
|
|
2980
3203
|
}
|
|
2981
3204
|
} catch (error) {
|
|
@@ -3261,7 +3484,7 @@ export async function startDiscordBot({
|
|
|
3261
3484
|
|
|
3262
3485
|
await discordClient.login(token)
|
|
3263
3486
|
|
|
3264
|
-
const handleShutdown = async (signal: string) => {
|
|
3487
|
+
const handleShutdown = async (signal: string, { skipExit = false } = {}) => {
|
|
3265
3488
|
discordLogger.log(`Received ${signal}, cleaning up...`)
|
|
3266
3489
|
|
|
3267
3490
|
// Prevent multiple shutdown calls
|
|
@@ -3310,11 +3533,15 @@ export async function startDiscordBot({
|
|
|
3310
3533
|
discordLogger.log('Destroying Discord client...')
|
|
3311
3534
|
discordClient.destroy()
|
|
3312
3535
|
|
|
3313
|
-
discordLogger.log('Cleanup complete
|
|
3314
|
-
|
|
3536
|
+
discordLogger.log('Cleanup complete.')
|
|
3537
|
+
if (!skipExit) {
|
|
3538
|
+
process.exit(0)
|
|
3539
|
+
}
|
|
3315
3540
|
} catch (error) {
|
|
3316
3541
|
voiceLogger.error('[SHUTDOWN] Error during cleanup:', error)
|
|
3317
|
-
|
|
3542
|
+
if (!skipExit) {
|
|
3543
|
+
process.exit(1)
|
|
3544
|
+
}
|
|
3318
3545
|
}
|
|
3319
3546
|
}
|
|
3320
3547
|
|
|
@@ -3337,6 +3564,23 @@ export async function startDiscordBot({
|
|
|
3337
3564
|
}
|
|
3338
3565
|
})
|
|
3339
3566
|
|
|
3567
|
+
process.on('SIGUSR2', async () => {
|
|
3568
|
+
discordLogger.log('Received SIGUSR2, restarting after cleanup...')
|
|
3569
|
+
try {
|
|
3570
|
+
await handleShutdown('SIGUSR2', { skipExit: true })
|
|
3571
|
+
} catch (error) {
|
|
3572
|
+
voiceLogger.error('[SIGUSR2] Error during shutdown:', error)
|
|
3573
|
+
}
|
|
3574
|
+
const { spawn } = await import('node:child_process')
|
|
3575
|
+
spawn(process.argv[0]!, [...process.execArgv, ...process.argv.slice(1)], {
|
|
3576
|
+
stdio: 'inherit',
|
|
3577
|
+
detached: true,
|
|
3578
|
+
cwd: process.cwd(),
|
|
3579
|
+
env: process.env,
|
|
3580
|
+
}).unref()
|
|
3581
|
+
process.exit(0)
|
|
3582
|
+
})
|
|
3583
|
+
|
|
3340
3584
|
// Prevent unhandled promise rejections from crashing the process during shutdown
|
|
3341
3585
|
process.on('unhandledRejection', (reason, promise) => {
|
|
3342
3586
|
if ((global as any).shuttingDown) {
|
package/src/genai-worker.ts
CHANGED
|
@@ -5,9 +5,9 @@ import path from 'node:path'
|
|
|
5
5
|
import { Resampler } from '@purinton/resampler'
|
|
6
6
|
import * as prism from 'prism-media'
|
|
7
7
|
import { startGenAiSession } from './genai.js'
|
|
8
|
+
import type { Session } from '@google/genai'
|
|
8
9
|
import { getTools } from './tools.js'
|
|
9
10
|
import type { WorkerInMessage, WorkerOutMessage } from './worker-types.js'
|
|
10
|
-
import type { Session } from '@google/genai'
|
|
11
11
|
import { createLogger } from './logger.js'
|
|
12
12
|
|
|
13
13
|
if (!parentPort) {
|
package/src/genai.ts
CHANGED
|
@@ -1,12 +0,0 @@
|
|
|
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.
|