kimaki 0.4.24 → 0.4.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin.js +6 -1
- package/dist/acp-client.test.js +149 -0
- package/dist/ai-tool-to-genai.js +3 -0
- package/dist/channel-management.js +14 -9
- package/dist/cli.js +148 -17
- package/dist/commands/abort.js +78 -0
- package/dist/commands/add-project.js +98 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/create-new-project.js +78 -0
- package/dist/commands/fork.js +186 -0
- package/dist/commands/model.js +313 -0
- package/dist/commands/permissions.js +126 -0
- package/dist/commands/queue.js +129 -0
- package/dist/commands/resume.js +145 -0
- package/dist/commands/session.js +142 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +161 -0
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +54 -0
- package/dist/discord-bot.js +35 -32
- package/dist/discord-utils.js +81 -15
- package/dist/format-tables.js +3 -0
- package/dist/genai-worker-wrapper.js +3 -0
- package/dist/genai-worker.js +3 -0
- package/dist/genai.js +3 -0
- package/dist/interaction-handler.js +89 -695
- package/dist/logger.js +46 -5
- package/dist/markdown.js +107 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +113 -28
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +73 -16
- package/dist/session-handler.js +176 -63
- package/dist/system-message.js +7 -38
- package/dist/tools.js +3 -0
- package/dist/utils.js +3 -0
- package/dist/voice-handler.js +21 -8
- package/dist/voice.js +31 -12
- package/dist/worker-types.js +3 -0
- package/dist/xml.js +3 -0
- package/package.json +3 -3
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/ai-tool-to-genai.ts +4 -0
- package/src/channel-management.ts +24 -8
- package/src/cli.ts +163 -18
- package/src/commands/abort.ts +94 -0
- package/src/commands/add-project.ts +139 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/create-new-project.ts +111 -0
- package/src/{fork.ts → commands/fork.ts} +40 -7
- package/src/{model-command.ts → commands/model.ts} +31 -9
- package/src/commands/permissions.ts +146 -0
- package/src/commands/queue.ts +181 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/session.ts +184 -0
- package/src/commands/share.ts +96 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +213 -0
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +65 -0
- package/src/discord-bot.ts +40 -33
- package/src/discord-utils.ts +88 -14
- package/src/format-tables.ts +4 -0
- package/src/genai-worker-wrapper.ts +4 -0
- package/src/genai-worker.ts +4 -0
- package/src/genai.ts +4 -0
- package/src/interaction-handler.ts +111 -924
- package/src/logger.ts +51 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +136 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +143 -30
- package/src/opencode.ts +84 -21
- package/src/session-handler.ts +248 -91
- package/src/system-message.ts +8 -38
- package/src/tools.ts +4 -0
- package/src/utils.ts +4 -0
- package/src/voice-handler.ts +24 -9
- package/src/voice.ts +36 -13
- package/src/worker-types.ts +4 -0
- package/src/xml.ts +4 -0
- package/README.md +0 -48
package/dist/logger.js
CHANGED
|
@@ -1,10 +1,51 @@
|
|
|
1
|
+
// Prefixed logging utility using @clack/prompts.
|
|
2
|
+
// Creates loggers with consistent prefixes for different subsystems
|
|
3
|
+
// (DISCORD, VOICE, SESSION, etc.) for easier debugging.
|
|
1
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
|
+
}
|
|
2
28
|
export function createLogger(prefix) {
|
|
3
29
|
return {
|
|
4
|
-
log: (...args) =>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
},
|
|
9
50
|
};
|
|
10
51
|
}
|
package/dist/markdown.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
// Session-to-markdown renderer for sharing.
|
|
2
|
+
// Generates shareable markdown from OpenCode sessions, formatting
|
|
3
|
+
// user messages, assistant responses, tool calls, and reasoning blocks.
|
|
1
4
|
import * as yaml from 'js-yaml';
|
|
2
5
|
import { formatDateTime } from './utils.js';
|
|
3
6
|
import { extractNonXmlContent } from './xml.js';
|
|
7
|
+
import { createLogger } from './logger.js';
|
|
8
|
+
const markdownLogger = createLogger('MARKDOWN');
|
|
4
9
|
export class ShareMarkdown {
|
|
5
10
|
client;
|
|
6
11
|
constructor(client) {
|
|
@@ -201,3 +206,105 @@ export class ShareMarkdown {
|
|
|
201
206
|
return `${minutes}m ${seconds}s`;
|
|
202
207
|
}
|
|
203
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
|
+
}
|
package/dist/markdown.test.js
CHANGED
|
@@ -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,5 +1,40 @@
|
|
|
1
|
+
// OpenCode message part formatting for Discord.
|
|
2
|
+
// Converts SDK message parts (text, tools, reasoning) to Discord-friendly format,
|
|
3
|
+
// handles file attachments, and provides tool summary generation.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
1
6
|
import { createLogger } from './logger.js';
|
|
7
|
+
const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments');
|
|
2
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
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Collects and formats the last N assistant parts from session messages.
|
|
18
|
+
* Used by both /resume and /fork to show recent assistant context.
|
|
19
|
+
*/
|
|
20
|
+
export function collectLastAssistantParts({ messages, limit = 30, }) {
|
|
21
|
+
const allAssistantParts = [];
|
|
22
|
+
for (const message of messages) {
|
|
23
|
+
if (message.info.role === 'assistant') {
|
|
24
|
+
for (const part of message.parts) {
|
|
25
|
+
const content = formatPart(part);
|
|
26
|
+
if (content.trim()) {
|
|
27
|
+
allAssistantParts.push({ id: part.id, content: content.trimEnd() });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const partsToRender = allAssistantParts.slice(-limit);
|
|
33
|
+
const partIds = partsToRender.map((p) => p.id);
|
|
34
|
+
const content = partsToRender.map((p) => p.content).join('\n');
|
|
35
|
+
const skippedCount = allAssistantParts.length - partsToRender.length;
|
|
36
|
+
return { partIds, content, skippedCount };
|
|
37
|
+
}
|
|
3
38
|
export const TEXT_MIME_TYPES = [
|
|
4
39
|
'text/',
|
|
5
40
|
'application/json',
|
|
@@ -36,17 +71,42 @@ export async function getTextAttachments(message) {
|
|
|
36
71
|
}));
|
|
37
72
|
return textContents.join('\n\n');
|
|
38
73
|
}
|
|
39
|
-
export function getFileAttachments(message) {
|
|
74
|
+
export async function getFileAttachments(message) {
|
|
40
75
|
const fileAttachments = Array.from(message.attachments.values()).filter((attachment) => {
|
|
41
76
|
const contentType = attachment.contentType || '';
|
|
42
77
|
return (contentType.startsWith('image/') || contentType === 'application/pdf');
|
|
43
78
|
});
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
}
|
|
49
108
|
}));
|
|
109
|
+
return results.filter((r) => r !== null);
|
|
50
110
|
}
|
|
51
111
|
export function getToolSummaryText(part) {
|
|
52
112
|
if (part.type !== 'tool')
|
|
@@ -58,48 +118,48 @@ export function getToolSummaryText(part) {
|
|
|
58
118
|
const added = newString.split('\n').length;
|
|
59
119
|
const removed = oldString.split('\n').length;
|
|
60
120
|
const fileName = filePath.split('/').pop() || '';
|
|
61
|
-
return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`;
|
|
121
|
+
return fileName ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})` : `(+${added}-${removed})`;
|
|
62
122
|
}
|
|
63
123
|
if (part.tool === 'write') {
|
|
64
124
|
const filePath = part.state.input?.filePath || '';
|
|
65
125
|
const content = part.state.input?.content || '';
|
|
66
126
|
const lines = content.split('\n').length;
|
|
67
127
|
const fileName = filePath.split('/').pop() || '';
|
|
68
|
-
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'})`;
|
|
69
129
|
}
|
|
70
130
|
if (part.tool === 'webfetch') {
|
|
71
131
|
const url = part.state.input?.url || '';
|
|
72
132
|
const urlWithoutProtocol = url.replace(/^https?:\/\//, '');
|
|
73
|
-
return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : '';
|
|
133
|
+
return urlWithoutProtocol ? `*${escapeInlineMarkdown(urlWithoutProtocol)}*` : '';
|
|
74
134
|
}
|
|
75
135
|
if (part.tool === 'read') {
|
|
76
136
|
const filePath = part.state.input?.filePath || '';
|
|
77
137
|
const fileName = filePath.split('/').pop() || '';
|
|
78
|
-
return fileName ? `*${fileName}*` : '';
|
|
138
|
+
return fileName ? `*${escapeInlineMarkdown(fileName)}*` : '';
|
|
79
139
|
}
|
|
80
140
|
if (part.tool === 'list') {
|
|
81
141
|
const path = part.state.input?.path || '';
|
|
82
142
|
const dirName = path.split('/').pop() || path;
|
|
83
|
-
return dirName ? `*${dirName}*` : '';
|
|
143
|
+
return dirName ? `*${escapeInlineMarkdown(dirName)}*` : '';
|
|
84
144
|
}
|
|
85
145
|
if (part.tool === 'glob') {
|
|
86
146
|
const pattern = part.state.input?.pattern || '';
|
|
87
|
-
return pattern ? `*${pattern}*` : '';
|
|
147
|
+
return pattern ? `*${escapeInlineMarkdown(pattern)}*` : '';
|
|
88
148
|
}
|
|
89
149
|
if (part.tool === 'grep') {
|
|
90
150
|
const pattern = part.state.input?.pattern || '';
|
|
91
|
-
return pattern ? `*${pattern}*` : '';
|
|
151
|
+
return pattern ? `*${escapeInlineMarkdown(pattern)}*` : '';
|
|
92
152
|
}
|
|
93
153
|
if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
|
|
94
154
|
return '';
|
|
95
155
|
}
|
|
96
156
|
if (part.tool === 'task') {
|
|
97
157
|
const description = part.state.input?.description || '';
|
|
98
|
-
return description ? `_${description}_` : '';
|
|
158
|
+
return description ? `_${escapeInlineMarkdown(description)}_` : '';
|
|
99
159
|
}
|
|
100
160
|
if (part.tool === 'skill') {
|
|
101
161
|
const name = part.state.input?.name || '';
|
|
102
|
-
return name ? `_${name}_` : '';
|
|
162
|
+
return name ? `_${escapeInlineMarkdown(name)}_` : '';
|
|
103
163
|
}
|
|
104
164
|
if (!part.state.input)
|
|
105
165
|
return '';
|
|
@@ -108,7 +168,7 @@ export function getToolSummaryText(part) {
|
|
|
108
168
|
if (value === null || value === undefined)
|
|
109
169
|
return null;
|
|
110
170
|
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
|
111
|
-
const truncatedValue = stringValue.length >
|
|
171
|
+
const truncatedValue = stringValue.length > 50 ? stringValue.slice(0, 50) + '…' : stringValue;
|
|
112
172
|
return `${key}: ${truncatedValue}`;
|
|
113
173
|
})
|
|
114
174
|
.filter(Boolean);
|
|
@@ -126,16 +186,30 @@ export function formatTodoList(part) {
|
|
|
126
186
|
const activeTodo = todos[activeIndex];
|
|
127
187
|
if (activeIndex === -1 || !activeTodo)
|
|
128
188
|
return '';
|
|
129
|
-
|
|
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)}**`;
|
|
130
195
|
}
|
|
131
196
|
export function formatPart(part) {
|
|
132
197
|
if (part.type === 'text') {
|
|
133
|
-
|
|
198
|
+
if (!part.text?.trim())
|
|
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
|
+
}
|
|
207
|
+
return `⬥ ${part.text}`;
|
|
134
208
|
}
|
|
135
209
|
if (part.type === 'reasoning') {
|
|
136
210
|
if (!part.text?.trim())
|
|
137
211
|
return '';
|
|
138
|
-
return
|
|
212
|
+
return `┣ thinking`;
|
|
139
213
|
}
|
|
140
214
|
if (part.type === 'file') {
|
|
141
215
|
return `📄 ${part.filename || 'File'}`;
|
|
@@ -144,15 +218,19 @@ export function formatPart(part) {
|
|
|
144
218
|
return '';
|
|
145
219
|
}
|
|
146
220
|
if (part.type === 'agent') {
|
|
147
|
-
return
|
|
221
|
+
return `┣ agent ${part.id}`;
|
|
148
222
|
}
|
|
149
223
|
if (part.type === 'snapshot') {
|
|
150
|
-
return
|
|
224
|
+
return `┣ snapshot ${part.snapshot}`;
|
|
151
225
|
}
|
|
152
226
|
if (part.type === 'tool') {
|
|
153
227
|
if (part.tool === 'todowrite') {
|
|
154
228
|
return formatTodoList(part);
|
|
155
229
|
}
|
|
230
|
+
// Question tool is handled via Discord dropdowns, not text
|
|
231
|
+
if (part.tool === 'question') {
|
|
232
|
+
return '';
|
|
233
|
+
}
|
|
156
234
|
if (part.state.status === 'pending') {
|
|
157
235
|
return '';
|
|
158
236
|
}
|
|
@@ -166,21 +244,28 @@ export function formatPart(part) {
|
|
|
166
244
|
const command = part.state.input?.command || '';
|
|
167
245
|
const description = part.state.input?.description || '';
|
|
168
246
|
const isSingleLine = !command.includes('\n');
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
toolTitle = `_${command}_`;
|
|
247
|
+
if (isSingleLine && command.length <= 50) {
|
|
248
|
+
toolTitle = `_${escapeInlineMarkdown(command)}_`;
|
|
172
249
|
}
|
|
173
250
|
else if (description) {
|
|
174
|
-
toolTitle = `_${description}_`;
|
|
251
|
+
toolTitle = `_${escapeInlineMarkdown(description)}_`;
|
|
175
252
|
}
|
|
176
253
|
else if (stateTitle) {
|
|
177
|
-
toolTitle = `_${stateTitle}_`;
|
|
254
|
+
toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
|
|
178
255
|
}
|
|
179
256
|
}
|
|
180
257
|
else if (stateTitle) {
|
|
181
|
-
toolTitle = `_${stateTitle}_`;
|
|
258
|
+
toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
|
|
182
259
|
}
|
|
183
|
-
const icon =
|
|
260
|
+
const icon = (() => {
|
|
261
|
+
if (part.state.status === 'error') {
|
|
262
|
+
return '⨯';
|
|
263
|
+
}
|
|
264
|
+
if (part.tool === 'edit' || part.tool === 'write') {
|
|
265
|
+
return '◼︎';
|
|
266
|
+
}
|
|
267
|
+
return '┣';
|
|
268
|
+
})();
|
|
184
269
|
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
|
|
185
270
|
}
|
|
186
271
|
logger.warn('Unknown part type:', part);
|
|
@@ -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
|
+
});
|