kimaki 0.4.24 → 0.4.26

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.
Files changed (86) hide show
  1. package/bin.js +6 -1
  2. package/dist/acp-client.test.js +149 -0
  3. package/dist/ai-tool-to-genai.js +3 -0
  4. package/dist/channel-management.js +14 -9
  5. package/dist/cli.js +148 -17
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +54 -0
  22. package/dist/discord-bot.js +35 -32
  23. package/dist/discord-utils.js +81 -15
  24. package/dist/format-tables.js +3 -0
  25. package/dist/genai-worker-wrapper.js +3 -0
  26. package/dist/genai-worker.js +3 -0
  27. package/dist/genai.js +3 -0
  28. package/dist/interaction-handler.js +89 -695
  29. package/dist/logger.js +46 -5
  30. package/dist/markdown.js +107 -0
  31. package/dist/markdown.test.js +31 -1
  32. package/dist/message-formatting.js +113 -28
  33. package/dist/message-formatting.test.js +73 -0
  34. package/dist/opencode.js +73 -16
  35. package/dist/session-handler.js +176 -63
  36. package/dist/system-message.js +7 -38
  37. package/dist/tools.js +3 -0
  38. package/dist/utils.js +3 -0
  39. package/dist/voice-handler.js +21 -8
  40. package/dist/voice.js +31 -12
  41. package/dist/worker-types.js +3 -0
  42. package/dist/xml.js +3 -0
  43. package/package.json +3 -3
  44. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  45. package/src/__snapshots__/compact-session-context.md +47 -0
  46. package/src/ai-tool-to-genai.ts +4 -0
  47. package/src/channel-management.ts +24 -8
  48. package/src/cli.ts +163 -18
  49. package/src/commands/abort.ts +94 -0
  50. package/src/commands/add-project.ts +139 -0
  51. package/src/commands/agent.ts +201 -0
  52. package/src/commands/ask-question.ts +276 -0
  53. package/src/commands/create-new-project.ts +111 -0
  54. package/src/{fork.ts → commands/fork.ts} +40 -7
  55. package/src/{model-command.ts → commands/model.ts} +31 -9
  56. package/src/commands/permissions.ts +146 -0
  57. package/src/commands/queue.ts +181 -0
  58. package/src/commands/resume.ts +230 -0
  59. package/src/commands/session.ts +184 -0
  60. package/src/commands/share.ts +96 -0
  61. package/src/commands/types.ts +25 -0
  62. package/src/commands/undo-redo.ts +213 -0
  63. package/src/commands/user-command.ts +178 -0
  64. package/src/database.ts +65 -0
  65. package/src/discord-bot.ts +40 -33
  66. package/src/discord-utils.ts +88 -14
  67. package/src/format-tables.ts +4 -0
  68. package/src/genai-worker-wrapper.ts +4 -0
  69. package/src/genai-worker.ts +4 -0
  70. package/src/genai.ts +4 -0
  71. package/src/interaction-handler.ts +111 -924
  72. package/src/logger.ts +51 -10
  73. package/src/markdown.test.ts +45 -1
  74. package/src/markdown.ts +136 -0
  75. package/src/message-formatting.test.ts +81 -0
  76. package/src/message-formatting.ts +143 -30
  77. package/src/opencode.ts +84 -21
  78. package/src/session-handler.ts +248 -91
  79. package/src/system-message.ts +8 -38
  80. package/src/tools.ts +4 -0
  81. package/src/utils.ts +4 -0
  82. package/src/voice-handler.ts +24 -9
  83. package/src/voice.ts +36 -13
  84. package/src/worker-types.ts +4 -0
  85. package/src/xml.ts +4 -0
  86. package/README.md +0 -48
package/dist/database.js CHANGED
@@ -1,3 +1,6 @@
1
+ // SQLite database manager for persistent bot state.
2
+ // Stores thread-session mappings, bot tokens, channel directories,
3
+ // API keys, and model preferences in ~/.kimaki/discord-sessions.db.
1
4
  import Database from 'better-sqlite3';
2
5
  import fs from 'node:fs';
3
6
  import os from 'node:os';
@@ -79,6 +82,21 @@ export function runModelMigrations(database) {
79
82
  model_id TEXT NOT NULL,
80
83
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
81
84
  )
85
+ `);
86
+ targetDb.exec(`
87
+ CREATE TABLE IF NOT EXISTS channel_agents (
88
+ channel_id TEXT PRIMARY KEY,
89
+ agent_name TEXT NOT NULL,
90
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
91
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
92
+ )
93
+ `);
94
+ targetDb.exec(`
95
+ CREATE TABLE IF NOT EXISTS session_agents (
96
+ session_id TEXT PRIMARY KEY,
97
+ agent_name TEXT NOT NULL,
98
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
99
+ )
82
100
  `);
83
101
  dbLogger.log('Model preferences migrations complete');
84
102
  }
@@ -122,6 +140,42 @@ export function setSessionModel(sessionId, modelId) {
122
140
  const db = getDatabase();
123
141
  db.prepare(`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`).run(sessionId, modelId);
124
142
  }
143
+ /**
144
+ * Get the agent preference for a channel.
145
+ */
146
+ export function getChannelAgent(channelId) {
147
+ const db = getDatabase();
148
+ const row = db
149
+ .prepare('SELECT agent_name FROM channel_agents WHERE channel_id = ?')
150
+ .get(channelId);
151
+ return row?.agent_name;
152
+ }
153
+ /**
154
+ * Set the agent preference for a channel.
155
+ */
156
+ export function setChannelAgent(channelId, agentName) {
157
+ const db = getDatabase();
158
+ db.prepare(`INSERT INTO channel_agents (channel_id, agent_name, updated_at)
159
+ VALUES (?, ?, CURRENT_TIMESTAMP)
160
+ ON CONFLICT(channel_id) DO UPDATE SET agent_name = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, agentName, agentName);
161
+ }
162
+ /**
163
+ * Get the agent preference for a session.
164
+ */
165
+ export function getSessionAgent(sessionId) {
166
+ const db = getDatabase();
167
+ const row = db
168
+ .prepare('SELECT agent_name FROM session_agents WHERE session_id = ?')
169
+ .get(sessionId);
170
+ return row?.agent_name;
171
+ }
172
+ /**
173
+ * Set the agent preference for a session.
174
+ */
175
+ export function setSessionAgent(sessionId, agentName) {
176
+ const db = getDatabase();
177
+ db.prepare(`INSERT OR REPLACE INTO session_agents (session_id, agent_name) VALUES (?, ?)`).run(sessionId, agentName);
178
+ }
125
179
  export function closeDatabase() {
126
180
  if (db) {
127
181
  db.close();
@@ -1,3 +1,6 @@
1
+ // Core Discord bot module that handles message events and bot lifecycle.
2
+ // Bridges Discord messages to OpenCode sessions, manages voice connections,
3
+ // and orchestrates the main event loop for the Kimaki bot.
1
4
  import { getDatabase, closeDatabase } from './database.js';
2
5
  import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js';
3
6
  import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, } from './discord-utils.js';
@@ -5,7 +8,8 @@ import { getOpencodeSystemMessage } from './system-message.js';
5
8
  import { getFileAttachments, getTextAttachments } from './message-formatting.js';
6
9
  import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
7
10
  import { voiceConnections, cleanupVoiceConnection, processVoiceAttachment, registerVoiceStateHandler, } from './voice-handler.js';
8
- import { handleOpencodeSession, parseSlashCommand, } from './session-handler.js';
11
+ import { getCompactSessionContext, getLastSessionId, } from './markdown.js';
12
+ import { handleOpencodeSession } from './session-handler.js';
9
13
  import { registerInteractionHandler } from './interaction-handler.js';
10
14
  export { getDatabase, closeDatabase } from './database.js';
11
15
  export { initializeOpencodeForDirectory } from './opencode.js';
@@ -150,35 +154,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
150
154
  return;
151
155
  }
152
156
  let messageContent = message.content || '';
153
- let sessionMessagesText;
154
- if (projectDirectory && row.session_id) {
157
+ let currentSessionContext;
158
+ let lastSessionContext;
159
+ if (projectDirectory) {
155
160
  try {
156
161
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
157
- const messagesResponse = await getClient().session.messages({
158
- path: { id: row.session_id },
162
+ const client = getClient();
163
+ // get current session context (without system prompt, it would be duplicated)
164
+ if (row.session_id) {
165
+ currentSessionContext = await getCompactSessionContext({
166
+ client,
167
+ sessionId: row.session_id,
168
+ includeSystemPrompt: false,
169
+ maxMessages: 15,
170
+ });
171
+ }
172
+ // get last session context (with system prompt for project context)
173
+ const lastSessionId = await getLastSessionId({
174
+ client,
175
+ excludeSessionId: row.session_id,
159
176
  });
160
- const messages = messagesResponse.data || [];
161
- const recentMessages = messages.slice(-10);
162
- sessionMessagesText = recentMessages
163
- .map((m) => {
164
- const role = m.info.role === 'user' ? 'User' : 'Assistant';
165
- const text = (() => {
166
- if (m.info.role === 'user') {
167
- const textParts = (m.parts || []).filter((p) => p.type === 'text');
168
- return textParts
169
- .map((p) => ('text' in p ? p.text : ''))
170
- .filter(Boolean)
171
- .join('\n');
172
- }
173
- const assistantInfo = m.info;
174
- return assistantInfo.text?.slice(0, 500);
175
- })();
176
- return `[${role}]: ${text || '(no text)'}`;
177
- })
178
- .join('\n\n');
177
+ if (lastSessionId) {
178
+ lastSessionContext = await getCompactSessionContext({
179
+ client,
180
+ sessionId: lastSessionId,
181
+ includeSystemPrompt: true,
182
+ maxMessages: 10,
183
+ });
184
+ }
179
185
  }
180
186
  catch (e) {
181
- voiceLogger.log(`Could not get session messages:`, e);
187
+ voiceLogger.error(`Could not get session context:`, e);
182
188
  }
183
189
  }
184
190
  const transcription = await processVoiceAttachment({
@@ -186,24 +192,23 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
186
192
  thread,
187
193
  projectDirectory,
188
194
  appId: currentAppId,
189
- sessionMessages: sessionMessagesText,
195
+ currentSessionContext,
196
+ lastSessionContext,
190
197
  });
191
198
  if (transcription) {
192
199
  messageContent = transcription;
193
200
  }
194
- const fileAttachments = getFileAttachments(message);
201
+ const fileAttachments = await getFileAttachments(message);
195
202
  const textAttachmentsContent = await getTextAttachments(message);
196
203
  const promptWithAttachments = textAttachmentsContent
197
204
  ? `${messageContent}\n\n${textAttachmentsContent}`
198
205
  : messageContent;
199
- const parsedCommand = parseSlashCommand(messageContent);
200
206
  await handleOpencodeSession({
201
207
  prompt: promptWithAttachments,
202
208
  thread,
203
209
  projectDirectory,
204
210
  originalMessage: message,
205
211
  images: fileAttachments,
206
- parsedCommand,
207
212
  channelId: parent?.id,
208
213
  });
209
214
  return;
@@ -262,19 +267,17 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
262
267
  if (transcription) {
263
268
  messageContent = transcription;
264
269
  }
265
- const fileAttachments = getFileAttachments(message);
270
+ const fileAttachments = await getFileAttachments(message);
266
271
  const textAttachmentsContent = await getTextAttachments(message);
267
272
  const promptWithAttachments = textAttachmentsContent
268
273
  ? `${messageContent}\n\n${textAttachmentsContent}`
269
274
  : messageContent;
270
- const parsedCommand = parseSlashCommand(messageContent);
271
275
  await handleOpencodeSession({
272
276
  prompt: promptWithAttachments,
273
277
  thread,
274
278
  projectDirectory,
275
279
  originalMessage: message,
276
280
  images: fileAttachments,
277
- parsedCommand,
278
281
  channelId: textChannel.id,
279
282
  });
280
283
  }
@@ -1,3 +1,6 @@
1
+ // Discord-specific utility functions.
2
+ // Handles markdown splitting for Discord's 2000-char limit, code block escaping,
3
+ // thread message sending, and channel metadata extraction from topic tags.
1
4
  import { ChannelType, } from 'discord.js';
2
5
  import { Lexer } from 'marked';
3
6
  import { extractTagsArrays } from './xml.js';
@@ -5,6 +8,8 @@ import { formatMarkdownTables } from './format-tables.js';
5
8
  import { createLogger } from './logger.js';
6
9
  const discordLogger = createLogger('DISCORD');
7
10
  export const SILENT_MESSAGE_FLAGS = 4 | 4096;
11
+ // Same as SILENT but without SuppressNotifications - triggers badge/notification
12
+ export const NOTIFY_MESSAGE_FLAGS = 4;
8
13
  export function escapeBackticksInCodeBlocks(markdown) {
9
14
  const lexer = new Lexer();
10
15
  const tokens = lexer.lex(markdown);
@@ -51,29 +56,86 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
51
56
  const chunks = [];
52
57
  let currentChunk = '';
53
58
  let currentLang = null;
59
+ // helper to split a long line into smaller pieces at word boundaries or hard breaks
60
+ const splitLongLine = (text, available, inCode) => {
61
+ const pieces = [];
62
+ let remaining = text;
63
+ while (remaining.length > available) {
64
+ let splitAt = available;
65
+ // for non-code, try to split at word boundary
66
+ if (!inCode) {
67
+ const lastSpace = remaining.lastIndexOf(' ', available);
68
+ if (lastSpace > available * 0.5) {
69
+ splitAt = lastSpace + 1;
70
+ }
71
+ }
72
+ pieces.push(remaining.slice(0, splitAt));
73
+ remaining = remaining.slice(splitAt);
74
+ }
75
+ if (remaining) {
76
+ pieces.push(remaining);
77
+ }
78
+ return pieces;
79
+ };
54
80
  for (const line of lines) {
55
81
  const wouldExceed = currentChunk.length + line.text.length > maxLength;
56
- if (wouldExceed && currentChunk) {
57
- if (currentLang !== null) {
58
- currentChunk += '```\n';
59
- }
60
- chunks.push(currentChunk);
61
- if (line.isClosingFence && currentLang !== null) {
62
- currentChunk = '';
82
+ if (wouldExceed) {
83
+ // handle case where single line is longer than maxLength
84
+ if (line.text.length > maxLength) {
85
+ // first, flush current chunk if any
86
+ if (currentChunk) {
87
+ if (currentLang !== null) {
88
+ currentChunk += '```\n';
89
+ }
90
+ chunks.push(currentChunk);
91
+ currentChunk = '';
92
+ }
93
+ // calculate overhead for code block markers
94
+ const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0;
95
+ const availablePerChunk = maxLength - codeBlockOverhead - 50; // safety margin
96
+ const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
97
+ for (let i = 0; i < pieces.length; i++) {
98
+ const piece = pieces[i];
99
+ if (line.inCodeBlock) {
100
+ chunks.push('```' + line.lang + '\n' + piece + '```\n');
101
+ }
102
+ else {
103
+ chunks.push(piece);
104
+ }
105
+ }
63
106
  currentLang = null;
64
107
  continue;
65
108
  }
66
- if (line.inCodeBlock || line.isOpeningFence) {
67
- const lang = line.lang;
68
- currentChunk = '```' + lang + '\n';
69
- if (!line.isOpeningFence) {
70
- currentChunk += line.text;
109
+ // normal case: line fits in a chunk but current chunk would overflow
110
+ if (currentChunk) {
111
+ if (currentLang !== null) {
112
+ currentChunk += '```\n';
113
+ }
114
+ chunks.push(currentChunk);
115
+ if (line.isClosingFence && currentLang !== null) {
116
+ currentChunk = '';
117
+ currentLang = null;
118
+ continue;
119
+ }
120
+ if (line.inCodeBlock || line.isOpeningFence) {
121
+ const lang = line.lang;
122
+ currentChunk = '```' + lang + '\n';
123
+ if (!line.isOpeningFence) {
124
+ currentChunk += line.text;
125
+ }
126
+ currentLang = lang;
127
+ }
128
+ else {
129
+ currentChunk = line.text;
130
+ currentLang = null;
71
131
  }
72
- currentLang = lang;
73
132
  }
74
133
  else {
134
+ // currentChunk is empty but line still exceeds - shouldn't happen after above check
75
135
  currentChunk = line.text;
76
- currentLang = null;
136
+ if (line.inCodeBlock || line.isOpeningFence) {
137
+ currentLang = line.lang;
138
+ }
77
139
  }
78
140
  }
79
141
  else {
@@ -91,10 +153,14 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
91
153
  }
92
154
  return chunks;
93
155
  }
94
- export async function sendThreadMessage(thread, content) {
156
+ export async function sendThreadMessage(thread, content, options) {
95
157
  const MAX_LENGTH = 2000;
96
158
  content = formatMarkdownTables(content);
97
159
  content = escapeBackticksInCodeBlocks(content);
160
+ // If custom flags provided, send as single message (no chunking)
161
+ if (options?.flags !== undefined) {
162
+ return thread.send({ content, flags: options.flags });
163
+ }
98
164
  const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH });
99
165
  if (chunks.length > 1) {
100
166
  discordLogger.log(`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`);
@@ -1,3 +1,6 @@
1
+ // Markdown table to code block converter.
2
+ // Discord doesn't render GFM tables, so this converts them to
3
+ // space-aligned code blocks for proper monospace display.
1
4
  import { Lexer } from 'marked';
2
5
  export function formatMarkdownTables(markdown) {
3
6
  const lexer = new Lexer();
@@ -1,3 +1,6 @@
1
+ // Main thread interface for the GenAI worker.
2
+ // Spawns and manages the worker thread, handling message passing for
3
+ // audio input/output, tool call completions, and graceful shutdown.
1
4
  import { Worker } from 'node:worker_threads';
2
5
  import { createLogger } from './logger.js';
3
6
  const genaiWorkerLogger = createLogger('GENAI WORKER');
@@ -1,3 +1,6 @@
1
+ // Worker thread for GenAI voice processing.
2
+ // Runs in a separate thread to handle audio encoding/decoding without blocking.
3
+ // Resamples 24kHz GenAI output to 48kHz stereo Opus packets for Discord.
1
4
  import { parentPort, threadId } from 'node:worker_threads';
2
5
  import { createWriteStream } from 'node:fs';
3
6
  import { mkdir } from 'node:fs/promises';
package/dist/genai.js CHANGED
@@ -1,3 +1,6 @@
1
+ // Google GenAI Live session manager for real-time voice interactions.
2
+ // Establishes bidirectional audio streaming with Gemini, handles tool calls,
3
+ // and manages the assistant's audio output for Discord voice channels.
1
4
  import { GoogleGenAI, LiveServerMessage, MediaResolution, Modality, Session, } from '@google/genai';
2
5
  import { writeFile } from 'fs';
3
6
  import { createLogger } from './logger.js';