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 +21 -0
- package/dist/cli.js +70 -37
- package/dist/commands/ask-question.js +49 -16
- package/dist/discord-bot.js +4 -1
- package/dist/discord-utils.js +4 -1
- package/dist/escape-backticks.test.js +11 -3
- package/dist/session-handler.js +17 -3
- package/dist/system-message.js +4 -4
- package/dist/unnest-code-blocks.js +110 -0
- package/dist/unnest-code-blocks.test.js +213 -0
- package/dist/utils.js +1 -0
- package/package.json +11 -12
- package/src/cli.ts +91 -46
- package/src/commands/ask-question.ts +57 -22
- package/src/discord-bot.ts +4 -1
- package/src/discord-utils.ts +4 -1
- package/src/escape-backticks.test.ts +11 -3
- package/src/session-handler.ts +19 -3
- package/src/system-message.ts +4 -4
- package/src/unnest-code-blocks.test.ts +225 -0
- package/src/unnest-code-blocks.ts +127 -0
- package/src/utils.ts +1 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
s.stop('OpenCode server
|
|
448
|
-
s.start('Fetching OpenCode
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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 {
|
|
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
|
|
136
|
+
const answers = context.questions.map((_, i) => {
|
|
133
137
|
return context.answers[i] || [];
|
|
134
138
|
});
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
package/dist/discord-bot.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/dist/discord-utils.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
345
|
-
\`\`\`
|
|
344
|
+
veryverylo\`\`\`
|
|
346
345
|
",
|
|
347
346
|
"\`\`\`js
|
|
348
|
-
|
|
347
|
+
nglinethat\`\`\`
|
|
348
|
+
",
|
|
349
|
+
"\`\`\`js
|
|
350
|
+
exceedsmax\`\`\`
|
|
351
|
+
",
|
|
352
|
+
"\`\`\`js
|
|
353
|
+
length
|
|
354
|
+
\`\`\`
|
|
355
|
+
",
|
|
356
|
+
"short
|
|
349
357
|
\`\`\`
|
|
350
358
|
",
|
|
351
359
|
]
|
package/dist/session-handler.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/system-message.js
CHANGED
|
@@ -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
|
+
}
|