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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { createLogger } from './logger.js';
|
|
3
|
+
const xaiLogger = createLogger('XAI');
|
|
4
|
+
export class XaiRealtimeClient {
|
|
5
|
+
ws = null;
|
|
6
|
+
apiKey;
|
|
7
|
+
baseUrl = 'wss://api.x.ai/v1/realtime';
|
|
8
|
+
callbacks;
|
|
9
|
+
isConnected = false;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.apiKey = options.apiKey;
|
|
12
|
+
this.callbacks = options.callbacks || {};
|
|
13
|
+
}
|
|
14
|
+
async connect() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
this.ws = new WebSocket(this.baseUrl, {
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
this.ws.on('open', () => {
|
|
22
|
+
xaiLogger.log('WebSocket connected');
|
|
23
|
+
this.isConnected = true;
|
|
24
|
+
this.callbacks.onOpen?.();
|
|
25
|
+
resolve();
|
|
26
|
+
});
|
|
27
|
+
this.ws.on('message', (data) => {
|
|
28
|
+
try {
|
|
29
|
+
const event = JSON.parse(data.toString());
|
|
30
|
+
this.callbacks.onMessage?.(event);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
xaiLogger.error('Failed to parse message:', error);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
this.ws.on('error', (error) => {
|
|
37
|
+
xaiLogger.error('WebSocket error:', error);
|
|
38
|
+
this.callbacks.onError?.(error);
|
|
39
|
+
if (!this.isConnected) {
|
|
40
|
+
reject(error);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
this.ws.on('close', (code, reason) => {
|
|
44
|
+
xaiLogger.log('WebSocket closed:', code, reason.toString());
|
|
45
|
+
this.isConnected = false;
|
|
46
|
+
this.callbacks.onClose?.({ code, reason: reason.toString() });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
send(event) {
|
|
51
|
+
if (!this.ws || !this.isConnected) {
|
|
52
|
+
throw new Error('WebSocket is not connected');
|
|
53
|
+
}
|
|
54
|
+
this.ws.send(JSON.stringify(event));
|
|
55
|
+
}
|
|
56
|
+
updateSession(session) {
|
|
57
|
+
this.send({ type: 'session.update', session });
|
|
58
|
+
}
|
|
59
|
+
appendInputAudio(base64Audio) {
|
|
60
|
+
this.send({ type: 'input_audio_buffer.append', audio: base64Audio });
|
|
61
|
+
}
|
|
62
|
+
commitInputAudio() {
|
|
63
|
+
this.send({ type: 'input_audio_buffer.commit' });
|
|
64
|
+
}
|
|
65
|
+
clearInputAudio() {
|
|
66
|
+
this.send({ type: 'input_audio_buffer.clear' });
|
|
67
|
+
}
|
|
68
|
+
createConversationItem(item, previousItemId) {
|
|
69
|
+
this.send({
|
|
70
|
+
type: 'conversation.item.create',
|
|
71
|
+
previous_item_id: previousItemId,
|
|
72
|
+
item,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
sendFunctionCallOutput(callId, output) {
|
|
76
|
+
this.createConversationItem({
|
|
77
|
+
type: 'function_call_output',
|
|
78
|
+
call_id: callId,
|
|
79
|
+
output,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
createResponse(modalities) {
|
|
83
|
+
this.send({ type: 'response.create', response: modalities ? { modalities } : undefined });
|
|
84
|
+
}
|
|
85
|
+
close() {
|
|
86
|
+
if (this.ws) {
|
|
87
|
+
this.ws.close();
|
|
88
|
+
this.ws = null;
|
|
89
|
+
this.isConnected = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
get connected() {
|
|
93
|
+
return this.isConnected;
|
|
94
|
+
}
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.20",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx --env-file .env src/cli.ts",
|
|
8
8
|
"prepublishOnly": "pnpm tsc",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"bin.js"
|
|
22
22
|
],
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@opencode-ai/plugin": "^1.0.
|
|
24
|
+
"@opencode-ai/plugin": "^1.0.193",
|
|
25
25
|
"@types/better-sqlite3": "^7.6.13",
|
|
26
26
|
"@types/bun": "latest",
|
|
27
27
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
"tsx": "^4.20.5"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@ai-sdk/google": "^2.0.
|
|
33
|
+
"@ai-sdk/google": "^2.0.47",
|
|
34
34
|
"@clack/prompts": "^0.11.0",
|
|
35
35
|
"@discordjs/opus": "^0.10.0",
|
|
36
36
|
"@discordjs/voice": "^0.19.0",
|
|
37
|
-
"@google/genai": "^1.
|
|
38
|
-
"@opencode-ai/sdk": "^1.0.
|
|
37
|
+
"@google/genai": "^1.34.0",
|
|
38
|
+
"@opencode-ai/sdk": "^1.0.193",
|
|
39
39
|
"@purinton/resampler": "^1.0.4",
|
|
40
40
|
"@snazzah/davey": "^0.1.6",
|
|
41
|
-
"ai": "^5.0.
|
|
41
|
+
"ai": "^5.0.114",
|
|
42
42
|
"better-sqlite3": "^12.3.0",
|
|
43
43
|
"cac": "^6.7.14",
|
|
44
44
|
"date-fns": "^4.1.0",
|
|
@@ -53,6 +53,6 @@
|
|
|
53
53
|
"prism-media": "^1.3.5",
|
|
54
54
|
"string-dedent": "^3.0.2",
|
|
55
55
|
"undici": "^7.16.0",
|
|
56
|
-
"zod": "^4.
|
|
56
|
+
"zod": "^4.2.1"
|
|
57
57
|
}
|
|
58
58
|
}
|
package/src/cli.ts
CHANGED
|
@@ -37,29 +37,48 @@ import {
|
|
|
37
37
|
} from 'discord.js'
|
|
38
38
|
import path from 'node:path'
|
|
39
39
|
import fs from 'node:fs'
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
|
|
41
|
+
|
|
42
42
|
import { createLogger } from './logger.js'
|
|
43
43
|
import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
|
|
44
|
+
import http from 'node:http'
|
|
44
45
|
|
|
45
46
|
const cliLogger = createLogger('CLI')
|
|
46
47
|
const cli = cac('kimaki')
|
|
47
48
|
|
|
48
49
|
process.title = 'kimaki'
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
51
|
+
const LOCK_PORT = 29988
|
|
52
|
+
|
|
53
|
+
async function checkSingleInstance(): Promise<void> {
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetch(`http://127.0.0.1:${LOCK_PORT}`, {
|
|
56
|
+
signal: AbortSignal.timeout(1000),
|
|
57
|
+
})
|
|
58
|
+
if (response.ok) {
|
|
59
|
+
cliLogger.error('Another kimaki instance is already running')
|
|
60
|
+
process.exit(1)
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Connection refused means no instance running, continue
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function startLockServer(): void {
|
|
68
|
+
const server = http.createServer((req, res) => {
|
|
69
|
+
res.writeHead(200)
|
|
70
|
+
res.end('kimaki')
|
|
71
|
+
})
|
|
72
|
+
server.listen(LOCK_PORT, '127.0.0.1')
|
|
73
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
74
|
+
if (err.code === 'EADDRINUSE') {
|
|
75
|
+
cliLogger.error('Another kimaki instance is already running')
|
|
76
|
+
process.exit(1)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
63
82
|
|
|
64
83
|
const EXIT_NO_RESTART = 64
|
|
65
84
|
|
|
@@ -129,6 +148,18 @@ async function registerCommands(token: string, appId: string) {
|
|
|
129
148
|
return option
|
|
130
149
|
})
|
|
131
150
|
.toJSON(),
|
|
151
|
+
new SlashCommandBuilder()
|
|
152
|
+
.setName('add-new-project')
|
|
153
|
+
.setDescription('Create a new project folder, initialize git, and start a session')
|
|
154
|
+
.addStringOption((option) => {
|
|
155
|
+
option
|
|
156
|
+
.setName('name')
|
|
157
|
+
.setDescription('Name for the new project folder')
|
|
158
|
+
.setRequired(true)
|
|
159
|
+
|
|
160
|
+
return option
|
|
161
|
+
})
|
|
162
|
+
.toJSON(),
|
|
132
163
|
new SlashCommandBuilder()
|
|
133
164
|
.setName('accept')
|
|
134
165
|
.setDescription('Accept a pending permission request (this request only)')
|
|
@@ -145,6 +176,10 @@ async function registerCommands(token: string, appId: string) {
|
|
|
145
176
|
.setName('abort')
|
|
146
177
|
.setDescription('Abort the current OpenCode request in this thread')
|
|
147
178
|
.toJSON(),
|
|
179
|
+
new SlashCommandBuilder()
|
|
180
|
+
.setName('share')
|
|
181
|
+
.setDescription('Share the current session as a public URL')
|
|
182
|
+
.toJSON(),
|
|
148
183
|
]
|
|
149
184
|
|
|
150
185
|
const rest = new REST().setToken(token)
|
|
@@ -649,6 +684,8 @@ cli
|
|
|
649
684
|
)
|
|
650
685
|
.action(async (options: { restart?: boolean; addChannels?: boolean }) => {
|
|
651
686
|
try {
|
|
687
|
+
await checkSingleInstance()
|
|
688
|
+
startLockServer()
|
|
652
689
|
await run({
|
|
653
690
|
restart: options.restart,
|
|
654
691
|
addChannels: options.addChannels,
|
|
@@ -662,179 +699,7 @@ cli
|
|
|
662
699
|
}
|
|
663
700
|
})
|
|
664
701
|
|
|
665
|
-
cli
|
|
666
|
-
.command(
|
|
667
|
-
'send-to-discord <sessionId>',
|
|
668
|
-
'Send an OpenCode session to Discord and create a thread for it',
|
|
669
|
-
)
|
|
670
|
-
.option('-d, --directory <dir>', 'Project directory (defaults to current working directory)')
|
|
671
|
-
.action(async (sessionId: string, options: { directory?: string }) => {
|
|
672
|
-
try {
|
|
673
|
-
const directory = options.directory || process.cwd()
|
|
674
|
-
|
|
675
|
-
const db = getDatabase()
|
|
676
|
-
|
|
677
|
-
const botRow = db
|
|
678
|
-
.prepare(
|
|
679
|
-
'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
|
|
680
|
-
)
|
|
681
|
-
.get() as { app_id: string; token: string } | undefined
|
|
682
|
-
|
|
683
|
-
if (!botRow) {
|
|
684
|
-
cliLogger.error('No bot credentials found. Run `kimaki` first to set up the bot.')
|
|
685
|
-
process.exit(EXIT_NO_RESTART)
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
const channelRow = db
|
|
689
|
-
.prepare(
|
|
690
|
-
'SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?',
|
|
691
|
-
)
|
|
692
|
-
.get(directory, 'text') as { channel_id: string } | undefined
|
|
693
|
-
|
|
694
|
-
if (!channelRow) {
|
|
695
|
-
cliLogger.error(
|
|
696
|
-
`No Discord channel found for directory: ${directory}\n` +
|
|
697
|
-
'Run `kimaki --add-channels` to create a channel for this project.',
|
|
698
|
-
)
|
|
699
|
-
process.exit(EXIT_NO_RESTART)
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
const s = spinner()
|
|
703
|
-
s.start('Connecting to Discord...')
|
|
704
|
-
|
|
705
|
-
const discordClient = await createDiscordClient()
|
|
706
702
|
|
|
707
|
-
await new Promise<void>((resolve, reject) => {
|
|
708
|
-
discordClient.once(Events.ClientReady, () => {
|
|
709
|
-
resolve()
|
|
710
|
-
})
|
|
711
|
-
discordClient.once(Events.Error, reject)
|
|
712
|
-
discordClient.login(botRow.token).catch(reject)
|
|
713
|
-
})
|
|
714
|
-
|
|
715
|
-
s.stop('Connected to Discord!')
|
|
716
|
-
|
|
717
|
-
const channel = await discordClient.channels.fetch(channelRow.channel_id)
|
|
718
|
-
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
719
|
-
cliLogger.error('Could not find the text channel or it is not a text channel')
|
|
720
|
-
discordClient.destroy()
|
|
721
|
-
process.exit(EXIT_NO_RESTART)
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
const textChannel = channel as import('discord.js').TextChannel
|
|
725
|
-
|
|
726
|
-
s.start('Fetching session from OpenCode...')
|
|
727
|
-
|
|
728
|
-
const getClient = await initializeOpencodeForDirectory(directory)
|
|
729
|
-
const sessionResponse = await getClient().session.get({
|
|
730
|
-
path: { id: sessionId },
|
|
731
|
-
})
|
|
732
|
-
|
|
733
|
-
if (!sessionResponse.data) {
|
|
734
|
-
s.stop('Session not found')
|
|
735
|
-
discordClient.destroy()
|
|
736
|
-
process.exit(EXIT_NO_RESTART)
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
const session = sessionResponse.data
|
|
740
|
-
s.stop(`Found session: ${session.title}`)
|
|
741
|
-
|
|
742
|
-
s.start('Creating Discord thread...')
|
|
743
|
-
|
|
744
|
-
const thread = await textChannel.threads.create({
|
|
745
|
-
name: `Resume: ${session.title}`.slice(0, 100),
|
|
746
|
-
autoArchiveDuration: 1440,
|
|
747
|
-
reason: `Resuming session ${sessionId} from CLI`,
|
|
748
|
-
})
|
|
749
|
-
|
|
750
|
-
db.prepare(
|
|
751
|
-
'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)',
|
|
752
|
-
).run(thread.id, sessionId)
|
|
753
|
-
|
|
754
|
-
s.stop('Created Discord thread!')
|
|
755
|
-
|
|
756
|
-
s.start('Loading session messages...')
|
|
757
|
-
|
|
758
|
-
const messagesResponse = await getClient().session.messages({
|
|
759
|
-
path: { id: sessionId },
|
|
760
|
-
})
|
|
761
|
-
|
|
762
|
-
if (!messagesResponse.data) {
|
|
763
|
-
s.stop('Failed to fetch session messages')
|
|
764
|
-
discordClient.destroy()
|
|
765
|
-
process.exit(EXIT_NO_RESTART)
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
const messages = messagesResponse.data
|
|
769
|
-
|
|
770
|
-
await thread.send(
|
|
771
|
-
`š **Resumed session:** ${session.title}\nš
**Created:** ${new Date(session.time.created).toLocaleString()}\n\n*Loading ${messages.length} messages...*`,
|
|
772
|
-
)
|
|
773
|
-
|
|
774
|
-
let messageCount = 0
|
|
775
|
-
for (const message of messages) {
|
|
776
|
-
if (message.info.role === 'user') {
|
|
777
|
-
const userParts = message.parts.filter(
|
|
778
|
-
(p) => p.type === 'text' && !p.synthetic,
|
|
779
|
-
)
|
|
780
|
-
const userTexts = userParts
|
|
781
|
-
.map((p) => {
|
|
782
|
-
if (p.type === 'text') {
|
|
783
|
-
return p.text
|
|
784
|
-
}
|
|
785
|
-
return ''
|
|
786
|
-
})
|
|
787
|
-
.filter((t) => t.trim())
|
|
788
|
-
|
|
789
|
-
const userText = userTexts.join('\n\n')
|
|
790
|
-
if (userText) {
|
|
791
|
-
const truncated = userText.length > 1900 ? userText.slice(0, 1900) + 'ā¦' : userText
|
|
792
|
-
await thread.send(`**User:**\n${truncated}`)
|
|
793
|
-
}
|
|
794
|
-
} else if (message.info.role === 'assistant') {
|
|
795
|
-
const textParts = message.parts.filter((p) => p.type === 'text')
|
|
796
|
-
const texts = textParts
|
|
797
|
-
.map((p) => {
|
|
798
|
-
if (p.type === 'text') {
|
|
799
|
-
return p.text
|
|
800
|
-
}
|
|
801
|
-
return ''
|
|
802
|
-
})
|
|
803
|
-
.filter((t) => t?.trim())
|
|
804
|
-
|
|
805
|
-
if (texts.length > 0) {
|
|
806
|
-
const combinedText = texts.join('\n\n')
|
|
807
|
-
const truncated = combinedText.length > 1900 ? combinedText.slice(0, 1900) + 'ā¦' : combinedText
|
|
808
|
-
await thread.send(truncated)
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
messageCount++
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
await thread.send(
|
|
815
|
-
`ā
**Session resumed!** Loaded ${messageCount} messages.\n\nYou can now continue the conversation by sending messages in this thread.`,
|
|
816
|
-
)
|
|
817
|
-
|
|
818
|
-
s.stop(`Loaded ${messageCount} messages`)
|
|
819
|
-
|
|
820
|
-
const guildId = textChannel.guildId
|
|
821
|
-
const threadUrl = `https://discord.com/channels/${guildId}/${thread.id}`
|
|
822
|
-
|
|
823
|
-
note(
|
|
824
|
-
`Session "${session.title}" has been sent to Discord!\n\nThread: ${threadUrl}`,
|
|
825
|
-
'ā
Success',
|
|
826
|
-
)
|
|
827
|
-
|
|
828
|
-
discordClient.destroy()
|
|
829
|
-
process.exit(0)
|
|
830
|
-
} catch (error) {
|
|
831
|
-
cliLogger.error(
|
|
832
|
-
'Error:',
|
|
833
|
-
error instanceof Error ? error.message : String(error),
|
|
834
|
-
)
|
|
835
|
-
process.exit(EXIT_NO_RESTART)
|
|
836
|
-
}
|
|
837
|
-
})
|
|
838
703
|
|
|
839
704
|
cli
|
|
840
705
|
.command('upload-to-discord [...files]', 'Upload files to a Discord thread for a session')
|
|
@@ -929,36 +794,7 @@ cli
|
|
|
929
794
|
}
|
|
930
795
|
})
|
|
931
796
|
|
|
932
|
-
cli
|
|
933
|
-
.command('install-plugin', 'Install the OpenCode command for kimaki Discord integration')
|
|
934
|
-
.action(async () => {
|
|
935
|
-
try {
|
|
936
|
-
const require = createRequire(import.meta.url)
|
|
937
|
-
const sendCommandSrc = require.resolve('./opencode-command-send-to-discord.md')
|
|
938
|
-
|
|
939
|
-
const opencodeConfig = path.join(os.homedir(), '.config', 'opencode')
|
|
940
|
-
const commandDir = path.join(opencodeConfig, 'command')
|
|
941
797
|
|
|
942
|
-
fs.mkdirSync(commandDir, { recursive: true })
|
|
943
|
-
|
|
944
|
-
const sendCommandDest = path.join(commandDir, 'send-to-kimaki-discord.md')
|
|
945
|
-
|
|
946
|
-
fs.copyFileSync(sendCommandSrc, sendCommandDest)
|
|
947
|
-
|
|
948
|
-
note(
|
|
949
|
-
`Command installed:\n- ${sendCommandDest}\n\nUse /send-to-kimaki-discord to send session to Discord.`,
|
|
950
|
-
'ā
Installed',
|
|
951
|
-
)
|
|
952
|
-
|
|
953
|
-
process.exit(0)
|
|
954
|
-
} catch (error) {
|
|
955
|
-
cliLogger.error(
|
|
956
|
-
'Error:',
|
|
957
|
-
error instanceof Error ? error.message : String(error),
|
|
958
|
-
)
|
|
959
|
-
process.exit(EXIT_NO_RESTART)
|
|
960
|
-
}
|
|
961
|
-
})
|
|
962
798
|
|
|
963
799
|
cli.help()
|
|
964
800
|
cli.parse()
|