kimaki 0.4.32 → 0.4.34
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/dist/cli.js +3 -1
- package/dist/discord-utils.js +2 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +13 -6
- package/dist/message-formatting.js +3 -3
- package/dist/message-formatting.test.js +3 -3
- package/dist/session-handler.js +35 -4
- package/dist/system-message.js +13 -0
- package/dist/unnest-code-blocks.js +6 -1
- package/dist/unnest-code-blocks.test.js +194 -0
- package/package.json +5 -6
- package/src/cli.ts +3 -1
- package/src/discord-utils.ts +2 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +20 -12
- package/src/message-formatting.test.ts +3 -3
- package/src/message-formatting.ts +3 -3
- package/src/session-handler.ts +40 -4
- package/src/system-message.ts +13 -0
- package/src/unnest-code-blocks.test.ts +203 -0
- package/src/unnest-code-blocks.ts +6 -1
package/dist/cli.js
CHANGED
|
@@ -213,7 +213,9 @@ async function registerCommands(token, appId, userCommands = []) {
|
|
|
213
213
|
if (SKIP_USER_COMMANDS.includes(cmd.name)) {
|
|
214
214
|
continue;
|
|
215
215
|
}
|
|
216
|
-
|
|
216
|
+
// Sanitize command name: oh-my-opencode uses MCP commands with colons, which Discord doesn't allow
|
|
217
|
+
const sanitizedName = cmd.name.replace(/:/g, '-');
|
|
218
|
+
const commandName = `${sanitizedName}-cmd`;
|
|
217
219
|
const description = cmd.description || `Run /${cmd.name} command`;
|
|
218
220
|
commands.push(new SlashCommandBuilder()
|
|
219
221
|
.setName(commandName)
|
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 { limitHeadingDepth } from './limit-heading-depth.js';
|
|
8
9
|
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
|
|
9
10
|
import { createLogger } from './logger.js';
|
|
10
11
|
const discordLogger = createLogger('DISCORD');
|
|
@@ -159,6 +160,7 @@ export async function sendThreadMessage(thread, content, options) {
|
|
|
159
160
|
const MAX_LENGTH = 2000;
|
|
160
161
|
content = formatMarkdownTables(content);
|
|
161
162
|
content = unnestCodeBlocksFromLists(content);
|
|
163
|
+
content = limitHeadingDepth(content);
|
|
162
164
|
content = escapeBackticksInCodeBlocks(content);
|
|
163
165
|
// If custom flags provided, send as single message (no chunking)
|
|
164
166
|
if (options?.flags !== undefined) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Limit heading depth for Discord.
|
|
2
|
+
// Discord only supports headings up to ### (h3), so this converts
|
|
3
|
+
// ####, #####, etc. to ### to maintain consistent rendering.
|
|
4
|
+
import { Lexer } from 'marked';
|
|
5
|
+
export function limitHeadingDepth(markdown, maxDepth = 3) {
|
|
6
|
+
const lexer = new Lexer();
|
|
7
|
+
const tokens = lexer.lex(markdown);
|
|
8
|
+
let result = '';
|
|
9
|
+
for (const token of tokens) {
|
|
10
|
+
if (token.type === 'heading') {
|
|
11
|
+
const heading = token;
|
|
12
|
+
if (heading.depth > maxDepth) {
|
|
13
|
+
const hashes = '#'.repeat(maxDepth);
|
|
14
|
+
result += hashes + ' ' + heading.text + '\n';
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
result += token.raw;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
result += token.raw;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { expect, test } from 'vitest';
|
|
2
|
+
import { limitHeadingDepth } from './limit-heading-depth.js';
|
|
3
|
+
test('converts h4 to h3', () => {
|
|
4
|
+
const input = '#### Fourth level heading';
|
|
5
|
+
const result = limitHeadingDepth(input);
|
|
6
|
+
expect(result).toMatchInlineSnapshot(`
|
|
7
|
+
"### Fourth level heading
|
|
8
|
+
"
|
|
9
|
+
`);
|
|
10
|
+
});
|
|
11
|
+
test('converts h5 to h3', () => {
|
|
12
|
+
const input = '##### Fifth level heading';
|
|
13
|
+
const result = limitHeadingDepth(input);
|
|
14
|
+
expect(result).toMatchInlineSnapshot(`
|
|
15
|
+
"### Fifth level heading
|
|
16
|
+
"
|
|
17
|
+
`);
|
|
18
|
+
});
|
|
19
|
+
test('converts h6 to h3', () => {
|
|
20
|
+
const input = '###### Sixth level heading';
|
|
21
|
+
const result = limitHeadingDepth(input);
|
|
22
|
+
expect(result).toMatchInlineSnapshot(`
|
|
23
|
+
"### Sixth level heading
|
|
24
|
+
"
|
|
25
|
+
`);
|
|
26
|
+
});
|
|
27
|
+
test('preserves h3 unchanged', () => {
|
|
28
|
+
const input = '### Third level heading';
|
|
29
|
+
const result = limitHeadingDepth(input);
|
|
30
|
+
expect(result).toMatchInlineSnapshot(`"### Third level heading"`);
|
|
31
|
+
});
|
|
32
|
+
test('preserves h2 unchanged', () => {
|
|
33
|
+
const input = '## Second level heading';
|
|
34
|
+
const result = limitHeadingDepth(input);
|
|
35
|
+
expect(result).toMatchInlineSnapshot(`"## Second level heading"`);
|
|
36
|
+
});
|
|
37
|
+
test('preserves h1 unchanged', () => {
|
|
38
|
+
const input = '# First level heading';
|
|
39
|
+
const result = limitHeadingDepth(input);
|
|
40
|
+
expect(result).toMatchInlineSnapshot(`"# First level heading"`);
|
|
41
|
+
});
|
|
42
|
+
test('handles multiple headings in document', () => {
|
|
43
|
+
const input = `# Title
|
|
44
|
+
|
|
45
|
+
Some text
|
|
46
|
+
|
|
47
|
+
## Section
|
|
48
|
+
|
|
49
|
+
### Subsection
|
|
50
|
+
|
|
51
|
+
#### Too deep
|
|
52
|
+
|
|
53
|
+
##### Even deeper
|
|
54
|
+
|
|
55
|
+
Regular paragraph
|
|
56
|
+
|
|
57
|
+
### Back to normal
|
|
58
|
+
`;
|
|
59
|
+
const result = limitHeadingDepth(input);
|
|
60
|
+
expect(result).toMatchInlineSnapshot(`
|
|
61
|
+
"# Title
|
|
62
|
+
|
|
63
|
+
Some text
|
|
64
|
+
|
|
65
|
+
## Section
|
|
66
|
+
|
|
67
|
+
### Subsection
|
|
68
|
+
|
|
69
|
+
### Too deep
|
|
70
|
+
### Even deeper
|
|
71
|
+
Regular paragraph
|
|
72
|
+
|
|
73
|
+
### Back to normal
|
|
74
|
+
"
|
|
75
|
+
`);
|
|
76
|
+
});
|
|
77
|
+
test('preserves heading with inline formatting', () => {
|
|
78
|
+
const input = '#### Heading with **bold** and `code`';
|
|
79
|
+
const result = limitHeadingDepth(input);
|
|
80
|
+
expect(result).toMatchInlineSnapshot(`
|
|
81
|
+
"### Heading with **bold** and \`code\`
|
|
82
|
+
"
|
|
83
|
+
`);
|
|
84
|
+
});
|
|
85
|
+
test('handles empty markdown', () => {
|
|
86
|
+
const result = limitHeadingDepth('');
|
|
87
|
+
expect(result).toMatchInlineSnapshot(`""`);
|
|
88
|
+
});
|
|
89
|
+
test('handles markdown with no headings', () => {
|
|
90
|
+
const input = 'Just some text\n\nAnd more text';
|
|
91
|
+
const result = limitHeadingDepth(input);
|
|
92
|
+
expect(result).toMatchInlineSnapshot(`
|
|
93
|
+
"Just some text
|
|
94
|
+
|
|
95
|
+
And more text"
|
|
96
|
+
`);
|
|
97
|
+
});
|
|
98
|
+
test('allows custom maxDepth', () => {
|
|
99
|
+
const input = '### Third level';
|
|
100
|
+
const result = limitHeadingDepth(input, 2);
|
|
101
|
+
expect(result).toMatchInlineSnapshot(`
|
|
102
|
+
"## Third level
|
|
103
|
+
"
|
|
104
|
+
`);
|
|
105
|
+
});
|
package/dist/logger.js
CHANGED
|
@@ -5,6 +5,7 @@ import { log } from '@clack/prompts';
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path, { dirname } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import util from 'node:util';
|
|
8
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
10
|
const __dirname = dirname(__filename);
|
|
10
11
|
const isDev = !__dirname.includes('node_modules');
|
|
@@ -17,35 +18,41 @@ if (isDev) {
|
|
|
17
18
|
}
|
|
18
19
|
fs.writeFileSync(logFilePath, `--- kimaki log started at ${new Date().toISOString()} ---\n`);
|
|
19
20
|
}
|
|
21
|
+
function formatArg(arg) {
|
|
22
|
+
if (typeof arg === 'string') {
|
|
23
|
+
return arg;
|
|
24
|
+
}
|
|
25
|
+
return util.inspect(arg, { colors: true, depth: 4 });
|
|
26
|
+
}
|
|
20
27
|
function writeToFile(level, prefix, args) {
|
|
21
28
|
if (!isDev) {
|
|
22
29
|
return;
|
|
23
30
|
}
|
|
24
31
|
const timestamp = new Date().toISOString();
|
|
25
|
-
const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(
|
|
32
|
+
const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(formatArg).join(' ')}\n`;
|
|
26
33
|
fs.appendFileSync(logFilePath, message);
|
|
27
34
|
}
|
|
28
35
|
export function createLogger(prefix) {
|
|
29
36
|
return {
|
|
30
37
|
log: (...args) => {
|
|
31
38
|
writeToFile('INFO', prefix, args);
|
|
32
|
-
log.info([`[${prefix}]`, ...args.map(
|
|
39
|
+
log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '));
|
|
33
40
|
},
|
|
34
41
|
error: (...args) => {
|
|
35
42
|
writeToFile('ERROR', prefix, args);
|
|
36
|
-
log.error([`[${prefix}]`, ...args.map(
|
|
43
|
+
log.error([`[${prefix}]`, ...args.map(formatArg)].join(' '));
|
|
37
44
|
},
|
|
38
45
|
warn: (...args) => {
|
|
39
46
|
writeToFile('WARN', prefix, args);
|
|
40
|
-
log.warn([`[${prefix}]`, ...args.map(
|
|
47
|
+
log.warn([`[${prefix}]`, ...args.map(formatArg)].join(' '));
|
|
41
48
|
},
|
|
42
49
|
info: (...args) => {
|
|
43
50
|
writeToFile('INFO', prefix, args);
|
|
44
|
-
log.info([`[${prefix}]`, ...args.map(
|
|
51
|
+
log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '));
|
|
45
52
|
},
|
|
46
53
|
debug: (...args) => {
|
|
47
54
|
writeToFile('DEBUG', prefix, args);
|
|
48
|
-
log.info([`[${prefix}]`, ...args.map(
|
|
55
|
+
log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '));
|
|
49
56
|
},
|
|
50
57
|
};
|
|
51
58
|
}
|
|
@@ -186,10 +186,10 @@ export function formatTodoList(part) {
|
|
|
186
186
|
const activeTodo = todos[activeIndex];
|
|
187
187
|
if (activeIndex === -1 || !activeTodo)
|
|
188
188
|
return '';
|
|
189
|
-
//
|
|
190
|
-
const
|
|
189
|
+
// digit-with-period ⒈-⒛ for 1-20, fallback to regular number for 21+
|
|
190
|
+
const digitWithPeriod = '⒈⒉⒊⒋⒌⒍⒎⒏⒐⒑⒒⒓⒔⒕⒖⒗⒘⒙⒚⒛';
|
|
191
191
|
const todoNumber = activeIndex + 1;
|
|
192
|
-
const num = todoNumber <= 20 ?
|
|
192
|
+
const num = todoNumber <= 20 ? digitWithPeriod[todoNumber - 1] : `${todoNumber}.`;
|
|
193
193
|
const content = activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1);
|
|
194
194
|
return `${num} **${escapeInlineMarkdown(content)}**`;
|
|
195
195
|
}
|
|
@@ -24,7 +24,7 @@ describe('formatTodoList', () => {
|
|
|
24
24
|
time: { start: 0, end: 0 },
|
|
25
25
|
},
|
|
26
26
|
};
|
|
27
|
-
expect(formatTodoList(part)).toMatchInlineSnapshot(`"
|
|
27
|
+
expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒉ **second task**"`);
|
|
28
28
|
});
|
|
29
29
|
test('formats double digit todo numbers', () => {
|
|
30
30
|
const todos = Array.from({ length: 12 }, (_, i) => ({
|
|
@@ -47,7 +47,7 @@ describe('formatTodoList', () => {
|
|
|
47
47
|
time: { start: 0, end: 0 },
|
|
48
48
|
},
|
|
49
49
|
};
|
|
50
|
-
expect(formatTodoList(part)).toMatchInlineSnapshot(`"
|
|
50
|
+
expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒓ **task 12**"`);
|
|
51
51
|
});
|
|
52
52
|
test('lowercases first letter of content', () => {
|
|
53
53
|
const part = {
|
|
@@ -68,6 +68,6 @@ describe('formatTodoList', () => {
|
|
|
68
68
|
time: { start: 0, end: 0 },
|
|
69
69
|
},
|
|
70
70
|
};
|
|
71
|
-
expect(formatTodoList(part)).toMatchInlineSnapshot(`"
|
|
71
|
+
expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒈ **fix the bug**"`);
|
|
72
72
|
});
|
|
73
73
|
});
|
package/dist/session-handler.js
CHANGED
|
@@ -9,7 +9,7 @@ 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, cancelPendingQuestion } from './commands/ask-question.js';
|
|
12
|
+
import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts } 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,11 +154,10 @@ 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
|
|
157
|
+
// Cancel any pending question tool if user sends a new message (silently, no thread message)
|
|
158
158
|
const questionCancelled = await cancelPendingQuestion(thread.id);
|
|
159
159
|
if (questionCancelled) {
|
|
160
160
|
sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`);
|
|
161
|
-
await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`);
|
|
162
161
|
}
|
|
163
162
|
const abortController = new AbortController();
|
|
164
163
|
abortControllers.set(session.id, abortController);
|
|
@@ -310,7 +309,12 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
310
309
|
currentParts.push(part);
|
|
311
310
|
}
|
|
312
311
|
if (part.type === 'step-start') {
|
|
313
|
-
|
|
312
|
+
// Don't start typing if user needs to respond to a question or permission
|
|
313
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
|
|
314
|
+
const hasPendingPermission = pendingPermissions.has(thread.id);
|
|
315
|
+
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
316
|
+
stopTyping = startTyping();
|
|
317
|
+
}
|
|
314
318
|
}
|
|
315
319
|
if (part.type === 'tool' && part.state.status === 'running') {
|
|
316
320
|
// Flush any pending text/reasoning parts before showing the tool
|
|
@@ -348,6 +352,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
348
352
|
if (part.type === 'reasoning') {
|
|
349
353
|
await sendPartMessage(part);
|
|
350
354
|
}
|
|
355
|
+
// Send text parts when complete (time.end is set)
|
|
356
|
+
// Text parts stream incrementally; only send when finished to avoid partial text
|
|
357
|
+
if (part.type === 'text' && part.time?.end) {
|
|
358
|
+
await sendPartMessage(part);
|
|
359
|
+
}
|
|
351
360
|
if (part.type === 'step-finish') {
|
|
352
361
|
for (const p of currentParts) {
|
|
353
362
|
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
@@ -357,6 +366,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
357
366
|
setTimeout(() => {
|
|
358
367
|
if (abortController.signal.aborted)
|
|
359
368
|
return;
|
|
369
|
+
// Don't restart typing if user needs to respond to a question or permission
|
|
370
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some((ctx) => ctx.thread.id === thread.id);
|
|
371
|
+
const hasPendingPermission = pendingPermissions.has(thread.id);
|
|
372
|
+
if (hasPendingQuestion || hasPendingPermission)
|
|
373
|
+
return;
|
|
360
374
|
stopTyping = startTyping();
|
|
361
375
|
}, 300);
|
|
362
376
|
}
|
|
@@ -391,6 +405,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
391
405
|
continue;
|
|
392
406
|
}
|
|
393
407
|
sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
|
|
408
|
+
// Stop typing - user needs to respond now, not the bot
|
|
409
|
+
if (stopTyping) {
|
|
410
|
+
stopTyping();
|
|
411
|
+
stopTyping = null;
|
|
412
|
+
}
|
|
394
413
|
// Show dropdown instead of text message
|
|
395
414
|
const { messageId, contextHash } = await showPermissionDropdown({
|
|
396
415
|
thread,
|
|
@@ -423,6 +442,11 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
423
442
|
continue;
|
|
424
443
|
}
|
|
425
444
|
sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
|
|
445
|
+
// Stop typing - user needs to respond now, not the bot
|
|
446
|
+
if (stopTyping) {
|
|
447
|
+
stopTyping();
|
|
448
|
+
stopTyping = null;
|
|
449
|
+
}
|
|
426
450
|
// Flush any pending text/reasoning parts before showing the dropdown
|
|
427
451
|
// This ensures text the LLM generated before the question tool is shown first
|
|
428
452
|
for (const p of currentParts) {
|
|
@@ -438,6 +462,13 @@ export async function handleOpencodeSession({ prompt, thread, projectDirectory,
|
|
|
438
462
|
input: { questions: questionRequest.questions },
|
|
439
463
|
});
|
|
440
464
|
}
|
|
465
|
+
else if (event.type === 'session.idle') {
|
|
466
|
+
// Session is done processing - abort to signal completion
|
|
467
|
+
if (event.properties.sessionID === session.id) {
|
|
468
|
+
sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`);
|
|
469
|
+
abortController.abort('finished');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
441
472
|
}
|
|
442
473
|
}
|
|
443
474
|
catch (e) {
|
package/dist/system-message.js
CHANGED
|
@@ -65,5 +65,18 @@ headings are discouraged anyway. instead try to use bold text for titles which r
|
|
|
65
65
|
## diagrams
|
|
66
66
|
|
|
67
67
|
you can create diagrams wrapping them in code blocks.
|
|
68
|
+
|
|
69
|
+
## ending conversations with options
|
|
70
|
+
|
|
71
|
+
IMPORTANT: At the end of each response, especially after completing a task or presenting a plan, use the question tool to offer the user clear options for what to do next.
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
- After showing a plan: offer "Start implementing?" with Yes/No options
|
|
75
|
+
- After completing edits: offer "Commit changes?" with Yes/No options
|
|
76
|
+
- After debugging: offer "How to proceed?" with options like "Apply fix", "Investigate further", "Try different approach"
|
|
77
|
+
|
|
78
|
+
The user can always select "Other" to type a custom response if the provided options don't fit their needs, or if the plan needs updating.
|
|
79
|
+
|
|
80
|
+
This makes the interaction more guided and reduces friction for the user.
|
|
68
81
|
`;
|
|
69
82
|
}
|
|
@@ -31,10 +31,14 @@ function processListToken(list) {
|
|
|
31
31
|
function processListItem(item, prefix) {
|
|
32
32
|
const segments = [];
|
|
33
33
|
let currentText = [];
|
|
34
|
+
// Track if we've seen a code block - text after code uses continuation prefix
|
|
35
|
+
let seenCodeBlock = false;
|
|
34
36
|
const flushText = () => {
|
|
35
37
|
const text = currentText.join('').trim();
|
|
36
38
|
if (text) {
|
|
37
|
-
|
|
39
|
+
// After a code block, use '-' as continuation prefix to avoid repeating numbers
|
|
40
|
+
const effectivePrefix = seenCodeBlock ? '- ' : prefix;
|
|
41
|
+
segments.push({ type: 'list-item', prefix: effectivePrefix, content: text });
|
|
38
42
|
}
|
|
39
43
|
currentText = [];
|
|
40
44
|
};
|
|
@@ -47,6 +51,7 @@ function processListItem(item, prefix) {
|
|
|
47
51
|
type: 'code',
|
|
48
52
|
content: '```' + lang + '\n' + codeToken.text + '\n```\n',
|
|
49
53
|
});
|
|
54
|
+
seenCodeBlock = true;
|
|
50
55
|
}
|
|
51
56
|
else if (token.type === 'list') {
|
|
52
57
|
flushText();
|
|
@@ -211,3 +211,197 @@ test('handles empty list item with code', () => {
|
|
|
211
211
|
"
|
|
212
212
|
`);
|
|
213
213
|
});
|
|
214
|
+
test('numbered list with text after code block', () => {
|
|
215
|
+
const input = `1. First item
|
|
216
|
+
\`\`\`js
|
|
217
|
+
const a = 1
|
|
218
|
+
\`\`\`
|
|
219
|
+
Text after the code
|
|
220
|
+
2. Second item`;
|
|
221
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
222
|
+
expect(result).toMatchInlineSnapshot(`
|
|
223
|
+
"1. First item
|
|
224
|
+
|
|
225
|
+
\`\`\`js
|
|
226
|
+
const a = 1
|
|
227
|
+
\`\`\`
|
|
228
|
+
- Text after the code
|
|
229
|
+
2. Second item"
|
|
230
|
+
`);
|
|
231
|
+
});
|
|
232
|
+
test('numbered list with multiple code blocks and text between', () => {
|
|
233
|
+
const input = `1. First item
|
|
234
|
+
\`\`\`js
|
|
235
|
+
const a = 1
|
|
236
|
+
\`\`\`
|
|
237
|
+
Middle text
|
|
238
|
+
\`\`\`python
|
|
239
|
+
b = 2
|
|
240
|
+
\`\`\`
|
|
241
|
+
Final text
|
|
242
|
+
2. Second item`;
|
|
243
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
244
|
+
expect(result).toMatchInlineSnapshot(`
|
|
245
|
+
"1. First item
|
|
246
|
+
|
|
247
|
+
\`\`\`js
|
|
248
|
+
const a = 1
|
|
249
|
+
\`\`\`
|
|
250
|
+
- Middle text
|
|
251
|
+
|
|
252
|
+
\`\`\`python
|
|
253
|
+
b = 2
|
|
254
|
+
\`\`\`
|
|
255
|
+
- Final text
|
|
256
|
+
2. Second item"
|
|
257
|
+
`);
|
|
258
|
+
});
|
|
259
|
+
test('unordered list with multiple code blocks and text between', () => {
|
|
260
|
+
const input = `- First item
|
|
261
|
+
\`\`\`js
|
|
262
|
+
const a = 1
|
|
263
|
+
\`\`\`
|
|
264
|
+
Middle text
|
|
265
|
+
\`\`\`python
|
|
266
|
+
b = 2
|
|
267
|
+
\`\`\`
|
|
268
|
+
Final text
|
|
269
|
+
- Second item`;
|
|
270
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
271
|
+
expect(result).toMatchInlineSnapshot(`
|
|
272
|
+
"- First item
|
|
273
|
+
|
|
274
|
+
\`\`\`js
|
|
275
|
+
const a = 1
|
|
276
|
+
\`\`\`
|
|
277
|
+
- Middle text
|
|
278
|
+
|
|
279
|
+
\`\`\`python
|
|
280
|
+
b = 2
|
|
281
|
+
\`\`\`
|
|
282
|
+
- Final text
|
|
283
|
+
- Second item"
|
|
284
|
+
`);
|
|
285
|
+
});
|
|
286
|
+
test('numbered list starting from 5', () => {
|
|
287
|
+
const input = `5. Fifth item
|
|
288
|
+
\`\`\`js
|
|
289
|
+
code
|
|
290
|
+
\`\`\`
|
|
291
|
+
Text after
|
|
292
|
+
6. Sixth item`;
|
|
293
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
294
|
+
expect(result).toMatchInlineSnapshot(`
|
|
295
|
+
"5. Fifth item
|
|
296
|
+
|
|
297
|
+
\`\`\`js
|
|
298
|
+
code
|
|
299
|
+
\`\`\`
|
|
300
|
+
- Text after
|
|
301
|
+
6. Sixth item"
|
|
302
|
+
`);
|
|
303
|
+
});
|
|
304
|
+
test('deeply nested list with code', () => {
|
|
305
|
+
const input = `- Level 1
|
|
306
|
+
- Level 2
|
|
307
|
+
- Level 3
|
|
308
|
+
\`\`\`js
|
|
309
|
+
deep code
|
|
310
|
+
\`\`\`
|
|
311
|
+
Text after deep code
|
|
312
|
+
- Another level 3
|
|
313
|
+
- Back to level 2`;
|
|
314
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
315
|
+
expect(result).toMatchInlineSnapshot(`
|
|
316
|
+
"- Level 1
|
|
317
|
+
- Level 2
|
|
318
|
+
- Level 3
|
|
319
|
+
|
|
320
|
+
\`\`\`js
|
|
321
|
+
deep code
|
|
322
|
+
\`\`\`
|
|
323
|
+
- Text after deep code
|
|
324
|
+
- Another level 3- Back to level 2"
|
|
325
|
+
`);
|
|
326
|
+
});
|
|
327
|
+
test('nested numbered list inside unordered with code', () => {
|
|
328
|
+
const input = `- Unordered item
|
|
329
|
+
1. Nested numbered
|
|
330
|
+
\`\`\`js
|
|
331
|
+
code
|
|
332
|
+
\`\`\`
|
|
333
|
+
Text after
|
|
334
|
+
2. Second nested
|
|
335
|
+
- Another unordered`;
|
|
336
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
337
|
+
expect(result).toMatchInlineSnapshot(`
|
|
338
|
+
"- Unordered item
|
|
339
|
+
1. Nested numbered
|
|
340
|
+
|
|
341
|
+
\`\`\`js
|
|
342
|
+
code
|
|
343
|
+
\`\`\`
|
|
344
|
+
- Text after
|
|
345
|
+
2. Second nested- Another unordered"
|
|
346
|
+
`);
|
|
347
|
+
});
|
|
348
|
+
test('code block at end of numbered item no text after', () => {
|
|
349
|
+
const input = `1. First with text
|
|
350
|
+
\`\`\`js
|
|
351
|
+
code here
|
|
352
|
+
\`\`\`
|
|
353
|
+
2. Second item
|
|
354
|
+
3. Third item`;
|
|
355
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
356
|
+
expect(result).toMatchInlineSnapshot(`
|
|
357
|
+
"1. First with text
|
|
358
|
+
|
|
359
|
+
\`\`\`js
|
|
360
|
+
code here
|
|
361
|
+
\`\`\`
|
|
362
|
+
2. Second item
|
|
363
|
+
3. Third item"
|
|
364
|
+
`);
|
|
365
|
+
});
|
|
366
|
+
test('multiple items each with code and text after', () => {
|
|
367
|
+
const input = `1. First
|
|
368
|
+
\`\`\`js
|
|
369
|
+
code1
|
|
370
|
+
\`\`\`
|
|
371
|
+
After first
|
|
372
|
+
2. Second
|
|
373
|
+
\`\`\`python
|
|
374
|
+
code2
|
|
375
|
+
\`\`\`
|
|
376
|
+
After second
|
|
377
|
+
3. Third no code`;
|
|
378
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
379
|
+
expect(result).toMatchInlineSnapshot(`
|
|
380
|
+
"1. First
|
|
381
|
+
|
|
382
|
+
\`\`\`js
|
|
383
|
+
code1
|
|
384
|
+
\`\`\`
|
|
385
|
+
- After first
|
|
386
|
+
2. Second
|
|
387
|
+
|
|
388
|
+
\`\`\`python
|
|
389
|
+
code2
|
|
390
|
+
\`\`\`
|
|
391
|
+
- After second
|
|
392
|
+
3. Third no code"
|
|
393
|
+
`);
|
|
394
|
+
});
|
|
395
|
+
test('code block immediately after list marker', () => {
|
|
396
|
+
const input = `1. \`\`\`js
|
|
397
|
+
immediate code
|
|
398
|
+
\`\`\`
|
|
399
|
+
2. Normal item`;
|
|
400
|
+
const result = unnestCodeBlocksFromLists(input);
|
|
401
|
+
expect(result).toMatchInlineSnapshot(`
|
|
402
|
+
"\`\`\`js
|
|
403
|
+
immediate code
|
|
404
|
+
\`\`\`
|
|
405
|
+
2. Normal item"
|
|
406
|
+
`);
|
|
407
|
+
});
|
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.34",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "tsx --env-file .env src/cli.ts",
|
|
8
8
|
"prepublishOnly": "pnpm tsc",
|
|
@@ -30,30 +30,29 @@
|
|
|
30
30
|
"tsx": "^4.20.5"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@ai-sdk/google": "^2.0.47",
|
|
34
33
|
"@clack/prompts": "^0.11.0",
|
|
35
|
-
"@discordjs/opus": "^0.10.0",
|
|
36
34
|
"@discordjs/voice": "^0.19.0",
|
|
37
35
|
"@google/genai": "^1.34.0",
|
|
38
36
|
"@opencode-ai/sdk": "^1.1.12",
|
|
39
37
|
"@purinton/resampler": "^1.0.4",
|
|
40
|
-
"@snazzah/davey": "^0.1.6",
|
|
41
38
|
"ai": "^5.0.114",
|
|
42
39
|
"better-sqlite3": "^12.3.0",
|
|
43
40
|
"cac": "^6.7.14",
|
|
44
41
|
"discord.js": "^14.16.3",
|
|
45
42
|
"domhandler": "^5.0.3",
|
|
46
43
|
"glob": "^13.0.0",
|
|
47
|
-
"go-try": "^3.0.2",
|
|
48
44
|
"htmlparser2": "^10.0.0",
|
|
49
45
|
"js-yaml": "^4.1.0",
|
|
50
46
|
"marked": "^16.3.0",
|
|
51
47
|
"picocolors": "^1.1.1",
|
|
52
48
|
"pretty-ms": "^9.3.0",
|
|
53
|
-
"prism-media": "^1.3.5",
|
|
54
49
|
"ripgrep-js": "^3.0.0",
|
|
55
50
|
"string-dedent": "^3.0.2",
|
|
56
51
|
"undici": "^7.16.0",
|
|
57
52
|
"zod": "^4.2.1"
|
|
53
|
+
},
|
|
54
|
+
"optionalDependencies": {
|
|
55
|
+
"@discordjs/opus": "^0.10.0",
|
|
56
|
+
"prism-media": "^1.3.5"
|
|
58
57
|
}
|
|
59
58
|
}
|
package/src/cli.ts
CHANGED
|
@@ -279,7 +279,9 @@ async function registerCommands(token: string, appId: string, userCommands: Open
|
|
|
279
279
|
continue
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
|
|
282
|
+
// Sanitize command name: oh-my-opencode uses MCP commands with colons, which Discord doesn't allow
|
|
283
|
+
const sanitizedName = cmd.name.replace(/:/g, '-')
|
|
284
|
+
const commandName = `${sanitizedName}-cmd`
|
|
283
285
|
const description = cmd.description || `Run /${cmd.name} command`
|
|
284
286
|
|
|
285
287
|
commands.push(
|
package/src/discord-utils.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { Lexer } from 'marked'
|
|
12
12
|
import { extractTagsArrays } from './xml.js'
|
|
13
13
|
import { formatMarkdownTables } from './format-tables.js'
|
|
14
|
+
import { limitHeadingDepth } from './limit-heading-depth.js'
|
|
14
15
|
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js'
|
|
15
16
|
import { createLogger } from './logger.js'
|
|
16
17
|
|
|
@@ -201,6 +202,7 @@ export async function sendThreadMessage(
|
|
|
201
202
|
|
|
202
203
|
content = formatMarkdownTables(content)
|
|
203
204
|
content = unnestCodeBlocksFromLists(content)
|
|
205
|
+
content = limitHeadingDepth(content)
|
|
204
206
|
content = escapeBackticksInCodeBlocks(content)
|
|
205
207
|
|
|
206
208
|
// If custom flags provided, send as single message (no chunking)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { expect, test } from 'vitest'
|
|
2
|
+
import { limitHeadingDepth } from './limit-heading-depth.js'
|
|
3
|
+
|
|
4
|
+
test('converts h4 to h3', () => {
|
|
5
|
+
const input = '#### Fourth level heading'
|
|
6
|
+
const result = limitHeadingDepth(input)
|
|
7
|
+
expect(result).toMatchInlineSnapshot(`
|
|
8
|
+
"### Fourth level heading
|
|
9
|
+
"
|
|
10
|
+
`)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('converts h5 to h3', () => {
|
|
14
|
+
const input = '##### Fifth level heading'
|
|
15
|
+
const result = limitHeadingDepth(input)
|
|
16
|
+
expect(result).toMatchInlineSnapshot(`
|
|
17
|
+
"### Fifth level heading
|
|
18
|
+
"
|
|
19
|
+
`)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('converts h6 to h3', () => {
|
|
23
|
+
const input = '###### Sixth level heading'
|
|
24
|
+
const result = limitHeadingDepth(input)
|
|
25
|
+
expect(result).toMatchInlineSnapshot(`
|
|
26
|
+
"### Sixth level heading
|
|
27
|
+
"
|
|
28
|
+
`)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('preserves h3 unchanged', () => {
|
|
32
|
+
const input = '### Third level heading'
|
|
33
|
+
const result = limitHeadingDepth(input)
|
|
34
|
+
expect(result).toMatchInlineSnapshot(`"### Third level heading"`)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('preserves h2 unchanged', () => {
|
|
38
|
+
const input = '## Second level heading'
|
|
39
|
+
const result = limitHeadingDepth(input)
|
|
40
|
+
expect(result).toMatchInlineSnapshot(`"## Second level heading"`)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('preserves h1 unchanged', () => {
|
|
44
|
+
const input = '# First level heading'
|
|
45
|
+
const result = limitHeadingDepth(input)
|
|
46
|
+
expect(result).toMatchInlineSnapshot(`"# First level heading"`)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('handles multiple headings in document', () => {
|
|
50
|
+
const input = `# Title
|
|
51
|
+
|
|
52
|
+
Some text
|
|
53
|
+
|
|
54
|
+
## Section
|
|
55
|
+
|
|
56
|
+
### Subsection
|
|
57
|
+
|
|
58
|
+
#### Too deep
|
|
59
|
+
|
|
60
|
+
##### Even deeper
|
|
61
|
+
|
|
62
|
+
Regular paragraph
|
|
63
|
+
|
|
64
|
+
### Back to normal
|
|
65
|
+
`
|
|
66
|
+
const result = limitHeadingDepth(input)
|
|
67
|
+
expect(result).toMatchInlineSnapshot(`
|
|
68
|
+
"# Title
|
|
69
|
+
|
|
70
|
+
Some text
|
|
71
|
+
|
|
72
|
+
## Section
|
|
73
|
+
|
|
74
|
+
### Subsection
|
|
75
|
+
|
|
76
|
+
### Too deep
|
|
77
|
+
### Even deeper
|
|
78
|
+
Regular paragraph
|
|
79
|
+
|
|
80
|
+
### Back to normal
|
|
81
|
+
"
|
|
82
|
+
`)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('preserves heading with inline formatting', () => {
|
|
86
|
+
const input = '#### Heading with **bold** and `code`'
|
|
87
|
+
const result = limitHeadingDepth(input)
|
|
88
|
+
expect(result).toMatchInlineSnapshot(`
|
|
89
|
+
"### Heading with **bold** and \`code\`
|
|
90
|
+
"
|
|
91
|
+
`)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('handles empty markdown', () => {
|
|
95
|
+
const result = limitHeadingDepth('')
|
|
96
|
+
expect(result).toMatchInlineSnapshot(`""`)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('handles markdown with no headings', () => {
|
|
100
|
+
const input = 'Just some text\n\nAnd more text'
|
|
101
|
+
const result = limitHeadingDepth(input)
|
|
102
|
+
expect(result).toMatchInlineSnapshot(`
|
|
103
|
+
"Just some text
|
|
104
|
+
|
|
105
|
+
And more text"
|
|
106
|
+
`)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('allows custom maxDepth', () => {
|
|
110
|
+
const input = '### Third level'
|
|
111
|
+
const result = limitHeadingDepth(input, 2)
|
|
112
|
+
expect(result).toMatchInlineSnapshot(`
|
|
113
|
+
"## Third level
|
|
114
|
+
"
|
|
115
|
+
`)
|
|
116
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Limit heading depth for Discord.
|
|
2
|
+
// Discord only supports headings up to ### (h3), so this converts
|
|
3
|
+
// ####, #####, etc. to ### to maintain consistent rendering.
|
|
4
|
+
|
|
5
|
+
import { Lexer, type Tokens } from 'marked'
|
|
6
|
+
|
|
7
|
+
export function limitHeadingDepth(markdown: string, maxDepth = 3): string {
|
|
8
|
+
const lexer = new Lexer()
|
|
9
|
+
const tokens = lexer.lex(markdown)
|
|
10
|
+
|
|
11
|
+
let result = ''
|
|
12
|
+
for (const token of tokens) {
|
|
13
|
+
if (token.type === 'heading') {
|
|
14
|
+
const heading = token as Tokens.Heading
|
|
15
|
+
if (heading.depth > maxDepth) {
|
|
16
|
+
const hashes = '#'.repeat(maxDepth)
|
|
17
|
+
result += hashes + ' ' + heading.text + '\n'
|
|
18
|
+
} else {
|
|
19
|
+
result += token.raw
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
result += token.raw
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return result
|
|
26
|
+
}
|
package/src/logger.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { log } from '@clack/prompts'
|
|
|
6
6
|
import fs from 'node:fs'
|
|
7
7
|
import path, { dirname } from 'node:path'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import util from 'node:util'
|
|
9
10
|
|
|
10
11
|
const __filename = fileURLToPath(import.meta.url)
|
|
11
12
|
const __dirname = dirname(__filename)
|
|
@@ -22,36 +23,43 @@ if (isDev) {
|
|
|
22
23
|
fs.writeFileSync(logFilePath, `--- kimaki log started at ${new Date().toISOString()} ---\n`)
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function
|
|
26
|
+
function formatArg(arg: unknown): string {
|
|
27
|
+
if (typeof arg === 'string') {
|
|
28
|
+
return arg
|
|
29
|
+
}
|
|
30
|
+
return util.inspect(arg, { colors: true, depth: 4 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeToFile(level: string, prefix: string, args: unknown[]) {
|
|
26
34
|
if (!isDev) {
|
|
27
35
|
return
|
|
28
36
|
}
|
|
29
37
|
const timestamp = new Date().toISOString()
|
|
30
|
-
const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(
|
|
38
|
+
const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(formatArg).join(' ')}\n`
|
|
31
39
|
fs.appendFileSync(logFilePath, message)
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
export function createLogger(prefix: string) {
|
|
35
43
|
return {
|
|
36
|
-
log: (...args:
|
|
44
|
+
log: (...args: unknown[]) => {
|
|
37
45
|
writeToFile('INFO', prefix, args)
|
|
38
|
-
log.info([`[${prefix}]`, ...args.map(
|
|
46
|
+
log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
|
|
39
47
|
},
|
|
40
|
-
error: (...args:
|
|
48
|
+
error: (...args: unknown[]) => {
|
|
41
49
|
writeToFile('ERROR', prefix, args)
|
|
42
|
-
log.error([`[${prefix}]`, ...args.map(
|
|
50
|
+
log.error([`[${prefix}]`, ...args.map(formatArg)].join(' '))
|
|
43
51
|
},
|
|
44
|
-
warn: (...args:
|
|
52
|
+
warn: (...args: unknown[]) => {
|
|
45
53
|
writeToFile('WARN', prefix, args)
|
|
46
|
-
log.warn([`[${prefix}]`, ...args.map(
|
|
54
|
+
log.warn([`[${prefix}]`, ...args.map(formatArg)].join(' '))
|
|
47
55
|
},
|
|
48
|
-
info: (...args:
|
|
56
|
+
info: (...args: unknown[]) => {
|
|
49
57
|
writeToFile('INFO', prefix, args)
|
|
50
|
-
log.info([`[${prefix}]`, ...args.map(
|
|
58
|
+
log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
|
|
51
59
|
},
|
|
52
|
-
debug: (...args:
|
|
60
|
+
debug: (...args: unknown[]) => {
|
|
53
61
|
writeToFile('DEBUG', prefix, args)
|
|
54
|
-
log.info([`[${prefix}]`, ...args.map(
|
|
62
|
+
log.info([`[${prefix}]`, ...args.map(formatArg)].join(' '))
|
|
55
63
|
},
|
|
56
64
|
}
|
|
57
65
|
}
|
|
@@ -27,7 +27,7 @@ describe('formatTodoList', () => {
|
|
|
27
27
|
},
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
expect(formatTodoList(part)).toMatchInlineSnapshot(`"
|
|
30
|
+
expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒉ **second task**"`)
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
test('formats double digit todo numbers', () => {
|
|
@@ -53,7 +53,7 @@ describe('formatTodoList', () => {
|
|
|
53
53
|
},
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
expect(formatTodoList(part)).toMatchInlineSnapshot(`"
|
|
56
|
+
expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒓ **task 12**"`)
|
|
57
57
|
})
|
|
58
58
|
|
|
59
59
|
test('lowercases first letter of content', () => {
|
|
@@ -76,6 +76,6 @@ describe('formatTodoList', () => {
|
|
|
76
76
|
},
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
expect(formatTodoList(part)).toMatchInlineSnapshot(`"
|
|
79
|
+
expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒈ **fix the bug**"`)
|
|
80
80
|
})
|
|
81
81
|
})
|
|
@@ -245,10 +245,10 @@ export function formatTodoList(part: Part): string {
|
|
|
245
245
|
})
|
|
246
246
|
const activeTodo = todos[activeIndex]
|
|
247
247
|
if (activeIndex === -1 || !activeTodo) return ''
|
|
248
|
-
//
|
|
249
|
-
const
|
|
248
|
+
// digit-with-period ⒈-⒛ for 1-20, fallback to regular number for 21+
|
|
249
|
+
const digitWithPeriod = '⒈⒉⒊⒋⒌⒍⒎⒏⒐⒑⒒⒓⒔⒕⒖⒗⒘⒙⒚⒛'
|
|
250
250
|
const todoNumber = activeIndex + 1
|
|
251
|
-
const num = todoNumber <= 20 ?
|
|
251
|
+
const num = todoNumber <= 20 ? digitWithPeriod[todoNumber - 1] : `${todoNumber}.`
|
|
252
252
|
const content = activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1)
|
|
253
253
|
return `${num} **${escapeInlineMarkdown(content)}**`
|
|
254
254
|
}
|
package/src/session-handler.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { formatPart } from './message-formatting.js'
|
|
|
13
13
|
import { getOpencodeSystemMessage } from './system-message.js'
|
|
14
14
|
import { createLogger } from './logger.js'
|
|
15
15
|
import { isAbortError } from './utils.js'
|
|
16
|
-
import { showAskUserQuestionDropdowns, cancelPendingQuestion } from './commands/ask-question.js'
|
|
16
|
+
import { showAskUserQuestionDropdowns, cancelPendingQuestion, pendingQuestionContexts } from './commands/ask-question.js'
|
|
17
17
|
import { showPermissionDropdown, cleanupPermissionContext } from './commands/permissions.js'
|
|
18
18
|
|
|
19
19
|
const sessionLogger = createLogger('SESSION')
|
|
@@ -239,11 +239,10 @@ export async function handleOpencodeSession({
|
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
// Cancel any pending question tool if user sends a new message
|
|
242
|
+
// Cancel any pending question tool if user sends a new message (silently, no thread message)
|
|
243
243
|
const questionCancelled = await cancelPendingQuestion(thread.id)
|
|
244
244
|
if (questionCancelled) {
|
|
245
245
|
sessionLogger.log(`[QUESTION] Cancelled pending question due to new message`)
|
|
246
|
-
await sendThreadMessage(thread, `⚠️ Previous question cancelled - processing your new message`)
|
|
247
246
|
}
|
|
248
247
|
|
|
249
248
|
const abortController = new AbortController()
|
|
@@ -433,7 +432,14 @@ export async function handleOpencodeSession({
|
|
|
433
432
|
}
|
|
434
433
|
|
|
435
434
|
if (part.type === 'step-start') {
|
|
436
|
-
|
|
435
|
+
// Don't start typing if user needs to respond to a question or permission
|
|
436
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
|
|
437
|
+
(ctx) => ctx.thread.id === thread.id,
|
|
438
|
+
)
|
|
439
|
+
const hasPendingPermission = pendingPermissions.has(thread.id)
|
|
440
|
+
if (!hasPendingQuestion && !hasPendingPermission) {
|
|
441
|
+
stopTyping = startTyping()
|
|
442
|
+
}
|
|
437
443
|
}
|
|
438
444
|
|
|
439
445
|
if (part.type === 'tool' && part.state.status === 'running') {
|
|
@@ -475,6 +481,12 @@ export async function handleOpencodeSession({
|
|
|
475
481
|
await sendPartMessage(part)
|
|
476
482
|
}
|
|
477
483
|
|
|
484
|
+
// Send text parts when complete (time.end is set)
|
|
485
|
+
// Text parts stream incrementally; only send when finished to avoid partial text
|
|
486
|
+
if (part.type === 'text' && part.time?.end) {
|
|
487
|
+
await sendPartMessage(part)
|
|
488
|
+
}
|
|
489
|
+
|
|
478
490
|
if (part.type === 'step-finish') {
|
|
479
491
|
for (const p of currentParts) {
|
|
480
492
|
if (p.type !== 'step-start' && p.type !== 'step-finish') {
|
|
@@ -483,6 +495,12 @@ export async function handleOpencodeSession({
|
|
|
483
495
|
}
|
|
484
496
|
setTimeout(() => {
|
|
485
497
|
if (abortController.signal.aborted) return
|
|
498
|
+
// Don't restart typing if user needs to respond to a question or permission
|
|
499
|
+
const hasPendingQuestion = [...pendingQuestionContexts.values()].some(
|
|
500
|
+
(ctx) => ctx.thread.id === thread.id,
|
|
501
|
+
)
|
|
502
|
+
const hasPendingPermission = pendingPermissions.has(thread.id)
|
|
503
|
+
if (hasPendingQuestion || hasPendingPermission) return
|
|
486
504
|
stopTyping = startTyping()
|
|
487
505
|
}, 300)
|
|
488
506
|
}
|
|
@@ -527,6 +545,12 @@ export async function handleOpencodeSession({
|
|
|
527
545
|
`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`,
|
|
528
546
|
)
|
|
529
547
|
|
|
548
|
+
// Stop typing - user needs to respond now, not the bot
|
|
549
|
+
if (stopTyping) {
|
|
550
|
+
stopTyping()
|
|
551
|
+
stopTyping = null
|
|
552
|
+
}
|
|
553
|
+
|
|
530
554
|
// Show dropdown instead of text message
|
|
531
555
|
const { messageId, contextHash } = await showPermissionDropdown({
|
|
532
556
|
thread,
|
|
@@ -569,6 +593,12 @@ export async function handleOpencodeSession({
|
|
|
569
593
|
`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
|
|
570
594
|
)
|
|
571
595
|
|
|
596
|
+
// Stop typing - user needs to respond now, not the bot
|
|
597
|
+
if (stopTyping) {
|
|
598
|
+
stopTyping()
|
|
599
|
+
stopTyping = null
|
|
600
|
+
}
|
|
601
|
+
|
|
572
602
|
// Flush any pending text/reasoning parts before showing the dropdown
|
|
573
603
|
// This ensures text the LLM generated before the question tool is shown first
|
|
574
604
|
for (const p of currentParts) {
|
|
@@ -584,6 +614,12 @@ export async function handleOpencodeSession({
|
|
|
584
614
|
requestId: questionRequest.id,
|
|
585
615
|
input: { questions: questionRequest.questions },
|
|
586
616
|
})
|
|
617
|
+
} else if (event.type === 'session.idle') {
|
|
618
|
+
// Session is done processing - abort to signal completion
|
|
619
|
+
if (event.properties.sessionID === session.id) {
|
|
620
|
+
sessionLogger.log(`[SESSION IDLE] Session ${session.id} is idle, aborting`)
|
|
621
|
+
abortController.abort('finished')
|
|
622
|
+
}
|
|
587
623
|
}
|
|
588
624
|
}
|
|
589
625
|
} catch (e) {
|
package/src/system-message.ts
CHANGED
|
@@ -66,5 +66,18 @@ headings are discouraged anyway. instead try to use bold text for titles which r
|
|
|
66
66
|
## diagrams
|
|
67
67
|
|
|
68
68
|
you can create diagrams wrapping them in code blocks.
|
|
69
|
+
|
|
70
|
+
## ending conversations with options
|
|
71
|
+
|
|
72
|
+
IMPORTANT: At the end of each response, especially after completing a task or presenting a plan, use the question tool to offer the user clear options for what to do next.
|
|
73
|
+
|
|
74
|
+
Examples:
|
|
75
|
+
- After showing a plan: offer "Start implementing?" with Yes/No options
|
|
76
|
+
- After completing edits: offer "Commit changes?" with Yes/No options
|
|
77
|
+
- After debugging: offer "How to proceed?" with options like "Apply fix", "Investigate further", "Try different approach"
|
|
78
|
+
|
|
79
|
+
The user can always select "Other" to type a custom response if the provided options don't fit their needs, or if the plan needs updating.
|
|
80
|
+
|
|
81
|
+
This makes the interaction more guided and reduces friction for the user.
|
|
69
82
|
`
|
|
70
83
|
}
|
|
@@ -223,3 +223,206 @@ test('handles empty list item with code', () => {
|
|
|
223
223
|
"
|
|
224
224
|
`)
|
|
225
225
|
})
|
|
226
|
+
|
|
227
|
+
test('numbered list with text after code block', () => {
|
|
228
|
+
const input = `1. First item
|
|
229
|
+
\`\`\`js
|
|
230
|
+
const a = 1
|
|
231
|
+
\`\`\`
|
|
232
|
+
Text after the code
|
|
233
|
+
2. Second item`
|
|
234
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
235
|
+
expect(result).toMatchInlineSnapshot(`
|
|
236
|
+
"1. First item
|
|
237
|
+
|
|
238
|
+
\`\`\`js
|
|
239
|
+
const a = 1
|
|
240
|
+
\`\`\`
|
|
241
|
+
- Text after the code
|
|
242
|
+
2. Second item"
|
|
243
|
+
`)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('numbered list with multiple code blocks and text between', () => {
|
|
247
|
+
const input = `1. First item
|
|
248
|
+
\`\`\`js
|
|
249
|
+
const a = 1
|
|
250
|
+
\`\`\`
|
|
251
|
+
Middle text
|
|
252
|
+
\`\`\`python
|
|
253
|
+
b = 2
|
|
254
|
+
\`\`\`
|
|
255
|
+
Final text
|
|
256
|
+
2. Second item`
|
|
257
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
258
|
+
expect(result).toMatchInlineSnapshot(`
|
|
259
|
+
"1. First item
|
|
260
|
+
|
|
261
|
+
\`\`\`js
|
|
262
|
+
const a = 1
|
|
263
|
+
\`\`\`
|
|
264
|
+
- Middle text
|
|
265
|
+
|
|
266
|
+
\`\`\`python
|
|
267
|
+
b = 2
|
|
268
|
+
\`\`\`
|
|
269
|
+
- Final text
|
|
270
|
+
2. Second item"
|
|
271
|
+
`)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('unordered list with multiple code blocks and text between', () => {
|
|
275
|
+
const input = `- First item
|
|
276
|
+
\`\`\`js
|
|
277
|
+
const a = 1
|
|
278
|
+
\`\`\`
|
|
279
|
+
Middle text
|
|
280
|
+
\`\`\`python
|
|
281
|
+
b = 2
|
|
282
|
+
\`\`\`
|
|
283
|
+
Final text
|
|
284
|
+
- Second item`
|
|
285
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
286
|
+
expect(result).toMatchInlineSnapshot(`
|
|
287
|
+
"- First item
|
|
288
|
+
|
|
289
|
+
\`\`\`js
|
|
290
|
+
const a = 1
|
|
291
|
+
\`\`\`
|
|
292
|
+
- Middle text
|
|
293
|
+
|
|
294
|
+
\`\`\`python
|
|
295
|
+
b = 2
|
|
296
|
+
\`\`\`
|
|
297
|
+
- Final text
|
|
298
|
+
- Second item"
|
|
299
|
+
`)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('numbered list starting from 5', () => {
|
|
303
|
+
const input = `5. Fifth item
|
|
304
|
+
\`\`\`js
|
|
305
|
+
code
|
|
306
|
+
\`\`\`
|
|
307
|
+
Text after
|
|
308
|
+
6. Sixth item`
|
|
309
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
310
|
+
expect(result).toMatchInlineSnapshot(`
|
|
311
|
+
"5. Fifth item
|
|
312
|
+
|
|
313
|
+
\`\`\`js
|
|
314
|
+
code
|
|
315
|
+
\`\`\`
|
|
316
|
+
- Text after
|
|
317
|
+
6. Sixth item"
|
|
318
|
+
`)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('deeply nested list with code', () => {
|
|
322
|
+
const input = `- Level 1
|
|
323
|
+
- Level 2
|
|
324
|
+
- Level 3
|
|
325
|
+
\`\`\`js
|
|
326
|
+
deep code
|
|
327
|
+
\`\`\`
|
|
328
|
+
Text after deep code
|
|
329
|
+
- Another level 3
|
|
330
|
+
- Back to level 2`
|
|
331
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
332
|
+
expect(result).toMatchInlineSnapshot(`
|
|
333
|
+
"- Level 1
|
|
334
|
+
- Level 2
|
|
335
|
+
- Level 3
|
|
336
|
+
|
|
337
|
+
\`\`\`js
|
|
338
|
+
deep code
|
|
339
|
+
\`\`\`
|
|
340
|
+
- Text after deep code
|
|
341
|
+
- Another level 3- Back to level 2"
|
|
342
|
+
`)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('nested numbered list inside unordered with code', () => {
|
|
346
|
+
const input = `- Unordered item
|
|
347
|
+
1. Nested numbered
|
|
348
|
+
\`\`\`js
|
|
349
|
+
code
|
|
350
|
+
\`\`\`
|
|
351
|
+
Text after
|
|
352
|
+
2. Second nested
|
|
353
|
+
- Another unordered`
|
|
354
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
355
|
+
expect(result).toMatchInlineSnapshot(`
|
|
356
|
+
"- Unordered item
|
|
357
|
+
1. Nested numbered
|
|
358
|
+
|
|
359
|
+
\`\`\`js
|
|
360
|
+
code
|
|
361
|
+
\`\`\`
|
|
362
|
+
- Text after
|
|
363
|
+
2. Second nested- Another unordered"
|
|
364
|
+
`)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('code block at end of numbered item no text after', () => {
|
|
368
|
+
const input = `1. First with text
|
|
369
|
+
\`\`\`js
|
|
370
|
+
code here
|
|
371
|
+
\`\`\`
|
|
372
|
+
2. Second item
|
|
373
|
+
3. Third item`
|
|
374
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
375
|
+
expect(result).toMatchInlineSnapshot(`
|
|
376
|
+
"1. First with text
|
|
377
|
+
|
|
378
|
+
\`\`\`js
|
|
379
|
+
code here
|
|
380
|
+
\`\`\`
|
|
381
|
+
2. Second item
|
|
382
|
+
3. Third item"
|
|
383
|
+
`)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
test('multiple items each with code and text after', () => {
|
|
387
|
+
const input = `1. First
|
|
388
|
+
\`\`\`js
|
|
389
|
+
code1
|
|
390
|
+
\`\`\`
|
|
391
|
+
After first
|
|
392
|
+
2. Second
|
|
393
|
+
\`\`\`python
|
|
394
|
+
code2
|
|
395
|
+
\`\`\`
|
|
396
|
+
After second
|
|
397
|
+
3. Third no code`
|
|
398
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
399
|
+
expect(result).toMatchInlineSnapshot(`
|
|
400
|
+
"1. First
|
|
401
|
+
|
|
402
|
+
\`\`\`js
|
|
403
|
+
code1
|
|
404
|
+
\`\`\`
|
|
405
|
+
- After first
|
|
406
|
+
2. Second
|
|
407
|
+
|
|
408
|
+
\`\`\`python
|
|
409
|
+
code2
|
|
410
|
+
\`\`\`
|
|
411
|
+
- After second
|
|
412
|
+
3. Third no code"
|
|
413
|
+
`)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('code block immediately after list marker', () => {
|
|
417
|
+
const input = `1. \`\`\`js
|
|
418
|
+
immediate code
|
|
419
|
+
\`\`\`
|
|
420
|
+
2. Normal item`
|
|
421
|
+
const result = unnestCodeBlocksFromLists(input)
|
|
422
|
+
expect(result).toMatchInlineSnapshot(`
|
|
423
|
+
"\`\`\`js
|
|
424
|
+
immediate code
|
|
425
|
+
\`\`\`
|
|
426
|
+
2. Normal item"
|
|
427
|
+
`)
|
|
428
|
+
})
|
|
@@ -41,11 +41,15 @@ function processListToken(list: Tokens.List): Segment[] {
|
|
|
41
41
|
function processListItem(item: Tokens.ListItem, prefix: string): Segment[] {
|
|
42
42
|
const segments: Segment[] = []
|
|
43
43
|
let currentText: string[] = []
|
|
44
|
+
// Track if we've seen a code block - text after code uses continuation prefix
|
|
45
|
+
let seenCodeBlock = false
|
|
44
46
|
|
|
45
47
|
const flushText = (): void => {
|
|
46
48
|
const text = currentText.join('').trim()
|
|
47
49
|
if (text) {
|
|
48
|
-
|
|
50
|
+
// After a code block, use '-' as continuation prefix to avoid repeating numbers
|
|
51
|
+
const effectivePrefix = seenCodeBlock ? '- ' : prefix
|
|
52
|
+
segments.push({ type: 'list-item', prefix: effectivePrefix, content: text })
|
|
49
53
|
}
|
|
50
54
|
currentText = []
|
|
51
55
|
}
|
|
@@ -59,6 +63,7 @@ function processListItem(item: Tokens.ListItem, prefix: string): Segment[] {
|
|
|
59
63
|
type: 'code',
|
|
60
64
|
content: '```' + lang + '\n' + codeToken.text + '\n```\n',
|
|
61
65
|
})
|
|
66
|
+
seenCodeBlock = true
|
|
62
67
|
} else if (token.type === 'list') {
|
|
63
68
|
flushText()
|
|
64
69
|
// Recursively process nested list - segments bubble up
|