kimaki 0.4.38 → 0.4.40
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 +27 -23
- 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 +14 -1
- package/dist/commands/share.js +10 -1
- package/dist/commands/undo-redo.js +13 -4
- package/dist/commands/worktree.js +180 -0
- package/dist/database.js +57 -5
- package/dist/discord-bot.js +48 -10
- package/dist/discord-utils.js +36 -0
- package/dist/errors.js +109 -0
- package/dist/genai-worker.js +18 -16
- package/dist/interaction-handler.js +6 -2
- package/dist/markdown.js +100 -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 +4 -2
- package/src/cli.ts +31 -32
- 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 +14 -1
- package/src/commands/share.ts +11 -1
- package/src/commands/undo-redo.ts +15 -6
- package/src/commands/worktree.ts +243 -0
- package/src/database.ts +104 -4
- package/src/discord-bot.ts +49 -9
- package/src/discord-utils.ts +50 -0
- package/src/errors.ts +138 -0
- package/src/genai-worker.ts +20 -17
- package/src/interaction-handler.ts +7 -2
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +112 -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/markdown.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
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';
|
|
6
|
+
import { createTaggedError } from 'errore';
|
|
4
7
|
import * as yaml from 'js-yaml';
|
|
5
8
|
import { formatDateTime } from './utils.js';
|
|
6
9
|
import { extractNonXmlContent } from './xml.js';
|
|
7
10
|
import { createLogger } from './logger.js';
|
|
11
|
+
import { SessionNotFoundError, MessagesNotFoundError } from './errors.js';
|
|
12
|
+
// Generic error for unexpected exceptions in async operations
|
|
13
|
+
class UnexpectedError extends createTaggedError({
|
|
14
|
+
name: 'UnexpectedError',
|
|
15
|
+
message: '$message',
|
|
16
|
+
}) {
|
|
17
|
+
}
|
|
8
18
|
const markdownLogger = createLogger('MARKDOWN');
|
|
9
19
|
export class ShareMarkdown {
|
|
10
20
|
client;
|
|
@@ -14,7 +24,7 @@ export class ShareMarkdown {
|
|
|
14
24
|
/**
|
|
15
25
|
* Generate a markdown representation of a session
|
|
16
26
|
* @param options Configuration options
|
|
17
|
-
* @returns
|
|
27
|
+
* @returns Error or markdown string
|
|
18
28
|
*/
|
|
19
29
|
async generate(options) {
|
|
20
30
|
const { sessionID, includeSystemInfo, lastAssistantOnly } = options;
|
|
@@ -23,7 +33,7 @@ export class ShareMarkdown {
|
|
|
23
33
|
path: { id: sessionID },
|
|
24
34
|
});
|
|
25
35
|
if (!sessionResponse.data) {
|
|
26
|
-
|
|
36
|
+
return new SessionNotFoundError({ sessionId: sessionID });
|
|
27
37
|
}
|
|
28
38
|
const session = sessionResponse.data;
|
|
29
39
|
// Get all messages
|
|
@@ -31,7 +41,7 @@ export class ShareMarkdown {
|
|
|
31
41
|
path: { id: sessionID },
|
|
32
42
|
});
|
|
33
43
|
if (!messagesResponse.data) {
|
|
34
|
-
|
|
44
|
+
return new MessagesNotFoundError({ sessionId: sessionID });
|
|
35
45
|
}
|
|
36
46
|
const messages = messagesResponse.data;
|
|
37
47
|
// If lastAssistantOnly, filter to only the last assistant message
|
|
@@ -211,98 +221,103 @@ export class ShareMarkdown {
|
|
|
211
221
|
* Includes system prompt (optional), user messages, assistant text,
|
|
212
222
|
* and tool calls in compact form (name + params only, no output).
|
|
213
223
|
*/
|
|
214
|
-
export
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
224
|
+
export function getCompactSessionContext({ client, sessionId, includeSystemPrompt = false, maxMessages = 20, }) {
|
|
225
|
+
return errore.tryAsync({
|
|
226
|
+
try: async () => {
|
|
227
|
+
const messagesResponse = await client.session.messages({
|
|
228
|
+
path: { id: sessionId },
|
|
229
|
+
});
|
|
230
|
+
const messages = messagesResponse.data || [];
|
|
231
|
+
const lines = [];
|
|
232
|
+
// Get system prompt if requested
|
|
233
|
+
// Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
|
|
234
|
+
// 1. session.system field (if available in future SDK versions)
|
|
235
|
+
// 2. synthetic text part in first assistant message (current approach)
|
|
236
|
+
if (includeSystemPrompt && messages.length > 0) {
|
|
237
|
+
const firstAssistant = messages.find((m) => m.info.role === 'assistant');
|
|
238
|
+
if (firstAssistant) {
|
|
239
|
+
// look for text part marked as synthetic (system prompt)
|
|
240
|
+
const systemPart = (firstAssistant.parts || []).find((p) => p.type === 'text' && p.synthetic === true);
|
|
241
|
+
if (systemPart && 'text' in systemPart && systemPart.text) {
|
|
242
|
+
lines.push('[System Prompt]');
|
|
243
|
+
const truncated = systemPart.text.slice(0, 3000);
|
|
244
|
+
lines.push(truncated);
|
|
245
|
+
if (systemPart.text.length > 3000) {
|
|
246
|
+
lines.push('...(truncated)');
|
|
247
|
+
}
|
|
248
|
+
lines.push('');
|
|
236
249
|
}
|
|
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
250
|
}
|
|
253
251
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
252
|
+
// Process recent messages
|
|
253
|
+
const recentMessages = messages.slice(-maxMessages);
|
|
254
|
+
for (const msg of recentMessages) {
|
|
255
|
+
if (msg.info.role === 'user') {
|
|
256
|
+
const textParts = (msg.parts || [])
|
|
257
|
+
.filter((p) => p.type === 'text' && 'text' in p)
|
|
258
|
+
.map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
|
|
259
|
+
.filter(Boolean);
|
|
260
|
+
if (textParts.length > 0) {
|
|
261
|
+
lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`);
|
|
262
|
+
lines.push('');
|
|
263
|
+
}
|
|
263
264
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
265
|
+
else if (msg.info.role === 'assistant') {
|
|
266
|
+
// Get assistant text parts (non-synthetic, non-empty)
|
|
267
|
+
const textParts = (msg.parts || [])
|
|
268
|
+
.filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
|
|
269
|
+
.map((p) => ('text' in p ? p.text : ''))
|
|
270
|
+
.filter(Boolean);
|
|
271
|
+
if (textParts.length > 0) {
|
|
272
|
+
lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`);
|
|
273
|
+
lines.push('');
|
|
274
|
+
}
|
|
275
|
+
// Get tool calls in compact form (name + params only)
|
|
276
|
+
const toolParts = (msg.parts || []).filter((p) => p.type === 'tool' && 'state' in p && p.state?.status === 'completed');
|
|
277
|
+
for (const part of toolParts) {
|
|
278
|
+
if (part.type === 'tool' && 'tool' in part && 'state' in part) {
|
|
279
|
+
const toolName = part.tool;
|
|
280
|
+
// skip noisy tools
|
|
281
|
+
if (toolName === 'todoread' || toolName === 'todowrite') {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const input = part.state?.input || {};
|
|
285
|
+
const normalize = (value) => value.replace(/\s+/g, ' ').trim();
|
|
286
|
+
// compact params: just key=value on one line
|
|
287
|
+
const params = Object.entries(input)
|
|
288
|
+
.map(([k, v]) => {
|
|
289
|
+
const val = typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100);
|
|
290
|
+
return `${k}=${normalize(val)}`;
|
|
291
|
+
})
|
|
292
|
+
.join(', ');
|
|
293
|
+
lines.push(`[Tool ${toolName}]: ${params}`);
|
|
272
294
|
}
|
|
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
295
|
}
|
|
283
296
|
}
|
|
284
297
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
298
|
+
return lines.join('\n').slice(0, 8000);
|
|
299
|
+
},
|
|
300
|
+
catch: (e) => {
|
|
301
|
+
markdownLogger.error('Failed to get compact session context:', e);
|
|
302
|
+
return new UnexpectedError({ message: 'Failed to get compact session context', cause: e });
|
|
303
|
+
},
|
|
304
|
+
});
|
|
292
305
|
}
|
|
293
306
|
/**
|
|
294
307
|
* Get the last session for a directory (excluding the current one).
|
|
295
308
|
*/
|
|
296
|
-
export
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
309
|
+
export function getLastSessionId({ client, excludeSessionId, }) {
|
|
310
|
+
return errore.tryAsync({
|
|
311
|
+
try: async () => {
|
|
312
|
+
const sessionsResponse = await client.session.list();
|
|
313
|
+
const sessions = sessionsResponse.data || [];
|
|
314
|
+
// Sessions are sorted by time, get the most recent one that isn't the current
|
|
315
|
+
const lastSession = sessions.find((s) => s.id !== excludeSessionId);
|
|
316
|
+
return lastSession?.id || null;
|
|
317
|
+
},
|
|
318
|
+
catch: (e) => {
|
|
319
|
+
markdownLogger.error('Failed to get last session:', e);
|
|
320
|
+
return new UnexpectedError({ message: 'Failed to get last session', cause: e });
|
|
321
|
+
},
|
|
322
|
+
});
|
|
308
323
|
}
|
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 (response instanceof Error) {
|
|
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 (response instanceof Error) {
|
|
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 '';
|
package/dist/opencode.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// OpenCode server process manager.
|
|
2
2
|
// Spawns and maintains OpenCode API servers per project directory,
|
|
3
3
|
// handles automatic restarts on failure, and provides typed SDK clients.
|
|
4
|
+
// Uses errore for type-safe error handling.
|
|
4
5
|
import { spawn } from 'node:child_process';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import net from 'node:net';
|
|
7
8
|
import { createOpencodeClient } from '@opencode-ai/sdk';
|
|
8
9
|
import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
|
|
10
|
+
import * as errore from 'errore';
|
|
9
11
|
import { createLogger } from './logger.js';
|
|
12
|
+
import { DirectoryNotAccessibleError, ServerStartError, ServerNotReadyError, FetchError, } from './errors.js';
|
|
10
13
|
const opencodeLogger = createLogger('OPENCODE');
|
|
11
14
|
const opencodeServers = new Map();
|
|
12
15
|
const serverRetryCount = new Map();
|
|
@@ -30,42 +33,33 @@ async function getOpenPort() {
|
|
|
30
33
|
}
|
|
31
34
|
async function waitForServer(port, maxAttempts = 30) {
|
|
32
35
|
for (let i = 0; i < maxAttempts; i++) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
try
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (body.includes('BunInstallFailedError')) {
|
|
48
|
-
throw new Error(`Server failed to start: ${body.slice(0, 200)}`);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
catch (e) {
|
|
52
|
-
// Re-throw fatal errors
|
|
53
|
-
if (e.message?.includes('Server failed to start')) {
|
|
54
|
-
throw e;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
36
|
+
const endpoints = [
|
|
37
|
+
`http://127.0.0.1:${port}/api/health`,
|
|
38
|
+
`http://127.0.0.1:${port}/`,
|
|
39
|
+
`http://127.0.0.1:${port}/api`,
|
|
40
|
+
];
|
|
41
|
+
for (const endpoint of endpoints) {
|
|
42
|
+
const response = await errore.tryAsync({
|
|
43
|
+
try: () => fetch(endpoint),
|
|
44
|
+
catch: (e) => new FetchError({ url: endpoint, cause: e }),
|
|
45
|
+
});
|
|
46
|
+
if (response instanceof Error) {
|
|
47
|
+
// Connection refused or other transient errors - continue polling
|
|
48
|
+
opencodeLogger.debug(`Server polling attempt failed: ${response.message}`);
|
|
49
|
+
continue;
|
|
57
50
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
if (response.status < 500) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const body = await response.text();
|
|
55
|
+
// Fatal errors that won't resolve with retrying
|
|
56
|
+
if (body.includes('BunInstallFailedError')) {
|
|
57
|
+
return new ServerStartError({ port, reason: body.slice(0, 200) });
|
|
63
58
|
}
|
|
64
|
-
opencodeLogger.debug(`Server polling attempt failed: ${e.message}`);
|
|
65
59
|
}
|
|
66
60
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
67
61
|
}
|
|
68
|
-
|
|
62
|
+
return new ServerStartError({ port, reason: `Server did not start after ${maxAttempts} seconds` });
|
|
69
63
|
}
|
|
70
64
|
export async function initializeOpencodeForDirectory(directory) {
|
|
71
65
|
const existing = opencodeServers.get(directory);
|
|
@@ -74,17 +68,20 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
74
68
|
return () => {
|
|
75
69
|
const entry = opencodeServers.get(directory);
|
|
76
70
|
if (!entry?.client) {
|
|
77
|
-
throw new
|
|
71
|
+
throw new ServerNotReadyError({ directory });
|
|
78
72
|
}
|
|
79
73
|
return entry.client;
|
|
80
74
|
};
|
|
81
75
|
}
|
|
82
76
|
// Verify directory exists and is accessible before spawning
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
77
|
+
const accessCheck = errore.tryFn({
|
|
78
|
+
try: () => {
|
|
79
|
+
fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK);
|
|
80
|
+
},
|
|
81
|
+
catch: () => new DirectoryNotAccessibleError({ directory }),
|
|
82
|
+
});
|
|
83
|
+
if (accessCheck instanceof Error) {
|
|
84
|
+
return accessCheck;
|
|
88
85
|
}
|
|
89
86
|
const port = await getOpenPort();
|
|
90
87
|
const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
|
|
@@ -127,8 +124,10 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
127
124
|
if (retryCount < 5) {
|
|
128
125
|
serverRetryCount.set(directory, retryCount + 1);
|
|
129
126
|
opencodeLogger.log(`Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`);
|
|
130
|
-
initializeOpencodeForDirectory(directory).
|
|
131
|
-
|
|
127
|
+
initializeOpencodeForDirectory(directory).then((result) => {
|
|
128
|
+
if (result instanceof Error) {
|
|
129
|
+
opencodeLogger.error(`Failed to restart opencode server:`, result);
|
|
130
|
+
}
|
|
132
131
|
});
|
|
133
132
|
}
|
|
134
133
|
else {
|
|
@@ -139,18 +138,16 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
139
138
|
serverRetryCount.delete(directory);
|
|
140
139
|
}
|
|
141
140
|
});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
opencodeLogger.log(`Server ready on port ${port}`);
|
|
145
|
-
}
|
|
146
|
-
catch (e) {
|
|
141
|
+
const waitResult = await waitForServer(port);
|
|
142
|
+
if (waitResult instanceof Error) {
|
|
147
143
|
// Dump buffered logs on failure
|
|
148
144
|
opencodeLogger.error(`Server failed to start for ${directory}:`);
|
|
149
145
|
for (const line of logBuffer) {
|
|
150
146
|
opencodeLogger.error(` ${line}`);
|
|
151
147
|
}
|
|
152
|
-
|
|
148
|
+
return waitResult;
|
|
153
149
|
}
|
|
150
|
+
opencodeLogger.log(`Server ready on port ${port}`);
|
|
154
151
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
155
152
|
const fetchWithTimeout = (request) => fetch(request, {
|
|
156
153
|
// @ts-ignore
|
|
@@ -173,7 +170,7 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
173
170
|
return () => {
|
|
174
171
|
const entry = opencodeServers.get(directory);
|
|
175
172
|
if (!entry?.client) {
|
|
176
|
-
throw new
|
|
173
|
+
throw new ServerNotReadyError({ directory });
|
|
177
174
|
}
|
|
178
175
|
return entry.client;
|
|
179
176
|
};
|