kimaki 0.4.38 → 0.4.39
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 +9 -3
- package/dist/commands/abort.js +15 -6
- package/dist/commands/add-project.js +9 -0
- package/dist/commands/agent.js +13 -1
- package/dist/commands/fork.js +13 -2
- package/dist/commands/model.js +12 -0
- package/dist/commands/remove-project.js +26 -16
- package/dist/commands/resume.js +9 -0
- package/dist/commands/session.js +13 -0
- package/dist/commands/share.js +10 -1
- package/dist/commands/undo-redo.js +13 -4
- package/dist/database.js +9 -5
- package/dist/discord-bot.js +21 -8
- package/dist/errors.js +110 -0
- package/dist/genai-worker.js +18 -16
- package/dist/markdown.js +96 -85
- package/dist/markdown.test.js +10 -3
- package/dist/message-formatting.js +50 -37
- package/dist/opencode.js +43 -46
- package/dist/session-handler.js +100 -2
- package/dist/system-message.js +2 -0
- package/dist/tools.js +18 -8
- package/dist/voice-handler.js +48 -25
- package/dist/voice.js +159 -131
- package/package.json +2 -1
- package/src/cli.ts +12 -3
- package/src/commands/abort.ts +17 -7
- package/src/commands/add-project.ts +9 -0
- package/src/commands/agent.ts +13 -1
- package/src/commands/fork.ts +18 -7
- package/src/commands/model.ts +12 -0
- package/src/commands/remove-project.ts +28 -16
- package/src/commands/resume.ts +9 -0
- package/src/commands/session.ts +13 -0
- package/src/commands/share.ts +11 -1
- package/src/commands/undo-redo.ts +15 -6
- package/src/database.ts +9 -4
- package/src/discord-bot.ts +21 -7
- package/src/errors.ts +208 -0
- package/src/genai-worker.ts +20 -17
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +111 -95
- package/src/message-formatting.ts +55 -38
- package/src/opencode.ts +52 -49
- package/src/session-handler.ts +118 -3
- package/src/system-message.ts +2 -0
- package/src/tools.ts +18 -8
- package/src/voice-handler.ts +48 -23
- package/src/voice.ts +195 -148
package/dist/genai-worker.js
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// Resamples 24kHz GenAI output to 48kHz stereo Opus packets for Discord.
|
|
4
4
|
import { parentPort, threadId } from 'node:worker_threads';
|
|
5
5
|
import { createWriteStream } from 'node:fs';
|
|
6
|
-
import { mkdir } from 'node:fs/promises';
|
|
7
6
|
import path from 'node:path';
|
|
7
|
+
import * as errore from 'errore';
|
|
8
8
|
import { Resampler } from '@purinton/resampler';
|
|
9
9
|
import * as prism from 'prism-media';
|
|
10
10
|
import { startGenAiSession } from './genai.js';
|
|
11
11
|
import { getTools } from './tools.js';
|
|
12
|
+
import { mkdir } from 'node:fs/promises';
|
|
12
13
|
import { createLogger } from './logger.js';
|
|
13
14
|
if (!parentPort) {
|
|
14
15
|
throw new Error('This module must be run as a worker thread');
|
|
@@ -98,23 +99,24 @@ async function createAssistantAudioLogStream(guildId, channelId) {
|
|
|
98
99
|
return null;
|
|
99
100
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
100
101
|
const audioDir = path.join(process.cwd(), 'discord-audio-logs', guildId, channelId);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// Add error handler to prevent crashes
|
|
108
|
-
outputAudioStream.on('error', (error) => {
|
|
109
|
-
workerLogger.error(`Assistant audio log stream error:`, error);
|
|
110
|
-
});
|
|
111
|
-
workerLogger.log(`Created assistant audio log: ${outputFilePath}`);
|
|
112
|
-
return outputAudioStream;
|
|
113
|
-
}
|
|
114
|
-
catch (error) {
|
|
115
|
-
workerLogger.error(`Failed to create audio log directory:`, error);
|
|
102
|
+
const mkdirError = await errore.tryAsync({
|
|
103
|
+
try: () => mkdir(audioDir, { recursive: true }),
|
|
104
|
+
catch: (e) => e,
|
|
105
|
+
});
|
|
106
|
+
if (errore.isError(mkdirError)) {
|
|
107
|
+
workerLogger.error(`Failed to create audio log directory:`, mkdirError.message);
|
|
116
108
|
return null;
|
|
117
109
|
}
|
|
110
|
+
// Create stream for assistant audio (24kHz mono s16le PCM)
|
|
111
|
+
const outputFileName = `assistant_${timestamp}.24.pcm`;
|
|
112
|
+
const outputFilePath = path.join(audioDir, outputFileName);
|
|
113
|
+
const outputAudioStream = createWriteStream(outputFilePath);
|
|
114
|
+
// Add error handler to prevent crashes
|
|
115
|
+
outputAudioStream.on('error', (error) => {
|
|
116
|
+
workerLogger.error(`Assistant audio log stream error:`, error);
|
|
117
|
+
});
|
|
118
|
+
workerLogger.log(`Created assistant audio log: ${outputFilePath}`);
|
|
119
|
+
return outputAudioStream;
|
|
118
120
|
}
|
|
119
121
|
// Handle encoded Opus packets
|
|
120
122
|
opusEncoder.on('data', (packet) => {
|
package/dist/markdown.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// Session-to-markdown renderer for sharing.
|
|
2
2
|
// Generates shareable markdown from OpenCode sessions, formatting
|
|
3
3
|
// user messages, assistant responses, tool calls, and reasoning blocks.
|
|
4
|
+
// Uses errore for type-safe error handling.
|
|
5
|
+
import * as errore from 'errore';
|
|
4
6
|
import * as yaml from 'js-yaml';
|
|
5
7
|
import { formatDateTime } from './utils.js';
|
|
6
8
|
import { extractNonXmlContent } from './xml.js';
|
|
7
9
|
import { createLogger } from './logger.js';
|
|
10
|
+
import { SessionNotFoundError, MessagesNotFoundError } from './errors.js';
|
|
11
|
+
// Generic error for unexpected exceptions in async operations
|
|
12
|
+
class UnexpectedError extends errore.TaggedError('UnexpectedError')() {
|
|
13
|
+
}
|
|
8
14
|
const markdownLogger = createLogger('MARKDOWN');
|
|
9
15
|
export class ShareMarkdown {
|
|
10
16
|
client;
|
|
@@ -14,7 +20,7 @@ export class ShareMarkdown {
|
|
|
14
20
|
/**
|
|
15
21
|
* Generate a markdown representation of a session
|
|
16
22
|
* @param options Configuration options
|
|
17
|
-
* @returns
|
|
23
|
+
* @returns Error or markdown string
|
|
18
24
|
*/
|
|
19
25
|
async generate(options) {
|
|
20
26
|
const { sessionID, includeSystemInfo, lastAssistantOnly } = options;
|
|
@@ -23,7 +29,7 @@ export class ShareMarkdown {
|
|
|
23
29
|
path: { id: sessionID },
|
|
24
30
|
});
|
|
25
31
|
if (!sessionResponse.data) {
|
|
26
|
-
|
|
32
|
+
return new SessionNotFoundError({ sessionId: sessionID });
|
|
27
33
|
}
|
|
28
34
|
const session = sessionResponse.data;
|
|
29
35
|
// Get all messages
|
|
@@ -31,7 +37,7 @@ export class ShareMarkdown {
|
|
|
31
37
|
path: { id: sessionID },
|
|
32
38
|
});
|
|
33
39
|
if (!messagesResponse.data) {
|
|
34
|
-
|
|
40
|
+
return new MessagesNotFoundError({ sessionId: sessionID });
|
|
35
41
|
}
|
|
36
42
|
const messages = messagesResponse.data;
|
|
37
43
|
// If lastAssistantOnly, filter to only the last assistant message
|
|
@@ -211,98 +217,103 @@ export class ShareMarkdown {
|
|
|
211
217
|
* Includes system prompt (optional), user messages, assistant text,
|
|
212
218
|
* and tool calls in compact form (name + params only, no output).
|
|
213
219
|
*/
|
|
214
|
-
export
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
220
|
+
export function getCompactSessionContext({ client, sessionId, includeSystemPrompt = false, maxMessages = 20, }) {
|
|
221
|
+
return errore.tryAsync({
|
|
222
|
+
try: async () => {
|
|
223
|
+
const messagesResponse = await client.session.messages({
|
|
224
|
+
path: { id: sessionId },
|
|
225
|
+
});
|
|
226
|
+
const messages = messagesResponse.data || [];
|
|
227
|
+
const lines = [];
|
|
228
|
+
// Get system prompt if requested
|
|
229
|
+
// Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
|
|
230
|
+
// 1. session.system field (if available in future SDK versions)
|
|
231
|
+
// 2. synthetic text part in first assistant message (current approach)
|
|
232
|
+
if (includeSystemPrompt && messages.length > 0) {
|
|
233
|
+
const firstAssistant = messages.find((m) => m.info.role === 'assistant');
|
|
234
|
+
if (firstAssistant) {
|
|
235
|
+
// look for text part marked as synthetic (system prompt)
|
|
236
|
+
const systemPart = (firstAssistant.parts || []).find((p) => p.type === 'text' && p.synthetic === true);
|
|
237
|
+
if (systemPart && 'text' in systemPart && systemPart.text) {
|
|
238
|
+
lines.push('[System Prompt]');
|
|
239
|
+
const truncated = systemPart.text.slice(0, 3000);
|
|
240
|
+
lines.push(truncated);
|
|
241
|
+
if (systemPart.text.length > 3000) {
|
|
242
|
+
lines.push('...(truncated)');
|
|
243
|
+
}
|
|
244
|
+
lines.push('');
|
|
236
245
|
}
|
|
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
246
|
}
|
|
253
247
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
248
|
+
// Process recent messages
|
|
249
|
+
const recentMessages = messages.slice(-maxMessages);
|
|
250
|
+
for (const msg of recentMessages) {
|
|
251
|
+
if (msg.info.role === 'user') {
|
|
252
|
+
const textParts = (msg.parts || [])
|
|
253
|
+
.filter((p) => p.type === 'text' && 'text' in p)
|
|
254
|
+
.map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
|
|
255
|
+
.filter(Boolean);
|
|
256
|
+
if (textParts.length > 0) {
|
|
257
|
+
lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`);
|
|
258
|
+
lines.push('');
|
|
259
|
+
}
|
|
263
260
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
261
|
+
else if (msg.info.role === 'assistant') {
|
|
262
|
+
// Get assistant text parts (non-synthetic, non-empty)
|
|
263
|
+
const textParts = (msg.parts || [])
|
|
264
|
+
.filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
|
|
265
|
+
.map((p) => ('text' in p ? p.text : ''))
|
|
266
|
+
.filter(Boolean);
|
|
267
|
+
if (textParts.length > 0) {
|
|
268
|
+
lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`);
|
|
269
|
+
lines.push('');
|
|
270
|
+
}
|
|
271
|
+
// Get tool calls in compact form (name + params only)
|
|
272
|
+
const toolParts = (msg.parts || []).filter((p) => p.type === 'tool' && 'state' in p && p.state?.status === 'completed');
|
|
273
|
+
for (const part of toolParts) {
|
|
274
|
+
if (part.type === 'tool' && 'tool' in part && 'state' in part) {
|
|
275
|
+
const toolName = part.tool;
|
|
276
|
+
// skip noisy tools
|
|
277
|
+
if (toolName === 'todoread' || toolName === 'todowrite') {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const input = part.state?.input || {};
|
|
281
|
+
const normalize = (value) => value.replace(/\s+/g, ' ').trim();
|
|
282
|
+
// compact params: just key=value on one line
|
|
283
|
+
const params = Object.entries(input)
|
|
284
|
+
.map(([k, v]) => {
|
|
285
|
+
const val = typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100);
|
|
286
|
+
return `${k}=${normalize(val)}`;
|
|
287
|
+
})
|
|
288
|
+
.join(', ');
|
|
289
|
+
lines.push(`[Tool ${toolName}]: ${params}`);
|
|
272
290
|
}
|
|
273
|
-
const input = part.state?.input || {};
|
|
274
|
-
// compact params: just key=value on one line
|
|
275
|
-
const params = Object.entries(input)
|
|
276
|
-
.map(([k, v]) => {
|
|
277
|
-
const val = typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100);
|
|
278
|
-
return `${k}=${val}`;
|
|
279
|
-
})
|
|
280
|
-
.join(', ');
|
|
281
|
-
lines.push(`[Tool ${toolName}]: ${params}`);
|
|
282
291
|
}
|
|
283
292
|
}
|
|
284
293
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
294
|
+
return lines.join('\n').slice(0, 8000);
|
|
295
|
+
},
|
|
296
|
+
catch: (e) => {
|
|
297
|
+
markdownLogger.error('Failed to get compact session context:', e);
|
|
298
|
+
return new UnexpectedError({ message: 'Failed to get compact session context', cause: e });
|
|
299
|
+
},
|
|
300
|
+
});
|
|
292
301
|
}
|
|
293
302
|
/**
|
|
294
303
|
* Get the last session for a directory (excluding the current one).
|
|
295
304
|
*/
|
|
296
|
-
export
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
305
|
+
export function getLastSessionId({ client, excludeSessionId, }) {
|
|
306
|
+
return errore.tryAsync({
|
|
307
|
+
try: async () => {
|
|
308
|
+
const sessionsResponse = await client.session.list();
|
|
309
|
+
const sessions = sessionsResponse.data || [];
|
|
310
|
+
// Sessions are sorted by time, get the most recent one that isn't the current
|
|
311
|
+
const lastSession = sessions.find((s) => s.id !== excludeSessionId);
|
|
312
|
+
return lastSession?.id || null;
|
|
313
|
+
},
|
|
314
|
+
catch: (e) => {
|
|
315
|
+
markdownLogger.error('Failed to get last session:', e);
|
|
316
|
+
return new UnexpectedError({ message: 'Failed to get last session', cause: e });
|
|
317
|
+
},
|
|
318
|
+
});
|
|
308
319
|
}
|
package/dist/markdown.test.js
CHANGED
|
@@ -1,6 +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 * as errore from 'errore';
|
|
4
5
|
import { ShareMarkdown, getCompactSessionContext } from './markdown.js';
|
|
5
6
|
let serverProcess;
|
|
6
7
|
let client;
|
|
@@ -100,10 +101,12 @@ test('generate markdown from first available session', async () => {
|
|
|
100
101
|
// Create markdown exporter
|
|
101
102
|
const exporter = new ShareMarkdown(client);
|
|
102
103
|
// Generate markdown with system info
|
|
103
|
-
const
|
|
104
|
+
const markdownResult = await exporter.generate({
|
|
104
105
|
sessionID,
|
|
105
106
|
includeSystemInfo: true,
|
|
106
107
|
});
|
|
108
|
+
expect(errore.isOk(markdownResult)).toBe(true);
|
|
109
|
+
const markdown = errore.unwrap(markdownResult);
|
|
107
110
|
console.log(`Generated markdown length: ${markdown.length} characters`);
|
|
108
111
|
// Basic assertions
|
|
109
112
|
expect(markdown).toBeTruthy();
|
|
@@ -233,12 +236,14 @@ test('generate markdown from multiple sessions', async () => {
|
|
|
233
236
|
// test for getCompactSessionContext - disabled in CI since it requires a specific session
|
|
234
237
|
test.skipIf(process.env.CI)('getCompactSessionContext generates compact format', async () => {
|
|
235
238
|
const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM';
|
|
236
|
-
const
|
|
239
|
+
const contextResult = await getCompactSessionContext({
|
|
237
240
|
client,
|
|
238
241
|
sessionId,
|
|
239
242
|
includeSystemPrompt: true,
|
|
240
243
|
maxMessages: 15,
|
|
241
244
|
});
|
|
245
|
+
expect(errore.isOk(contextResult)).toBe(true);
|
|
246
|
+
const context = errore.unwrap(contextResult);
|
|
242
247
|
console.log(`Generated compact context length: ${context.length} characters`);
|
|
243
248
|
expect(context).toBeTruthy();
|
|
244
249
|
expect(context.length).toBeGreaterThan(0);
|
|
@@ -248,12 +253,14 @@ test.skipIf(process.env.CI)('getCompactSessionContext generates compact format',
|
|
|
248
253
|
});
|
|
249
254
|
test.skipIf(process.env.CI)('getCompactSessionContext without system prompt', async () => {
|
|
250
255
|
const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM';
|
|
251
|
-
const
|
|
256
|
+
const contextResult = await getCompactSessionContext({
|
|
252
257
|
client,
|
|
253
258
|
sessionId,
|
|
254
259
|
includeSystemPrompt: false,
|
|
255
260
|
maxMessages: 10,
|
|
256
261
|
});
|
|
262
|
+
expect(errore.isOk(contextResult)).toBe(true);
|
|
263
|
+
const context = errore.unwrap(contextResult);
|
|
257
264
|
console.log(`Generated compact context (no system) length: ${context.length} characters`);
|
|
258
265
|
expect(context).toBeTruthy();
|
|
259
266
|
// should NOT have system prompt
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// handles file attachments, and provides tool summary generation.
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import * as errore from 'errore';
|
|
6
7
|
import { createLogger } from './logger.js';
|
|
8
|
+
import { FetchError } from './errors.js';
|
|
7
9
|
const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments');
|
|
8
10
|
const logger = createLogger('FORMATTING');
|
|
9
11
|
/**
|
|
@@ -56,18 +58,18 @@ export async function getTextAttachments(message) {
|
|
|
56
58
|
return '';
|
|
57
59
|
}
|
|
58
60
|
const textContents = await Promise.all(textAttachments.map(async (attachment) => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`;
|
|
61
|
+
const response = await errore.tryAsync({
|
|
62
|
+
try: () => fetch(attachment.url),
|
|
63
|
+
catch: (e) => new FetchError({ url: attachment.url, cause: e }),
|
|
64
|
+
});
|
|
65
|
+
if (errore.isError(response)) {
|
|
66
|
+
return `<attachment filename="${attachment.name}" error="${response.message}" />`;
|
|
66
67
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return `<attachment filename="${attachment.name}" error="${errMsg}" />`;
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`;
|
|
70
70
|
}
|
|
71
|
+
const text = await response.text();
|
|
72
|
+
return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`;
|
|
71
73
|
}));
|
|
72
74
|
return textContents.join('\n\n');
|
|
73
75
|
}
|
|
@@ -84,27 +86,28 @@ export async function getFileAttachments(message) {
|
|
|
84
86
|
fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
|
85
87
|
}
|
|
86
88
|
const results = await Promise.all(fileAttachments.map(async (attachment) => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
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
|
-
};
|
|
89
|
+
const response = await errore.tryAsync({
|
|
90
|
+
try: () => fetch(attachment.url),
|
|
91
|
+
catch: (e) => new FetchError({ url: attachment.url, cause: e }),
|
|
92
|
+
});
|
|
93
|
+
if (errore.isError(response)) {
|
|
94
|
+
logger.error(`Error downloading attachment ${attachment.name}:`, response.message);
|
|
95
|
+
return null;
|
|
103
96
|
}
|
|
104
|
-
|
|
105
|
-
logger.error(`
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
logger.error(`Failed to fetch attachment ${attachment.name}: ${response.status}`);
|
|
106
99
|
return null;
|
|
107
100
|
}
|
|
101
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
102
|
+
const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`);
|
|
103
|
+
fs.writeFileSync(localPath, buffer);
|
|
104
|
+
logger.log(`Downloaded attachment to ${localPath}`);
|
|
105
|
+
return {
|
|
106
|
+
type: 'file',
|
|
107
|
+
mime: attachment.contentType || 'application/octet-stream',
|
|
108
|
+
filename: attachment.name,
|
|
109
|
+
url: localPath,
|
|
110
|
+
};
|
|
108
111
|
}));
|
|
109
112
|
return results.filter((r) => r !== null);
|
|
110
113
|
}
|
|
@@ -157,9 +160,9 @@ export function getToolSummaryText(part) {
|
|
|
157
160
|
if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
|
|
158
161
|
return '';
|
|
159
162
|
}
|
|
163
|
+
// Task tool display is handled via subtask part in session-handler (shows label like explore-1)
|
|
160
164
|
if (part.tool === 'task') {
|
|
161
|
-
|
|
162
|
-
return description ? `_${escapeInlineMarkdown(description)}_` : '';
|
|
165
|
+
return '';
|
|
163
166
|
}
|
|
164
167
|
if (part.tool === 'skill') {
|
|
165
168
|
const name = part.state.input?.name || '';
|
|
@@ -197,10 +200,15 @@ export function formatTodoList(part) {
|
|
|
197
200
|
const content = activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1);
|
|
198
201
|
return `${num} **${escapeInlineMarkdown(content)}**`;
|
|
199
202
|
}
|
|
200
|
-
export function formatPart(part) {
|
|
203
|
+
export function formatPart(part, prefix) {
|
|
204
|
+
const pfx = prefix ? `${prefix}: ` : '';
|
|
201
205
|
if (part.type === 'text') {
|
|
202
206
|
if (!part.text?.trim())
|
|
203
207
|
return '';
|
|
208
|
+
// For subtask text, always use bullet with prefix
|
|
209
|
+
if (prefix) {
|
|
210
|
+
return `⬥ ${pfx}${part.text.trim()}`;
|
|
211
|
+
}
|
|
204
212
|
const trimmed = part.text.trimStart();
|
|
205
213
|
const firstChar = trimmed[0] || '';
|
|
206
214
|
const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|'];
|
|
@@ -213,28 +221,33 @@ export function formatPart(part) {
|
|
|
213
221
|
if (part.type === 'reasoning') {
|
|
214
222
|
if (!part.text?.trim())
|
|
215
223
|
return '';
|
|
216
|
-
return `┣ thinking`;
|
|
224
|
+
return `┣ ${pfx}thinking`;
|
|
217
225
|
}
|
|
218
226
|
if (part.type === 'file') {
|
|
219
|
-
return `📄 ${part.filename || 'File'}`;
|
|
227
|
+
return prefix ? `📄 ${pfx}${part.filename || 'File'}` : `📄 ${part.filename || 'File'}`;
|
|
220
228
|
}
|
|
221
229
|
if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
|
|
222
230
|
return '';
|
|
223
231
|
}
|
|
224
232
|
if (part.type === 'agent') {
|
|
225
|
-
return `┣ agent ${part.id}`;
|
|
233
|
+
return `┣ ${pfx}agent ${part.id}`;
|
|
226
234
|
}
|
|
227
235
|
if (part.type === 'snapshot') {
|
|
228
|
-
return `┣ snapshot ${part.snapshot}`;
|
|
236
|
+
return `┣ ${pfx}snapshot ${part.snapshot}`;
|
|
229
237
|
}
|
|
230
238
|
if (part.type === 'tool') {
|
|
231
239
|
if (part.tool === 'todowrite') {
|
|
232
|
-
|
|
240
|
+
const formatted = formatTodoList(part);
|
|
241
|
+
return prefix && formatted ? `┣ ${pfx}${formatted}` : formatted;
|
|
233
242
|
}
|
|
234
243
|
// Question tool is handled via Discord dropdowns, not text
|
|
235
244
|
if (part.tool === 'question') {
|
|
236
245
|
return '';
|
|
237
246
|
}
|
|
247
|
+
// Task tool display is handled in session-handler with proper label
|
|
248
|
+
if (part.tool === 'task') {
|
|
249
|
+
return '';
|
|
250
|
+
}
|
|
238
251
|
if (part.state.status === 'pending') {
|
|
239
252
|
return '';
|
|
240
253
|
}
|
|
@@ -270,7 +283,7 @@ export function formatPart(part) {
|
|
|
270
283
|
}
|
|
271
284
|
return '┣';
|
|
272
285
|
})();
|
|
273
|
-
return `${icon} ${part.tool} ${toolTitle} ${summaryText}
|
|
286
|
+
return `${icon} ${pfx}${part.tool} ${toolTitle} ${summaryText}`.trim();
|
|
274
287
|
}
|
|
275
288
|
logger.warn('Unknown part type:', part);
|
|
276
289
|
return '';
|