kimaki 0.4.30 → 0.4.31

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kimaki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/cli.js CHANGED
@@ -388,7 +388,12 @@ async function run({ restart, addChannels }) {
388
388
  }
389
389
  }
390
390
  const s = spinner();
391
- s.start('Creating Discord client and connecting...');
391
+ // Start OpenCode server EARLY - let it initialize in parallel with Discord login.
392
+ // This is the biggest startup bottleneck (can take 1-30 seconds to spawn and wait for ready)
393
+ const currentDir = process.cwd();
394
+ s.start('Starting OpenCode server...');
395
+ const opencodePromise = initializeOpencodeForDirectory(currentDir);
396
+ s.message('Connecting to Discord...');
392
397
  const discordClient = await createDiscordClient();
393
398
  const guilds = [];
394
399
  const kimakiChannels = [];
@@ -397,11 +402,43 @@ async function run({ restart, addChannels }) {
397
402
  await new Promise((resolve, reject) => {
398
403
  discordClient.once(Events.ClientReady, async (c) => {
399
404
  guilds.push(...Array.from(c.guilds.cache.values()));
400
- for (const guild of guilds) {
405
+ // Process all guilds in parallel for faster startup
406
+ const guildResults = await Promise.all(guilds.map(async (guild) => {
407
+ // Create Kimaki role if it doesn't exist, or fix its position (fire-and-forget)
408
+ guild.roles
409
+ .fetch()
410
+ .then(async (roles) => {
411
+ const existingRole = roles.find((role) => role.name.toLowerCase() === 'kimaki');
412
+ if (existingRole) {
413
+ // Move to bottom if not already there
414
+ if (existingRole.position > 1) {
415
+ await existingRole.setPosition(1);
416
+ cliLogger.info(`Moved "Kimaki" role to bottom in ${guild.name}`);
417
+ }
418
+ return;
419
+ }
420
+ return guild.roles.create({
421
+ name: 'Kimaki',
422
+ position: 1, // Place at bottom so anyone with Manage Roles can assign it
423
+ reason: 'Kimaki bot permission role - assign to users who can start sessions, send messages in threads, and use voice features',
424
+ });
425
+ })
426
+ .then((role) => {
427
+ if (role) {
428
+ cliLogger.info(`Created "Kimaki" role in ${guild.name}`);
429
+ }
430
+ })
431
+ .catch((error) => {
432
+ cliLogger.warn(`Could not create Kimaki role in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
433
+ });
401
434
  const channels = await getChannelsWithDescriptions(guild);
402
435
  const kimakiChans = channels.filter((ch) => ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId));
403
- if (kimakiChans.length > 0) {
404
- kimakiChannels.push({ guild, channels: kimakiChans });
436
+ return { guild, channels: kimakiChans };
437
+ }));
438
+ // Collect results
439
+ for (const result of guildResults) {
440
+ if (result.channels.length > 0) {
441
+ kimakiChannels.push(result);
405
442
  }
406
443
  }
407
444
  resolve(null);
@@ -441,26 +478,22 @@ async function run({ restart, addChannels }) {
441
478
  .join('\n');
442
479
  note(channelList, 'Existing Kimaki Channels');
443
480
  }
444
- s.start('Starting OpenCode server...');
445
- const currentDir = process.cwd();
446
- let getClient = await initializeOpencodeForDirectory(currentDir);
447
- s.stop('OpenCode server started!');
448
- s.start('Fetching OpenCode projects...');
449
- let projects = [];
450
- try {
451
- const projectsResponse = await getClient().project.list({});
452
- if (!projectsResponse.data) {
453
- throw new Error('Failed to fetch projects');
454
- }
455
- projects = projectsResponse.data;
456
- s.stop(`Found ${projects.length} OpenCode project(s)`);
457
- }
458
- catch (error) {
459
- s.stop('Failed to fetch projects');
460
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
461
- discordClient.destroy();
462
- process.exit(EXIT_NO_RESTART);
463
- }
481
+ // Await the OpenCode server that was started in parallel with Discord login
482
+ s.start('Waiting for OpenCode server...');
483
+ const getClient = await opencodePromise;
484
+ s.stop('OpenCode server ready!');
485
+ s.start('Fetching OpenCode data...');
486
+ // Fetch projects and commands in parallel
487
+ const [projects, allUserCommands] = await Promise.all([
488
+ getClient().project.list({}).then((r) => r.data || []).catch((error) => {
489
+ s.stop('Failed to fetch projects');
490
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
491
+ discordClient.destroy();
492
+ process.exit(EXIT_NO_RESTART);
493
+ }),
494
+ getClient().command.list({ query: { directory: currentDir } }).then((r) => r.data || []).catch(() => []),
495
+ ]);
496
+ s.stop(`Found ${projects.length} OpenCode project(s)`);
464
497
  const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
465
498
  .filter((ch) => ch.kimakiDirectory && ch.kimakiApp === appId)
466
499
  .map((ch) => ch.kimakiDirectory)
@@ -541,19 +574,6 @@ async function run({ restart, addChannels }) {
541
574
  }
542
575
  }
543
576
  }
544
- // Fetch user-defined commands using the already-running server
545
- const allUserCommands = [];
546
- try {
547
- const commandsResponse = await getClient().command.list({
548
- query: { directory: currentDir },
549
- });
550
- if (commandsResponse.data) {
551
- allUserCommands.push(...commandsResponse.data);
552
- }
553
- }
554
- catch {
555
- // Ignore errors fetching commands
556
- }
557
577
  // Log available user commands
558
578
  const registrableCommands = allUserCommands.filter((cmd) => !SKIP_USER_COMMANDS.includes(cmd.name));
559
579
  if (registrableCommands.length > 0) {
@@ -598,6 +618,7 @@ cli
598
618
  .option('--restart', 'Prompt for new credentials even if saved')
599
619
  .option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
600
620
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
621
+ .option('--install-url', 'Print the bot install URL and exit')
601
622
  .action(async (options) => {
602
623
  try {
603
624
  // Set data directory early, before any database access
@@ -605,6 +626,18 @@ cli
605
626
  setDataDir(options.dataDir);
606
627
  cliLogger.log(`Using data directory: ${getDataDir()}`);
607
628
  }
629
+ if (options.installUrl) {
630
+ const db = getDatabase();
631
+ const existingBot = db
632
+ .prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
633
+ .get();
634
+ if (!existingBot) {
635
+ cliLogger.error('No bot configured yet. Run `kimaki` first to set up.');
636
+ process.exit(EXIT_NO_RESTART);
637
+ }
638
+ console.log(generateBotInstallUrl({ clientId: existingBot.app_id }));
639
+ process.exit(0);
640
+ }
608
641
  await checkSingleInstance();
609
642
  await startLockServer();
610
643
  await run({
@@ -4,7 +4,7 @@
4
4
  import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder, } from 'discord.js';
5
5
  import crypto from 'node:crypto';
6
6
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
7
- import { getOpencodeServerPort } from '../opencode.js';
7
+ import { getOpencodeClientV2 } from '../opencode.js';
8
8
  import { createLogger } from '../logger.js';
9
9
  const logger = createLogger('ASK_QUESTION');
10
10
  // Store pending question contexts by hash
@@ -128,25 +128,18 @@ export async function handleAskQuestionSelectMenu(interaction) {
128
128
  */
129
129
  async function submitQuestionAnswers(context) {
130
130
  try {
131
+ const clientV2 = getOpencodeClientV2(context.directory);
132
+ if (!clientV2) {
133
+ throw new Error('OpenCode server not found for directory');
134
+ }
131
135
  // Build answers array: each element is an array of selected labels for that question
132
- const answersPayload = context.questions.map((_, i) => {
136
+ const answers = context.questions.map((_, i) => {
133
137
  return context.answers[i] || [];
134
138
  });
135
- // Reply to the question using direct HTTP call to OpenCode API
136
- // (v1 SDK doesn't have question.reply, so we call it directly)
137
- const port = getOpencodeServerPort(context.directory);
138
- if (!port) {
139
- throw new Error('OpenCode server not found for directory');
140
- }
141
- const response = await fetch(`http://127.0.0.1:${port}/question/${context.requestId}/reply`, {
142
- method: 'POST',
143
- headers: { 'Content-Type': 'application/json' },
144
- body: JSON.stringify({ answers: answersPayload }),
139
+ await clientV2.question.reply({
140
+ requestID: context.requestId,
141
+ answers,
145
142
  });
146
- if (!response.ok) {
147
- const text = await response.text();
148
- throw new Error(`Failed to reply to question: ${response.status} ${text}`);
149
- }
150
143
  logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
151
144
  }
152
145
  catch (error) {
@@ -182,3 +175,43 @@ export function parseAskUserQuestionTool(part) {
182
175
  }
183
176
  return input;
184
177
  }
178
+ /**
179
+ * Cancel a pending question for a thread (e.g., when user sends a new message).
180
+ * Sends cancellation response to OpenCode so the session can continue.
181
+ */
182
+ export async function cancelPendingQuestion(threadId) {
183
+ // Find pending question for this thread
184
+ let contextHash;
185
+ let context;
186
+ for (const [hash, ctx] of pendingQuestionContexts) {
187
+ if (ctx.thread.id === threadId) {
188
+ contextHash = hash;
189
+ context = ctx;
190
+ break;
191
+ }
192
+ }
193
+ if (!contextHash || !context) {
194
+ return false;
195
+ }
196
+ try {
197
+ const clientV2 = getOpencodeClientV2(context.directory);
198
+ if (!clientV2) {
199
+ throw new Error('OpenCode server not found for directory');
200
+ }
201
+ // Preserve already-answered questions, mark unanswered as cancelled
202
+ const answers = context.questions.map((_, i) => {
203
+ return context.answers[i] || ['(cancelled - user sent new message)'];
204
+ });
205
+ await clientV2.question.reply({
206
+ requestID: context.requestId,
207
+ answers,
208
+ });
209
+ logger.log(`Cancelled question ${context.requestId} due to new user message`);
210
+ }
211
+ catch (error) {
212
+ logger.error('Failed to cancel question:', error);
213
+ }
214
+ // Clean up regardless of whether the API call succeeded
215
+ pendingQuestionContexts.delete(contextHash);
216
+ return true;
217
+ }
@@ -109,7 +109,10 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
109
109
  const canManageServer = message.member.permissions.has(PermissionsBitField.Flags.ManageGuild);
110
110
  const hasKimakiRole = message.member.roles.cache.some((role) => role.name.toLowerCase() === 'kimaki');
111
111
  if (!isOwner && !isAdmin && !canManageServer && !hasKimakiRole) {
112
- await message.react('🔒');
112
+ await message.reply({
113
+ content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
114
+ flags: SILENT_MESSAGE_FLAGS,
115
+ });
113
116
  return;
114
117
  }
115
118
  }
@@ -5,6 +5,7 @@ import { ChannelType, } from 'discord.js';
5
5
  import { Lexer } from 'marked';
6
6
  import { extractTagsArrays } from './xml.js';
7
7
  import { formatMarkdownTables } from './format-tables.js';
8
+ import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
8
9
  import { createLogger } from './logger.js';
9
10
  const discordLogger = createLogger('DISCORD');
10
11
  export const SILENT_MESSAGE_FLAGS = 4 | 4096;
@@ -92,7 +93,8 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
92
93
  }
93
94
  // calculate overhead for code block markers
94
95
  const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0;
95
- const availablePerChunk = maxLength - codeBlockOverhead - 50; // safety margin
96
+ // ensure at least 10 chars available, even if maxLength is very small
97
+ const availablePerChunk = Math.max(10, maxLength - codeBlockOverhead - 50);
96
98
  const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
97
99
  for (let i = 0; i < pieces.length; i++) {
98
100
  const piece = pieces[i];
@@ -156,6 +158,7 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
156
158
  export async function sendThreadMessage(thread, content, options) {
157
159
  const MAX_LENGTH = 2000;
158
160
  content = formatMarkdownTables(content);
161
+ content = unnestCodeBlocksFromLists(content);
159
162
  content = escapeBackticksInCodeBlocks(content);
160
163
  // If custom flags provided, send as single message (no chunking)
161
164
  if (options?.flags !== undefined) {
@@ -341,11 +341,19 @@ test('splitMarkdownForDiscord handles very long line inside code block', () => {
341
341
  \`\`\`
342
342
  ",
343
343
  "\`\`\`js
344
- veryverylonglinethatexceedsmaxlength
345
- \`\`\`
344
+ veryverylo\`\`\`
346
345
  ",
347
346
  "\`\`\`js
348
- short
347
+ nglinethat\`\`\`
348
+ ",
349
+ "\`\`\`js
350
+ exceedsmax\`\`\`
351
+ ",
352
+ "\`\`\`js
353
+ length
354
+ \`\`\`
355
+ ",
356
+ "short
349
357
  \`\`\`
350
358
  ",
351
359
  ]
@@ -4,12 +4,12 @@
4
4
  import prettyMilliseconds from 'pretty-ms';
5
5
  import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js';
6
6
  import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
7
- import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js';
7
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
8
8
  import { formatPart } from './message-formatting.js';
9
9
  import { getOpencodeSystemMessage } from './system-message.js';
10
10
  import { createLogger } from './logger.js';
11
11
  import { isAbortError } from './utils.js';
12
- import { showAskUserQuestionDropdowns } from './commands/ask-question.js';
12
+ import { showAskUserQuestionDropdowns, cancelPendingQuestion } from './commands/ask-question.js';
13
13
  import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js';
14
14
  const sessionLogger = createLogger('SESSION');
15
15
  const voiceLogger = createLogger('VOICE');
@@ -154,6 +154,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
154
154
  pendingPermissions.delete(thread.id);
155
155
  }
156
156
  }
157
+ // Cancel any pending question tool if user sends a new message
158
+ const questionCancelled = await cancelPendingQuestion(thread.id);
159
+ if (questionCancelled) {
160
+ sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`);
161
+ await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`);
162
+ }
157
163
  const abortController = new AbortController();
158
164
  abortControllers.set(session.id, abortController);
159
165
  if (existingController) {
@@ -281,7 +287,8 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
281
287
  const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
282
288
  if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
283
289
  lastDisplayedContextPercentage = thresholdCrossed;
284
- await sendThreadMessage(thread, `⬥ context usage ${currentPercentage}%`);
290
+ const chunk = `⬦ context usage ${currentPercentage}%`;
291
+ await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS });
285
292
  }
286
293
  }
287
294
  }
@@ -386,6 +393,13 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
386
393
  continue;
387
394
  }
388
395
  sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
396
+ // Flush any pending text/reasoning parts before showing the dropdown
397
+ // This ensures text the LLM generated before the question tool is shown first
398
+ for (const p of currentParts) {
399
+ if (p.type !== 'step-start' && p.type !== 'step-finish') {
400
+ await sendPartMessage(p);
401
+ }
402
+ }
389
403
  await showAskUserQuestionDropdowns({
390
404
  thread,
391
405
  sessionId: session.id,
@@ -37,17 +37,17 @@ IMPORTANT: After editing any files, you MUST execute the critique command using
37
37
 
38
38
  Execute this after making changes:
39
39
 
40
- bunx critique web
40
+ bunx critique web --title "Add user authentication flow"
41
41
 
42
42
  If there are other unrelated changes in the working directory, filter to only show the files you edited:
43
43
 
44
- bunx critique web -- path/to/file1.ts path/to/file2.ts
44
+ bunx critique web --title "Fix database connection retry" -- path/to/file1.ts path/to/file2.ts
45
45
 
46
46
  You can also show latest commit changes using:
47
47
 
48
- bunx critique web HEAD
48
+ bunx critique web --title "Refactor API endpoints" HEAD
49
49
 
50
- bunx critique web HEAD~1 to get the one before last
50
+ bunx critique web --title "Update dependencies" HEAD~1 to get the one before last
51
51
 
52
52
  Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
53
53
 
@@ -0,0 +1,110 @@
1
+ // Unnest code blocks from list items for Discord.
2
+ // Discord doesn't render code blocks inside lists, so this hoists them
3
+ // to root level while preserving list structure.
4
+ import { Lexer } from 'marked';
5
+ export function unnestCodeBlocksFromLists(markdown) {
6
+ const lexer = new Lexer();
7
+ const tokens = lexer.lex(markdown);
8
+ const result = [];
9
+ for (const token of tokens) {
10
+ if (token.type === 'list') {
11
+ const segments = processListToken(token);
12
+ result.push(renderSegments(segments));
13
+ }
14
+ else {
15
+ result.push(token.raw);
16
+ }
17
+ }
18
+ return result.join('');
19
+ }
20
+ function processListToken(list) {
21
+ const segments = [];
22
+ const start = typeof list.start === 'number' ? list.start : parseInt(list.start, 10) || 1;
23
+ const prefix = list.ordered ? (i) => `${start + i}. ` : () => '- ';
24
+ for (let i = 0; i < list.items.length; i++) {
25
+ const item = list.items[i];
26
+ const itemSegments = processListItem(item, prefix(i));
27
+ segments.push(...itemSegments);
28
+ }
29
+ return segments;
30
+ }
31
+ function processListItem(item, prefix) {
32
+ const segments = [];
33
+ let currentText = [];
34
+ const flushText = () => {
35
+ const text = currentText.join('').trim();
36
+ if (text) {
37
+ segments.push({ type: 'list-item', prefix, content: text });
38
+ }
39
+ currentText = [];
40
+ };
41
+ for (const token of item.tokens) {
42
+ if (token.type === 'code') {
43
+ flushText();
44
+ const codeToken = token;
45
+ const lang = codeToken.lang || '';
46
+ segments.push({
47
+ type: 'code',
48
+ content: '```' + lang + '\n' + codeToken.text + '\n```\n',
49
+ });
50
+ }
51
+ else if (token.type === 'list') {
52
+ flushText();
53
+ // Recursively process nested list - segments bubble up
54
+ const nestedSegments = processListToken(token);
55
+ segments.push(...nestedSegments);
56
+ }
57
+ else {
58
+ currentText.push(extractText(token));
59
+ }
60
+ }
61
+ flushText();
62
+ // If no segments were created (empty item), return empty
63
+ if (segments.length === 0) {
64
+ return [];
65
+ }
66
+ // If item had no code blocks (all segments are list-items from this level),
67
+ // return original raw to preserve formatting
68
+ const hasCode = segments.some((s) => s.type === 'code');
69
+ if (!hasCode) {
70
+ return [{ type: 'list-item', prefix: '', content: item.raw }];
71
+ }
72
+ return segments;
73
+ }
74
+ function extractText(token) {
75
+ if (token.type === 'text') {
76
+ return token.text;
77
+ }
78
+ if (token.type === 'space') {
79
+ return '';
80
+ }
81
+ if ('raw' in token) {
82
+ return token.raw;
83
+ }
84
+ return '';
85
+ }
86
+ function renderSegments(segments) {
87
+ const result = [];
88
+ for (let i = 0; i < segments.length; i++) {
89
+ const segment = segments[i];
90
+ const prev = segments[i - 1];
91
+ if (segment.type === 'code') {
92
+ // Add newline before code if previous was a list item
93
+ if (prev && prev.type === 'list-item') {
94
+ result.push('\n');
95
+ }
96
+ result.push(segment.content);
97
+ }
98
+ else {
99
+ // list-item
100
+ if (segment.prefix) {
101
+ result.push(segment.prefix + segment.content + '\n');
102
+ }
103
+ else {
104
+ // Raw content (no prefix means it's original raw)
105
+ result.push(segment.content);
106
+ }
107
+ }
108
+ }
109
+ return result.join('');
110
+ }