kimaki 0.4.25 → 0.4.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/acp-client.test.js +149 -0
  2. package/dist/channel-management.js +11 -9
  3. package/dist/cli.js +58 -18
  4. package/dist/commands/add-project.js +1 -0
  5. package/dist/commands/agent.js +152 -0
  6. package/dist/commands/ask-question.js +184 -0
  7. package/dist/commands/model.js +23 -4
  8. package/dist/commands/permissions.js +101 -105
  9. package/dist/commands/session.js +1 -3
  10. package/dist/commands/user-command.js +145 -0
  11. package/dist/database.js +51 -0
  12. package/dist/discord-bot.js +32 -32
  13. package/dist/discord-utils.js +71 -14
  14. package/dist/interaction-handler.js +25 -8
  15. package/dist/logger.js +43 -5
  16. package/dist/markdown.js +104 -0
  17. package/dist/markdown.test.js +31 -1
  18. package/dist/message-formatting.js +72 -22
  19. package/dist/message-formatting.test.js +73 -0
  20. package/dist/opencode.js +70 -16
  21. package/dist/session-handler.js +142 -66
  22. package/dist/system-message.js +4 -51
  23. package/dist/voice-handler.js +18 -8
  24. package/dist/voice.js +28 -12
  25. package/package.json +14 -13
  26. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  27. package/src/__snapshots__/compact-session-context.md +47 -0
  28. package/src/channel-management.ts +20 -8
  29. package/src/cli.ts +73 -19
  30. package/src/commands/add-project.ts +1 -0
  31. package/src/commands/agent.ts +201 -0
  32. package/src/commands/ask-question.ts +277 -0
  33. package/src/commands/fork.ts +1 -2
  34. package/src/commands/model.ts +24 -4
  35. package/src/commands/permissions.ts +139 -114
  36. package/src/commands/session.ts +1 -3
  37. package/src/commands/user-command.ts +178 -0
  38. package/src/database.ts +61 -0
  39. package/src/discord-bot.ts +36 -33
  40. package/src/discord-utils.ts +76 -14
  41. package/src/interaction-handler.ts +31 -10
  42. package/src/logger.ts +47 -10
  43. package/src/markdown.test.ts +45 -1
  44. package/src/markdown.ts +132 -0
  45. package/src/message-formatting.test.ts +81 -0
  46. package/src/message-formatting.ts +93 -25
  47. package/src/opencode.ts +80 -21
  48. package/src/session-handler.ts +190 -97
  49. package/src/system-message.ts +4 -51
  50. package/src/voice-handler.ts +20 -9
  51. package/src/voice.ts +32 -13
  52. package/LICENSE +0 -21
@@ -56,29 +56,86 @@ export function splitMarkdownForDiscord({ content, maxLength, }) {
56
56
  const chunks = [];
57
57
  let currentChunk = '';
58
58
  let currentLang = null;
59
+ // helper to split a long line into smaller pieces at word boundaries or hard breaks
60
+ const splitLongLine = (text, available, inCode) => {
61
+ const pieces = [];
62
+ let remaining = text;
63
+ while (remaining.length > available) {
64
+ let splitAt = available;
65
+ // for non-code, try to split at word boundary
66
+ if (!inCode) {
67
+ const lastSpace = remaining.lastIndexOf(' ', available);
68
+ if (lastSpace > available * 0.5) {
69
+ splitAt = lastSpace + 1;
70
+ }
71
+ }
72
+ pieces.push(remaining.slice(0, splitAt));
73
+ remaining = remaining.slice(splitAt);
74
+ }
75
+ if (remaining) {
76
+ pieces.push(remaining);
77
+ }
78
+ return pieces;
79
+ };
59
80
  for (const line of lines) {
60
81
  const wouldExceed = currentChunk.length + line.text.length > maxLength;
61
- if (wouldExceed && currentChunk) {
62
- if (currentLang !== null) {
63
- currentChunk += '```\n';
64
- }
65
- chunks.push(currentChunk);
66
- if (line.isClosingFence && currentLang !== null) {
67
- currentChunk = '';
82
+ if (wouldExceed) {
83
+ // handle case where single line is longer than maxLength
84
+ if (line.text.length > maxLength) {
85
+ // first, flush current chunk if any
86
+ if (currentChunk) {
87
+ if (currentLang !== null) {
88
+ currentChunk += '```\n';
89
+ }
90
+ chunks.push(currentChunk);
91
+ currentChunk = '';
92
+ }
93
+ // calculate overhead for code block markers
94
+ const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0;
95
+ const availablePerChunk = maxLength - codeBlockOverhead - 50; // safety margin
96
+ const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock);
97
+ for (let i = 0; i < pieces.length; i++) {
98
+ const piece = pieces[i];
99
+ if (line.inCodeBlock) {
100
+ chunks.push('```' + line.lang + '\n' + piece + '```\n');
101
+ }
102
+ else {
103
+ chunks.push(piece);
104
+ }
105
+ }
68
106
  currentLang = null;
69
107
  continue;
70
108
  }
71
- if (line.inCodeBlock || line.isOpeningFence) {
72
- const lang = line.lang;
73
- currentChunk = '```' + lang + '\n';
74
- if (!line.isOpeningFence) {
75
- currentChunk += line.text;
109
+ // normal case: line fits in a chunk but current chunk would overflow
110
+ if (currentChunk) {
111
+ if (currentLang !== null) {
112
+ currentChunk += '```\n';
113
+ }
114
+ chunks.push(currentChunk);
115
+ if (line.isClosingFence && currentLang !== null) {
116
+ currentChunk = '';
117
+ currentLang = null;
118
+ continue;
119
+ }
120
+ if (line.inCodeBlock || line.isOpeningFence) {
121
+ const lang = line.lang;
122
+ currentChunk = '```' + lang + '\n';
123
+ if (!line.isOpeningFence) {
124
+ currentChunk += line.text;
125
+ }
126
+ currentLang = lang;
127
+ }
128
+ else {
129
+ currentChunk = line.text;
130
+ currentLang = null;
76
131
  }
77
- currentLang = lang;
78
132
  }
79
133
  else {
134
+ // currentChunk is empty but line still exceeds - shouldn't happen after above check
80
135
  currentChunk = line.text;
81
- currentLang = null;
136
+ if (line.inCodeBlock || line.isOpeningFence) {
137
+ currentLang = line.lang;
138
+ }
82
139
  }
83
140
  }
84
141
  else {
@@ -6,13 +6,16 @@ import { handleSessionCommand, handleSessionAutocomplete } from './commands/sess
6
6
  import { handleResumeCommand, handleResumeAutocomplete } from './commands/resume.js';
7
7
  import { handleAddProjectCommand, handleAddProjectAutocomplete } from './commands/add-project.js';
8
8
  import { handleCreateNewProjectCommand } from './commands/create-new-project.js';
9
- import { handleAcceptCommand, handleRejectCommand } from './commands/permissions.js';
9
+ import { handlePermissionSelectMenu } from './commands/permissions.js';
10
10
  import { handleAbortCommand } from './commands/abort.js';
11
11
  import { handleShareCommand } from './commands/share.js';
12
12
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
13
13
  import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu } from './commands/model.js';
14
+ import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js';
15
+ import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
14
16
  import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js';
15
17
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
18
+ import { handleUserCommand } from './commands/user-command.js';
16
19
  import { createLogger } from './logger.js';
17
20
  const interactionLogger = createLogger('INTERACTION');
18
21
  export function registerInteractionHandler({ discordClient, appId, }) {
@@ -55,14 +58,8 @@ export function registerInteractionHandler({ discordClient, appId, }) {
55
58
  case 'create-new-project':
56
59
  await handleCreateNewProjectCommand({ command: interaction, appId });
57
60
  return;
58
- case 'accept':
59
- case 'accept-always':
60
- await handleAcceptCommand({ command: interaction, appId });
61
- return;
62
- case 'reject':
63
- await handleRejectCommand({ command: interaction, appId });
64
- return;
65
61
  case 'abort':
62
+ case 'stop':
66
63
  await handleAbortCommand({ command: interaction, appId });
67
64
  return;
68
65
  case 'share':
@@ -74,6 +71,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
74
71
  case 'model':
75
72
  await handleModelCommand({ interaction, appId });
76
73
  return;
74
+ case 'agent':
75
+ await handleAgentCommand({ interaction, appId });
76
+ return;
77
77
  case 'queue':
78
78
  await handleQueueCommand({ command: interaction, appId });
79
79
  return;
@@ -87,6 +87,11 @@ export function registerInteractionHandler({ discordClient, appId, }) {
87
87
  await handleRedoCommand({ command: interaction, appId });
88
88
  return;
89
89
  }
90
+ // Handle user-defined commands (ending with -cmd suffix)
91
+ if (interaction.commandName.endsWith('-cmd')) {
92
+ await handleUserCommand({ command: interaction, appId });
93
+ return;
94
+ }
90
95
  return;
91
96
  }
92
97
  if (interaction.isStringSelectMenu()) {
@@ -103,6 +108,18 @@ export function registerInteractionHandler({ discordClient, appId, }) {
103
108
  await handleModelSelectMenu(interaction);
104
109
  return;
105
110
  }
111
+ if (customId.startsWith('agent_select:')) {
112
+ await handleAgentSelectMenu(interaction);
113
+ return;
114
+ }
115
+ if (customId.startsWith('ask_question:')) {
116
+ await handleAskQuestionSelectMenu(interaction);
117
+ return;
118
+ }
119
+ if (customId.startsWith('permission:')) {
120
+ await handlePermissionSelectMenu(interaction);
121
+ return;
122
+ }
106
123
  return;
107
124
  }
108
125
  }
package/dist/logger.js CHANGED
@@ -2,12 +2,50 @@
2
2
  // Creates loggers with consistent prefixes for different subsystems
3
3
  // (DISCORD, VOICE, SESSION, etc.) for easier debugging.
4
4
  import { log } from '@clack/prompts';
5
+ import fs from 'node:fs';
6
+ import path, { dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ const isDev = !__dirname.includes('node_modules');
11
+ const logFilePath = path.join(__dirname, '..', 'tmp', 'kimaki.log');
12
+ // reset log file on startup in dev mode
13
+ if (isDev) {
14
+ const logDir = path.dirname(logFilePath);
15
+ if (!fs.existsSync(logDir)) {
16
+ fs.mkdirSync(logDir, { recursive: true });
17
+ }
18
+ fs.writeFileSync(logFilePath, `--- kimaki log started at ${new Date().toISOString()} ---\n`);
19
+ }
20
+ function writeToFile(level, prefix, args) {
21
+ if (!isDev) {
22
+ return;
23
+ }
24
+ const timestamp = new Date().toISOString();
25
+ const message = `[${timestamp}] [${level}] [${prefix}] ${args.map((arg) => String(arg)).join(' ')}\n`;
26
+ fs.appendFileSync(logFilePath, message);
27
+ }
5
28
  export function createLogger(prefix) {
6
29
  return {
7
- log: (...args) => log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
8
- error: (...args) => log.error([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
9
- warn: (...args) => log.warn([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
10
- info: (...args) => log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
11
- debug: (...args) => log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
30
+ log: (...args) => {
31
+ writeToFile('INFO', prefix, args);
32
+ log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '));
33
+ },
34
+ error: (...args) => {
35
+ writeToFile('ERROR', prefix, args);
36
+ log.error([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '));
37
+ },
38
+ warn: (...args) => {
39
+ writeToFile('WARN', prefix, args);
40
+ log.warn([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '));
41
+ },
42
+ info: (...args) => {
43
+ writeToFile('INFO', prefix, args);
44
+ log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '));
45
+ },
46
+ debug: (...args) => {
47
+ writeToFile('DEBUG', prefix, args);
48
+ log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '));
49
+ },
12
50
  };
13
51
  }
package/dist/markdown.js CHANGED
@@ -4,6 +4,8 @@
4
4
  import * as yaml from 'js-yaml';
5
5
  import { formatDateTime } from './utils.js';
6
6
  import { extractNonXmlContent } from './xml.js';
7
+ import { createLogger } from './logger.js';
8
+ const markdownLogger = createLogger('MARKDOWN');
7
9
  export class ShareMarkdown {
8
10
  client;
9
11
  constructor(client) {
@@ -204,3 +206,105 @@ export class ShareMarkdown {
204
206
  return `${minutes}m ${seconds}s`;
205
207
  }
206
208
  }
209
+ /**
210
+ * Generate compact session context for voice transcription.
211
+ * Includes system prompt (optional), user messages, assistant text,
212
+ * and tool calls in compact form (name + params only, no output).
213
+ */
214
+ export async function getCompactSessionContext({ client, sessionId, includeSystemPrompt = false, maxMessages = 20, }) {
215
+ try {
216
+ const messagesResponse = await client.session.messages({
217
+ path: { id: sessionId },
218
+ });
219
+ const messages = messagesResponse.data || [];
220
+ const lines = [];
221
+ // Get system prompt if requested
222
+ // Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
223
+ // 1. session.system field (if available in future SDK versions)
224
+ // 2. synthetic text part in first assistant message (current approach)
225
+ if (includeSystemPrompt && messages.length > 0) {
226
+ const firstAssistant = messages.find((m) => m.info.role === 'assistant');
227
+ if (firstAssistant) {
228
+ // look for text part marked as synthetic (system prompt)
229
+ const systemPart = (firstAssistant.parts || []).find((p) => p.type === 'text' && p.synthetic === true);
230
+ if (systemPart && 'text' in systemPart && systemPart.text) {
231
+ lines.push('[System Prompt]');
232
+ const truncated = systemPart.text.slice(0, 3000);
233
+ lines.push(truncated);
234
+ if (systemPart.text.length > 3000) {
235
+ lines.push('...(truncated)');
236
+ }
237
+ lines.push('');
238
+ }
239
+ }
240
+ }
241
+ // Process recent messages
242
+ const recentMessages = messages.slice(-maxMessages);
243
+ for (const msg of recentMessages) {
244
+ if (msg.info.role === 'user') {
245
+ const textParts = (msg.parts || [])
246
+ .filter((p) => p.type === 'text' && 'text' in p)
247
+ .map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
248
+ .filter(Boolean);
249
+ if (textParts.length > 0) {
250
+ lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`);
251
+ lines.push('');
252
+ }
253
+ }
254
+ else if (msg.info.role === 'assistant') {
255
+ // Get assistant text parts (non-synthetic, non-empty)
256
+ const textParts = (msg.parts || [])
257
+ .filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
258
+ .map((p) => ('text' in p ? p.text : ''))
259
+ .filter(Boolean);
260
+ if (textParts.length > 0) {
261
+ lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`);
262
+ lines.push('');
263
+ }
264
+ // Get tool calls in compact form (name + params only)
265
+ const toolParts = (msg.parts || []).filter((p) => p.type === 'tool' &&
266
+ 'state' in p &&
267
+ p.state?.status === 'completed');
268
+ for (const part of toolParts) {
269
+ if (part.type === 'tool' && 'tool' in part && 'state' in part) {
270
+ const toolName = part.tool;
271
+ // skip noisy tools
272
+ if (toolName === 'todoread' || toolName === 'todowrite') {
273
+ continue;
274
+ }
275
+ const input = part.state?.input || {};
276
+ // compact params: just key=value on one line
277
+ const params = Object.entries(input)
278
+ .map(([k, v]) => {
279
+ const val = typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100);
280
+ return `${k}=${val}`;
281
+ })
282
+ .join(', ');
283
+ lines.push(`[Tool ${toolName}]: ${params}`);
284
+ }
285
+ }
286
+ }
287
+ }
288
+ return lines.join('\n').slice(0, 8000);
289
+ }
290
+ catch (e) {
291
+ markdownLogger.error('Failed to get compact session context:', e);
292
+ return '';
293
+ }
294
+ }
295
+ /**
296
+ * Get the last session for a directory (excluding the current one).
297
+ */
298
+ export async function getLastSessionId({ client, excludeSessionId, }) {
299
+ try {
300
+ const sessionsResponse = await client.session.list();
301
+ const sessions = sessionsResponse.data || [];
302
+ // Sessions are sorted by time, get the most recent one that isn't the current
303
+ const lastSession = sessions.find((s) => s.id !== excludeSessionId);
304
+ return lastSession?.id || null;
305
+ }
306
+ catch (e) {
307
+ markdownLogger.error('Failed to get last session:', e);
308
+ return null;
309
+ }
310
+ }
@@ -1,7 +1,7 @@
1
1
  import { test, expect, beforeAll, afterAll } from 'vitest';
2
2
  import { spawn } from 'child_process';
3
3
  import { OpencodeClient } from '@opencode-ai/sdk';
4
- import { ShareMarkdown } from './markdown.js';
4
+ import { ShareMarkdown, getCompactSessionContext } from './markdown.js';
5
5
  let serverProcess;
6
6
  let client;
7
7
  let port;
@@ -230,3 +230,33 @@ test('generate markdown from multiple sessions', async () => {
230
230
  }
231
231
  }
232
232
  });
233
+ // test for getCompactSessionContext - disabled in CI since it requires a specific session
234
+ test.skipIf(process.env.CI)('getCompactSessionContext generates compact format', async () => {
235
+ const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM';
236
+ const context = await getCompactSessionContext({
237
+ client,
238
+ sessionId,
239
+ includeSystemPrompt: true,
240
+ maxMessages: 15,
241
+ });
242
+ console.log(`Generated compact context length: ${context.length} characters`);
243
+ expect(context).toBeTruthy();
244
+ expect(context.length).toBeGreaterThan(0);
245
+ // should have tool calls or messages
246
+ expect(context).toMatch(/\[Tool \w+\]:|\[User\]:|\[Assistant\]:/);
247
+ await expect(context).toMatchFileSnapshot('./__snapshots__/compact-session-context.md');
248
+ });
249
+ test.skipIf(process.env.CI)('getCompactSessionContext without system prompt', async () => {
250
+ const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM';
251
+ const context = await getCompactSessionContext({
252
+ client,
253
+ sessionId,
254
+ includeSystemPrompt: false,
255
+ maxMessages: 10,
256
+ });
257
+ console.log(`Generated compact context (no system) length: ${context.length} characters`);
258
+ expect(context).toBeTruthy();
259
+ // should NOT have system prompt
260
+ expect(context).not.toContain('[System Prompt]');
261
+ await expect(context).toMatchFileSnapshot('./__snapshots__/compact-session-context-no-system.md');
262
+ });
@@ -1,8 +1,18 @@
1
1
  // OpenCode message part formatting for Discord.
2
2
  // Converts SDK message parts (text, tools, reasoning) to Discord-friendly format,
3
3
  // handles file attachments, and provides tool summary generation.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
4
6
  import { createLogger } from './logger.js';
7
+ const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments');
5
8
  const logger = createLogger('FORMATTING');
9
+ /**
10
+ * Escapes Discord inline markdown characters so dynamic content
11
+ * doesn't break formatting when wrapped in *, _, **, etc.
12
+ */
13
+ function escapeInlineMarkdown(text) {
14
+ return text.replace(/([*_~|`\\])/g, '\\$1');
15
+ }
6
16
  /**
7
17
  * Collects and formats the last N assistant parts from session messages.
8
18
  * Used by both /resume and /fork to show recent assistant context.
@@ -61,17 +71,42 @@ export async function getTextAttachments(message) {
61
71
  }));
62
72
  return textContents.join('\n\n');
63
73
  }
64
- export function getFileAttachments(message) {
74
+ export async function getFileAttachments(message) {
65
75
  const fileAttachments = Array.from(message.attachments.values()).filter((attachment) => {
66
76
  const contentType = attachment.contentType || '';
67
77
  return (contentType.startsWith('image/') || contentType === 'application/pdf');
68
78
  });
69
- return fileAttachments.map((attachment) => ({
70
- type: 'file',
71
- mime: attachment.contentType || 'application/octet-stream',
72
- filename: attachment.name,
73
- url: attachment.url,
79
+ if (fileAttachments.length === 0) {
80
+ return [];
81
+ }
82
+ // ensure tmp directory exists
83
+ if (!fs.existsSync(ATTACHMENTS_DIR)) {
84
+ fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
85
+ }
86
+ const results = await Promise.all(fileAttachments.map(async (attachment) => {
87
+ try {
88
+ const response = await fetch(attachment.url);
89
+ if (!response.ok) {
90
+ logger.error(`Failed to fetch attachment ${attachment.name}: ${response.status}`);
91
+ return null;
92
+ }
93
+ const buffer = Buffer.from(await response.arrayBuffer());
94
+ const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`);
95
+ fs.writeFileSync(localPath, buffer);
96
+ logger.log(`Downloaded attachment to ${localPath}`);
97
+ return {
98
+ type: 'file',
99
+ mime: attachment.contentType || 'application/octet-stream',
100
+ filename: attachment.name,
101
+ url: localPath,
102
+ };
103
+ }
104
+ catch (error) {
105
+ logger.error(`Error downloading attachment ${attachment.name}:`, error);
106
+ return null;
107
+ }
74
108
  }));
109
+ return results.filter((r) => r !== null);
75
110
  }
76
111
  export function getToolSummaryText(part) {
77
112
  if (part.type !== 'tool')
@@ -83,48 +118,48 @@ export function getToolSummaryText(part) {
83
118
  const added = newString.split('\n').length;
84
119
  const removed = oldString.split('\n').length;
85
120
  const fileName = filePath.split('/').pop() || '';
86
- return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`;
121
+ return fileName ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})` : `(+${added}-${removed})`;
87
122
  }
88
123
  if (part.tool === 'write') {
89
124
  const filePath = part.state.input?.filePath || '';
90
125
  const content = part.state.input?.content || '';
91
126
  const lines = content.split('\n').length;
92
127
  const fileName = filePath.split('/').pop() || '';
93
- return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`;
128
+ return fileName ? `*${escapeInlineMarkdown(fileName)}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`;
94
129
  }
95
130
  if (part.tool === 'webfetch') {
96
131
  const url = part.state.input?.url || '';
97
132
  const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
98
- return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : '';
133
+ return urlWithoutProtocol ? `*${escapeInlineMarkdown(urlWithoutProtocol)}*` : '';
99
134
  }
100
135
  if (part.tool === 'read') {
101
136
  const filePath = part.state.input?.filePath || '';
102
137
  const fileName = filePath.split('/').pop() || '';
103
- return fileName ? `*${fileName}*` : '';
138
+ return fileName ? `*${escapeInlineMarkdown(fileName)}*` : '';
104
139
  }
105
140
  if (part.tool === 'list') {
106
141
  const path = part.state.input?.path || '';
107
142
  const dirName = path.split('/').pop() || path;
108
- return dirName ? `*${dirName}*` : '';
143
+ return dirName ? `*${escapeInlineMarkdown(dirName)}*` : '';
109
144
  }
110
145
  if (part.tool === 'glob') {
111
146
  const pattern = part.state.input?.pattern || '';
112
- return pattern ? `*${pattern}*` : '';
147
+ return pattern ? `*${escapeInlineMarkdown(pattern)}*` : '';
113
148
  }
114
149
  if (part.tool === 'grep') {
115
150
  const pattern = part.state.input?.pattern || '';
116
- return pattern ? `*${pattern}*` : '';
151
+ return pattern ? `*${escapeInlineMarkdown(pattern)}*` : '';
117
152
  }
118
153
  if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
119
154
  return '';
120
155
  }
121
156
  if (part.tool === 'task') {
122
157
  const description = part.state.input?.description || '';
123
- return description ? `_${description}_` : '';
158
+ return description ? `_${escapeInlineMarkdown(description)}_` : '';
124
159
  }
125
160
  if (part.tool === 'skill') {
126
161
  const name = part.state.input?.name || '';
127
- return name ? `_${name}_` : '';
162
+ return name ? `_${escapeInlineMarkdown(name)}_` : '';
128
163
  }
129
164
  if (!part.state.input)
130
165
  return '';
@@ -151,12 +186,24 @@ export function formatTodoList(part) {
151
186
  const activeTodo = todos[activeIndex];
152
187
  if (activeIndex === -1 || !activeTodo)
153
188
  return '';
154
- return `${activeIndex + 1}. **${activeTodo.content}**`;
189
+ // parenthesized digits ⑴-⒇ for 1-20, fallback to regular number for 21+
190
+ const parenthesizedDigits = '⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽⑾⑿⒀⒁⒂⒃⒄⒅⒆⒇';
191
+ const todoNumber = activeIndex + 1;
192
+ const num = todoNumber <= 20 ? parenthesizedDigits[todoNumber - 1] : `(${todoNumber})`;
193
+ const content = activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1);
194
+ return `${num} **${escapeInlineMarkdown(content)}**`;
155
195
  }
156
196
  export function formatPart(part) {
157
197
  if (part.type === 'text') {
158
198
  if (!part.text?.trim())
159
199
  return '';
200
+ const trimmed = part.text.trimStart();
201
+ const firstChar = trimmed[0] || '';
202
+ const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|'];
203
+ const startsWithMarkdown = markdownStarters.includes(firstChar) || /^\d+\./.test(trimmed);
204
+ if (startsWithMarkdown) {
205
+ return `\n${part.text}`;
206
+ }
160
207
  return `⬥ ${part.text}`;
161
208
  }
162
209
  if (part.type === 'reasoning') {
@@ -180,6 +227,10 @@ export function formatPart(part) {
180
227
  if (part.tool === 'todowrite') {
181
228
  return formatTodoList(part);
182
229
  }
230
+ // Question tool is handled via Discord dropdowns, not text
231
+ if (part.tool === 'question') {
232
+ return '';
233
+ }
183
234
  if (part.state.status === 'pending') {
184
235
  return '';
185
236
  }
@@ -193,19 +244,18 @@ export function formatPart(part) {
193
244
  const command = part.state.input?.command || '';
194
245
  const description = part.state.input?.description || '';
195
246
  const isSingleLine = !command.includes('\n');
196
- const hasUnderscores = command.includes('_');
197
- if (isSingleLine && !hasUnderscores && command.length <= 50) {
198
- toolTitle = `_${command}_`;
247
+ if (isSingleLine && command.length <= 50) {
248
+ toolTitle = `_${escapeInlineMarkdown(command)}_`;
199
249
  }
200
250
  else if (description) {
201
- toolTitle = `_${description}_`;
251
+ toolTitle = `_${escapeInlineMarkdown(description)}_`;
202
252
  }
203
253
  else if (stateTitle) {
204
- toolTitle = `_${stateTitle}_`;
254
+ toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
205
255
  }
206
256
  }
207
257
  else if (stateTitle) {
208
- toolTitle = `_${stateTitle}_`;
258
+ toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
209
259
  }
210
260
  const icon = (() => {
211
261
  if (part.state.status === 'error') {
@@ -0,0 +1,73 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { formatTodoList } from './message-formatting.js';
3
+ describe('formatTodoList', () => {
4
+ test('formats active todo with monospace numbers', () => {
5
+ const part = {
6
+ id: 'test',
7
+ type: 'tool',
8
+ tool: 'todowrite',
9
+ sessionID: 'ses_test',
10
+ messageID: 'msg_test',
11
+ callID: 'call_test',
12
+ state: {
13
+ status: 'completed',
14
+ input: {
15
+ todos: [
16
+ { content: 'First task', status: 'completed' },
17
+ { content: 'Second task', status: 'in_progress' },
18
+ { content: 'Third task', status: 'pending' },
19
+ ],
20
+ },
21
+ output: '',
22
+ title: 'todowrite',
23
+ metadata: {},
24
+ time: { start: 0, end: 0 },
25
+ },
26
+ };
27
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"⑵ **second task**"`);
28
+ });
29
+ test('formats double digit todo numbers', () => {
30
+ const todos = Array.from({ length: 12 }, (_, i) => ({
31
+ content: `Task ${i + 1}`,
32
+ status: i === 11 ? 'in_progress' : 'completed',
33
+ }));
34
+ const part = {
35
+ id: 'test',
36
+ type: 'tool',
37
+ tool: 'todowrite',
38
+ sessionID: 'ses_test',
39
+ messageID: 'msg_test',
40
+ callID: 'call_test',
41
+ state: {
42
+ status: 'completed',
43
+ input: { todos },
44
+ output: '',
45
+ title: 'todowrite',
46
+ metadata: {},
47
+ time: { start: 0, end: 0 },
48
+ },
49
+ };
50
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"⑿ **task 12**"`);
51
+ });
52
+ test('lowercases first letter of content', () => {
53
+ const part = {
54
+ id: 'test',
55
+ type: 'tool',
56
+ tool: 'todowrite',
57
+ sessionID: 'ses_test',
58
+ messageID: 'msg_test',
59
+ callID: 'call_test',
60
+ state: {
61
+ status: 'completed',
62
+ input: {
63
+ todos: [{ content: 'Fix the bug', status: 'in_progress' }],
64
+ },
65
+ output: '',
66
+ title: 'todowrite',
67
+ metadata: {},
68
+ time: { start: 0, end: 0 },
69
+ },
70
+ };
71
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"⑴ **fix the bug**"`);
72
+ });
73
+ });