kimaki 0.0.3 → 0.1.0

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/src/tools.ts ADDED
@@ -0,0 +1,421 @@
1
+ import { tool } from 'ai'
2
+ import { z } from 'zod'
3
+ import { spawn, type ChildProcess } from 'node:child_process'
4
+ import net from 'node:net'
5
+ import {
6
+ createOpencodeClient,
7
+ type OpencodeClient,
8
+ type AssistantMessage,
9
+ type Provider,
10
+ } from '@opencode-ai/sdk'
11
+ import { createLogger } from './logger.js'
12
+
13
+ const toolsLogger = createLogger('TOOLS')
14
+ import { formatDistanceToNow } from 'date-fns'
15
+
16
+ import { ShareMarkdown } from './markdown.js'
17
+ import pc from 'picocolors'
18
+ import { initializeOpencodeForDirectory } from './discordBot.js'
19
+
20
+ export async function getTools({
21
+ onMessageCompleted,
22
+ directory,
23
+ }: {
24
+ directory: string
25
+ onMessageCompleted?: (params: {
26
+ sessionId: string
27
+ messageId: string
28
+ data?: { info: AssistantMessage }
29
+ error?: any
30
+ markdown?: string
31
+ }) => void
32
+ }) {
33
+ const client = await initializeOpencodeForDirectory(directory)
34
+
35
+ const markdownRenderer = new ShareMarkdown(client)
36
+
37
+ const providersResponse = await client.config.providers({})
38
+ const providers: Provider[] = providersResponse.data?.providers || []
39
+
40
+ // Helper: get last assistant model for a session (non-summary)
41
+ const getSessionModel = async (
42
+ sessionId: string,
43
+ ): Promise<{ providerID: string; modelID: string } | undefined> => {
44
+ const res = await client.session.messages({ path: { id: sessionId } })
45
+ const data = res.data
46
+ if (!data || data.length === 0) return undefined
47
+ for (let i = data.length - 1; i >= 0; i--) {
48
+ const info = data?.[i]?.info
49
+ if (info?.role === 'assistant') {
50
+ const ai = info as AssistantMessage
51
+ if (!ai.summary && ai.providerID && ai.modelID) {
52
+ return { providerID: ai.providerID, modelID: ai.modelID }
53
+ }
54
+ }
55
+ }
56
+ return undefined
57
+ }
58
+
59
+ const tools = {
60
+ submitMessage: tool({
61
+ description:
62
+ 'Submit a message to an existing chat session. Does not wait for the message to complete',
63
+ inputSchema: z.object({
64
+ sessionId: z.string().describe('The session ID to send message to'),
65
+ message: z.string().describe('The message text to send'),
66
+ }),
67
+ execute: async ({ sessionId, message }) => {
68
+ const sessionModel = await getSessionModel(sessionId)
69
+
70
+ // do not await
71
+ client.session
72
+ .prompt({
73
+ path: { id: sessionId },
74
+
75
+ body: {
76
+ parts: [{ type: 'text', text: message }],
77
+ model: sessionModel,
78
+ },
79
+ })
80
+ .then(async (response) => {
81
+ const markdown = await markdownRenderer.generate({
82
+ sessionID: sessionId,
83
+ lastAssistantOnly: true,
84
+ })
85
+ onMessageCompleted?.({
86
+ sessionId,
87
+ messageId: '',
88
+ data: response.data,
89
+ markdown,
90
+ })
91
+ })
92
+ .catch((error) => {
93
+ onMessageCompleted?.({
94
+ sessionId,
95
+ messageId: '',
96
+ error,
97
+ })
98
+ })
99
+ return {
100
+ success: true,
101
+ sessionId,
102
+ directive: 'Tell user that message has been sent successfully',
103
+ }
104
+ },
105
+ }),
106
+
107
+ createNewChat: tool({
108
+ description:
109
+ 'Start a new chat session with an initial message. Does not wait for the message to complete',
110
+ inputSchema: z.object({
111
+ message: z
112
+ .string()
113
+ .describe('The initial message to start the chat with'),
114
+ title: z.string().optional().describe('Optional title for the session'),
115
+ model: z
116
+ .object({
117
+ providerId: z
118
+ .string()
119
+ .describe('The provider ID (e.g., "anthropic", "openai")'),
120
+ modelId: z
121
+ .string()
122
+ .describe(
123
+ 'The model ID (e.g., "claude-opus-4-20250514", "gpt-5")',
124
+ ),
125
+ })
126
+ .optional()
127
+ .describe('Optional model to use for this session'),
128
+ }),
129
+ execute: async ({ message, title, model }) => {
130
+ if (!message.trim()) {
131
+ throw new Error(`message must be a non empty string`)
132
+ }
133
+
134
+ try {
135
+ const session = await client.session.create({
136
+ body: {
137
+ title: title || message.slice(0, 50),
138
+ },
139
+ })
140
+
141
+ if (!session.data) {
142
+ throw new Error('Failed to create session')
143
+ }
144
+
145
+ // do not await
146
+ client.session
147
+ .prompt({
148
+ path: { id: session.data.id },
149
+ body: {
150
+ parts: [{ type: 'text', text: message }],
151
+ },
152
+ })
153
+ .then(async (response) => {
154
+ const markdown = await markdownRenderer.generate({
155
+ sessionID: session.data.id,
156
+ lastAssistantOnly: true,
157
+ })
158
+ onMessageCompleted?.({
159
+ sessionId: session.data.id,
160
+ messageId: '',
161
+ data: response.data,
162
+ markdown,
163
+ })
164
+ })
165
+ .catch((error) => {
166
+ onMessageCompleted?.({
167
+ sessionId: session.data.id,
168
+ messageId: '',
169
+ error,
170
+ })
171
+ })
172
+
173
+ return {
174
+ success: true,
175
+ sessionId: session.data.id,
176
+ title: session.data.title,
177
+ }
178
+ } catch (error) {
179
+ return {
180
+ success: false,
181
+ error:
182
+ error instanceof Error
183
+ ? error.message
184
+ : 'Failed to create chat session',
185
+ }
186
+ }
187
+ },
188
+ }),
189
+
190
+ listChats: tool({
191
+ description:
192
+ 'Get a list of available chat sessions sorted by most recent',
193
+ inputSchema: z.object({}),
194
+ execute: async () => {
195
+ toolsLogger.log(`Listing opencode sessions`)
196
+ const sessions = await client.session.list()
197
+
198
+ if (!sessions.data) {
199
+ return { success: false, error: 'No sessions found' }
200
+ }
201
+
202
+ const sortedSessions = [...sessions.data]
203
+ .sort((a, b) => {
204
+ return b.time.updated - a.time.updated
205
+ })
206
+ .slice(0, 20)
207
+
208
+ const sessionList = sortedSessions.map(async (session) => {
209
+ const finishedAt = session.time.updated
210
+ const status = await (async () => {
211
+ if (session.revert) return 'error'
212
+ const messagesResponse = await client.session.messages({
213
+ path: { id: session.id },
214
+ })
215
+ const messages = messagesResponse.data || []
216
+ const lastMessage = messages[messages.length - 1]
217
+ if (
218
+ lastMessage?.info.role === 'assistant' &&
219
+ !lastMessage.info.time.completed
220
+ ) {
221
+ return 'in_progress'
222
+ }
223
+ return 'finished'
224
+ })()
225
+
226
+ return {
227
+ id: session.id,
228
+ folder: session.directory,
229
+ status,
230
+ finishedAt: formatDistanceToNow(new Date(finishedAt), {
231
+ addSuffix: true,
232
+ }),
233
+ title: session.title,
234
+ prompt: session.title,
235
+ }
236
+ })
237
+
238
+ const resolvedList = await Promise.all(sessionList)
239
+
240
+ return {
241
+ success: true,
242
+ sessions: resolvedList,
243
+ }
244
+ },
245
+ }),
246
+
247
+ searchFiles: tool({
248
+ description: 'Search for files in a folder',
249
+ inputSchema: z.object({
250
+ folder: z
251
+ .string()
252
+ .optional()
253
+ .describe(
254
+ 'The folder path to search in, optional. only use if user specifically asks for it',
255
+ ),
256
+ query: z.string().describe('The search query for files'),
257
+ }),
258
+ execute: async ({ folder, query }) => {
259
+ const results = await client.find.files({
260
+ query: {
261
+ query,
262
+ directory: folder,
263
+ },
264
+ })
265
+
266
+ return {
267
+ success: true,
268
+ files: results.data || [],
269
+ }
270
+ },
271
+ }),
272
+
273
+ readSessionMessages: tool({
274
+ description: 'Read messages from a chat session',
275
+ inputSchema: z.object({
276
+ sessionId: z.string().describe('The session ID to read messages from'),
277
+ lastAssistantOnly: z
278
+ .boolean()
279
+ .optional()
280
+ .describe('Only read the last assistant message'),
281
+ }),
282
+ execute: async ({ sessionId, lastAssistantOnly = false }) => {
283
+ if (lastAssistantOnly) {
284
+ const messages = await client.session.messages({
285
+ path: { id: sessionId },
286
+ })
287
+
288
+ if (!messages.data) {
289
+ return { success: false, error: 'No messages found' }
290
+ }
291
+
292
+ const assistantMessages = messages.data.filter(
293
+ (m) => m.info.role === 'assistant',
294
+ )
295
+
296
+ if (assistantMessages.length === 0) {
297
+ return {
298
+ success: false,
299
+ error: 'No assistant messages found',
300
+ }
301
+ }
302
+
303
+ const lastMessage = assistantMessages[assistantMessages.length - 1]
304
+ const status =
305
+ 'completed' in lastMessage!.info.time &&
306
+ lastMessage!.info.time.completed
307
+ ? 'completed'
308
+ : 'in_progress'
309
+
310
+ const markdown = await markdownRenderer.generate({
311
+ sessionID: sessionId,
312
+ lastAssistantOnly: true,
313
+ })
314
+
315
+ return {
316
+ success: true,
317
+ markdown,
318
+ status,
319
+ }
320
+ } else {
321
+ const markdown = await markdownRenderer.generate({
322
+ sessionID: sessionId,
323
+ })
324
+
325
+ const messages = await client.session.messages({
326
+ path: { id: sessionId },
327
+ })
328
+ const lastMessage = messages.data?.[messages.data.length - 1]
329
+ const status =
330
+ lastMessage?.info.role === 'assistant' &&
331
+ lastMessage?.info.time &&
332
+ 'completed' in lastMessage.info.time &&
333
+ !lastMessage.info.time.completed
334
+ ? 'in_progress'
335
+ : 'completed'
336
+
337
+ return {
338
+ success: true,
339
+ markdown,
340
+ status,
341
+ }
342
+ }
343
+ },
344
+ }),
345
+
346
+ abortChat: tool({
347
+ description: 'Abort/stop an in-progress chat session',
348
+ inputSchema: z.object({
349
+ sessionId: z.string().describe('The session ID to abort'),
350
+ }),
351
+ execute: async ({ sessionId }) => {
352
+ try {
353
+ const result = await client.session.abort({
354
+ path: { id: sessionId },
355
+ })
356
+
357
+ if (!result.data) {
358
+ return {
359
+ success: false,
360
+ error: 'Failed to abort session',
361
+ }
362
+ }
363
+
364
+ return {
365
+ success: true,
366
+ sessionId,
367
+ message: 'Session aborted successfully',
368
+ }
369
+ } catch (error) {
370
+ return {
371
+ success: false,
372
+ error:
373
+ error instanceof Error ? error.message : 'Unknown error occurred',
374
+ }
375
+ }
376
+ },
377
+ }),
378
+
379
+ getModels: tool({
380
+ description: 'Get all available AI models from all providers',
381
+ inputSchema: z.object({}),
382
+ execute: async () => {
383
+ try {
384
+ const providersResponse = await client.config.providers({})
385
+ const providers: Provider[] = providersResponse.data?.providers || []
386
+
387
+ const models: Array<{ providerId: string; modelId: string }> = []
388
+
389
+ providers.forEach((provider) => {
390
+ if (provider.models && typeof provider.models === 'object') {
391
+ Object.entries(provider.models).forEach(([modelId, model]) => {
392
+ models.push({
393
+ providerId: provider.id,
394
+ modelId: modelId,
395
+ })
396
+ })
397
+ }
398
+ })
399
+
400
+ return {
401
+ success: true,
402
+ models,
403
+ totalCount: models.length,
404
+ }
405
+ } catch (error) {
406
+ return {
407
+ success: false,
408
+ error:
409
+ error instanceof Error ? error.message : 'Failed to fetch models',
410
+ models: [],
411
+ }
412
+ }
413
+ },
414
+ }),
415
+ }
416
+
417
+ return {
418
+ tools,
419
+ providers,
420
+ }
421
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { PermissionsBitField } from 'discord.js'
2
+
3
+ type GenerateInstallUrlOptions = {
4
+ clientId: string
5
+ permissions?: bigint[]
6
+ scopes?: string[]
7
+ guildId?: string
8
+ disableGuildSelect?: boolean
9
+ }
10
+
11
+ export function generateBotInstallUrl({
12
+ clientId,
13
+ permissions = [
14
+ PermissionsBitField.Flags.ViewChannel,
15
+ PermissionsBitField.Flags.ManageChannels,
16
+ PermissionsBitField.Flags.SendMessages,
17
+ PermissionsBitField.Flags.SendMessagesInThreads,
18
+ PermissionsBitField.Flags.CreatePublicThreads,
19
+ PermissionsBitField.Flags.ManageThreads,
20
+ PermissionsBitField.Flags.ReadMessageHistory,
21
+ PermissionsBitField.Flags.AddReactions,
22
+ PermissionsBitField.Flags.ManageMessages,
23
+ PermissionsBitField.Flags.UseExternalEmojis,
24
+ PermissionsBitField.Flags.AttachFiles,
25
+ PermissionsBitField.Flags.Connect,
26
+ PermissionsBitField.Flags.Speak,
27
+ ],
28
+ scopes = ['bot'],
29
+ guildId,
30
+ disableGuildSelect = false,
31
+ }: GenerateInstallUrlOptions): string {
32
+ const permissionsBitField = new PermissionsBitField(permissions)
33
+ const permissionsValue = permissionsBitField.bitfield.toString()
34
+
35
+ const url = new URL('https://discord.com/api/oauth2/authorize')
36
+ url.searchParams.set('client_id', clientId)
37
+ url.searchParams.set('permissions', permissionsValue)
38
+ url.searchParams.set('scope', scopes.join(' '))
39
+
40
+ if (guildId) {
41
+ url.searchParams.set('guild_id', guildId)
42
+ }
43
+
44
+ if (disableGuildSelect) {
45
+ url.searchParams.set('disable_guild_select', 'true')
46
+ }
47
+
48
+ return url.toString()
49
+ }
50
+
51
+ function getRequiredBotPermissions(): bigint[] {
52
+ return [
53
+ PermissionsBitField.Flags.ViewChannel,
54
+ PermissionsBitField.Flags.ManageChannels,
55
+ PermissionsBitField.Flags.SendMessages,
56
+ PermissionsBitField.Flags.SendMessagesInThreads,
57
+ PermissionsBitField.Flags.CreatePublicThreads,
58
+ PermissionsBitField.Flags.ManageThreads,
59
+ PermissionsBitField.Flags.ReadMessageHistory,
60
+ PermissionsBitField.Flags.AddReactions,
61
+ PermissionsBitField.Flags.ManageMessages,
62
+ PermissionsBitField.Flags.UseExternalEmojis,
63
+ PermissionsBitField.Flags.AttachFiles,
64
+ PermissionsBitField.Flags.Connect,
65
+ PermissionsBitField.Flags.Speak,
66
+ ]
67
+ }
68
+
69
+ function getPermissionNames(): string[] {
70
+ const permissions = getRequiredBotPermissions()
71
+ const permissionsBitField = new PermissionsBitField(permissions)
72
+ return permissionsBitField.toArray()
73
+ }
package/src/voice.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { openai } from '@ai-sdk/openai'
2
+ import { experimental_transcribe as transcribe } from 'ai'
3
+ import { createLogger } from './logger.js'
4
+
5
+ const voiceLogger = createLogger('VOICE')
6
+
7
+ export async function transcribeAudio({
8
+ audio,
9
+ prompt,
10
+ language,
11
+ temperature,
12
+ }: {
13
+ audio: Buffer | Uint8Array | ArrayBuffer | string
14
+ prompt?: string
15
+ language?: string
16
+ temperature?: number
17
+ }): Promise<string> {
18
+ try {
19
+ const result = await transcribe({
20
+ model: openai.transcription('whisper-1'),
21
+ audio,
22
+ ...(prompt || language || temperature !== undefined
23
+ ? {
24
+ providerOptions: {
25
+ openai: {
26
+ ...(prompt && { prompt }),
27
+ ...(language && { language }),
28
+ ...(temperature !== undefined && { temperature }),
29
+ },
30
+ },
31
+ }
32
+ : {}),
33
+ })
34
+
35
+ return result.text
36
+ } catch (error) {
37
+ voiceLogger.error('Failed to transcribe audio:', error)
38
+ throw new Error(
39
+ `Audio transcription failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
40
+ )
41
+ }
42
+ }
@@ -0,0 +1,60 @@
1
+ import type { Tool as AITool } from 'ai'
2
+
3
+ // Messages sent from main thread to worker
4
+ export type WorkerInMessage =
5
+ | {
6
+ type: 'init'
7
+ directory: string // Project directory for tools
8
+ systemMessage?: string
9
+ guildId: string
10
+ channelId: string
11
+ }
12
+ | {
13
+ type: 'sendRealtimeInput'
14
+ audio?: {
15
+ mimeType: string
16
+ data: string // base64
17
+ }
18
+ audioStreamEnd?: boolean
19
+ }
20
+ | {
21
+ type: 'sendTextInput'
22
+ text: string
23
+ }
24
+ | {
25
+ type: 'interrupt'
26
+ }
27
+ | {
28
+ type: 'stop'
29
+ }
30
+
31
+ // Messages sent from worker to main thread via parentPort
32
+ export type WorkerOutMessage =
33
+ | {
34
+ type: 'assistantOpusPacket'
35
+ packet: ArrayBuffer // Opus encoded audio packet
36
+ }
37
+ | {
38
+ type: 'assistantStartSpeaking'
39
+ }
40
+ | {
41
+ type: 'assistantStopSpeaking'
42
+ }
43
+ | {
44
+ type: 'assistantInterruptSpeaking'
45
+ }
46
+ | {
47
+ type: 'toolCallCompleted'
48
+ sessionId: string
49
+ messageId: string
50
+ data?: any
51
+ error?: any
52
+ markdown?: string
53
+ }
54
+ | {
55
+ type: 'error'
56
+ error: string
57
+ }
58
+ | {
59
+ type: 'ready'
60
+ }
package/src/xml.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { DomHandler, Parser, ElementType } from 'htmlparser2'
2
+ import type { ChildNode, Element, Text } from 'domhandler'
3
+ import { createLogger } from './logger.js'
4
+
5
+ const xmlLogger = createLogger('XML')
6
+
7
+ export function extractTagsArrays<T extends string>({
8
+ xml,
9
+ tags,
10
+ }: {
11
+ xml: string
12
+ tags: T[]
13
+ }): Record<T, string[]> & { others: string[] } {
14
+ const result: Record<string, string[]> = {
15
+ others: [],
16
+ }
17
+
18
+ // Initialize arrays for each tag
19
+ tags.forEach((tag) => {
20
+ result[tag] = []
21
+ })
22
+
23
+ try {
24
+ const handler = new DomHandler(
25
+ (error, dom) => {
26
+ if (error) {
27
+ xmlLogger.error('Error parsing XML:', error)
28
+ } else {
29
+ const findTags = (nodes: ChildNode[], path: string[] = []) => {
30
+ nodes.forEach((node) => {
31
+ if (node.type === ElementType.Tag) {
32
+ const element = node as Element
33
+ const currentPath = [...path, element.name]
34
+ const pathString = currentPath.join('.')
35
+
36
+ // Extract content using original string positions
37
+ const extractContent = (): string => {
38
+ // Use element's own indices but exclude the tags
39
+ if (
40
+ element.startIndex !== null &&
41
+ element.endIndex !== null
42
+ ) {
43
+ // Extract the full element including tags
44
+ const fullElement = xml.substring(
45
+ element.startIndex,
46
+ element.endIndex + 1,
47
+ )
48
+ // Find where content starts (after opening tag)
49
+ const contentStart = fullElement.indexOf('>') + 1
50
+ // Find where content ends (before this element's closing tag)
51
+ const closingTag = `</${element.name}>`
52
+ const contentEnd = fullElement.lastIndexOf(closingTag)
53
+
54
+ if (contentStart > 0 && contentEnd > contentStart) {
55
+ return fullElement.substring(contentStart, contentEnd)
56
+ }
57
+
58
+ return ''
59
+ }
60
+ return ''
61
+ }
62
+
63
+ // Check both single tag names and nested paths
64
+ if (tags.includes(element.name as T)) {
65
+ const content = extractContent()
66
+ result[element.name as T]?.push(content)
67
+ }
68
+
69
+ // Check for nested path matches
70
+ if (tags.includes(pathString as T)) {
71
+ const content = extractContent()
72
+ result[pathString as T]?.push(content)
73
+ }
74
+
75
+ if (element.children) {
76
+ findTags(element.children, currentPath)
77
+ }
78
+ } else if (
79
+ node.type === ElementType.Text &&
80
+ node.parent?.type === ElementType.Root
81
+ ) {
82
+ const textNode = node as Text
83
+ if (textNode.data.trim()) {
84
+ // console.log('node.parent',node.parent)
85
+ result.others?.push(textNode.data.trim())
86
+ }
87
+ }
88
+ })
89
+ }
90
+
91
+ findTags(dom)
92
+ }
93
+ },
94
+ {
95
+ withStartIndices: true,
96
+ withEndIndices: true,
97
+ xmlMode: true,
98
+ },
99
+ )
100
+
101
+ const parser = new Parser(handler, {
102
+ xmlMode: true,
103
+ decodeEntities: false,
104
+ })
105
+ parser.write(xml)
106
+ parser.end()
107
+ } catch (error) {
108
+ xmlLogger.error('Unexpected error in extractTags:', error)
109
+ }
110
+
111
+ return result as Record<T, string[]> & { others: string[] }
112
+ }
package/bin.js DELETED
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import './dist/bundle.js'