kimaki 0.4.25 → 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/dist/acp-client.test.js +149 -0
- package/dist/channel-management.js +11 -9
- package/dist/cli.js +59 -7
- package/dist/commands/add-project.js +1 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/model.js +23 -4
- package/dist/commands/session.js +1 -3
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +51 -0
- package/dist/discord-bot.js +32 -32
- package/dist/discord-utils.js +71 -14
- package/dist/interaction-handler.js +20 -0
- package/dist/logger.js +43 -5
- package/dist/markdown.js +104 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +72 -22
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +70 -16
- package/dist/session-handler.js +131 -62
- package/dist/system-message.js +4 -51
- package/dist/voice-handler.js +18 -8
- package/dist/voice.js +28 -12
- package/package.json +14 -13
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/channel-management.ts +20 -8
- package/src/cli.ts +74 -8
- package/src/commands/add-project.ts +1 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/fork.ts +1 -2
- package/src/commands/model.ts +24 -4
- package/src/commands/session.ts +1 -3
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +61 -0
- package/src/discord-bot.ts +36 -33
- package/src/discord-utils.ts +76 -14
- package/src/interaction-handler.ts +25 -0
- package/src/logger.ts +47 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +132 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +93 -25
- package/src/opencode.ts +80 -21
- package/src/session-handler.ts +180 -90
- package/src/system-message.ts +4 -51
- package/src/voice-handler.ts +20 -9
- package/src/voice.ts +32 -13
- package/LICENSE +0 -21
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
|
+
}
|
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,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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
+
});
|
package/dist/opencode.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// Spawns and maintains OpenCode API servers per project directory,
|
|
3
3
|
// handles automatic restarts on failure, and provides typed SDK clients.
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
5
6
|
import net from 'node:net';
|
|
6
7
|
import { createOpencodeClient, } from '@opencode-ai/sdk';
|
|
8
|
+
import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
|
|
7
9
|
import { createLogger } from './logger.js';
|
|
8
10
|
const opencodeLogger = createLogger('OPENCODE');
|
|
9
11
|
const opencodeServers = new Map();
|
|
@@ -30,22 +32,37 @@ async function waitForServer(port, maxAttempts = 30) {
|
|
|
30
32
|
for (let i = 0; i < maxAttempts; i++) {
|
|
31
33
|
try {
|
|
32
34
|
const endpoints = [
|
|
33
|
-
`http://
|
|
34
|
-
`http://
|
|
35
|
-
`http://
|
|
35
|
+
`http://127.0.0.1:${port}/api/health`,
|
|
36
|
+
`http://127.0.0.1:${port}/`,
|
|
37
|
+
`http://127.0.0.1:${port}/api`,
|
|
36
38
|
];
|
|
37
39
|
for (const endpoint of endpoints) {
|
|
38
40
|
try {
|
|
39
41
|
const response = await fetch(endpoint);
|
|
40
42
|
if (response.status < 500) {
|
|
41
|
-
opencodeLogger.log(`Server ready on port `);
|
|
42
43
|
return true;
|
|
43
44
|
}
|
|
45
|
+
const body = await response.text();
|
|
46
|
+
// Fatal errors that won't resolve with retrying
|
|
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
|
+
}
|
|
44
56
|
}
|
|
45
|
-
catch (e) { }
|
|
46
57
|
}
|
|
47
58
|
}
|
|
48
|
-
catch (e) {
|
|
59
|
+
catch (e) {
|
|
60
|
+
// Re-throw fatal errors that won't resolve with retrying
|
|
61
|
+
if (e.message?.includes('Server failed to start')) {
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
opencodeLogger.debug(`Server polling attempt failed: ${e.message}`);
|
|
65
|
+
}
|
|
49
66
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
50
67
|
}
|
|
51
68
|
throw new Error(`Server did not start on port ${port} after ${maxAttempts} seconds`);
|
|
@@ -62,8 +79,16 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
62
79
|
return entry.client;
|
|
63
80
|
};
|
|
64
81
|
}
|
|
82
|
+
// Verify directory exists and is accessible before spawning
|
|
83
|
+
try {
|
|
84
|
+
fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new Error(`Directory does not exist or is not accessible: ${directory}`);
|
|
88
|
+
}
|
|
65
89
|
const port = await getOpenPort();
|
|
66
|
-
const
|
|
90
|
+
const opencodeBinDir = `${process.env.HOME}/.opencode/bin`;
|
|
91
|
+
const opencodeCommand = process.env.OPENCODE_PATH || `${opencodeBinDir}/opencode`;
|
|
67
92
|
const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
|
|
68
93
|
stdio: 'pipe',
|
|
69
94
|
detached: false,
|
|
@@ -83,14 +108,17 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
83
108
|
OPENCODE_PORT: port.toString(),
|
|
84
109
|
},
|
|
85
110
|
});
|
|
111
|
+
// Buffer logs until we know if server started successfully
|
|
112
|
+
const logBuffer = [];
|
|
113
|
+
logBuffer.push(`Spawned opencode serve --port ${port} in ${directory} (pid: ${serverProcess.pid})`);
|
|
86
114
|
serverProcess.stdout?.on('data', (data) => {
|
|
87
|
-
|
|
115
|
+
logBuffer.push(`[stdout] ${data.toString().trim()}`);
|
|
88
116
|
});
|
|
89
117
|
serverProcess.stderr?.on('data', (data) => {
|
|
90
|
-
|
|
118
|
+
logBuffer.push(`[stderr] ${data.toString().trim()}`);
|
|
91
119
|
});
|
|
92
120
|
serverProcess.on('error', (error) => {
|
|
93
|
-
|
|
121
|
+
logBuffer.push(`Failed to start server on port ${port}: ${error}`);
|
|
94
122
|
});
|
|
95
123
|
serverProcess.on('exit', (code) => {
|
|
96
124
|
opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code);
|
|
@@ -112,17 +140,35 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
112
140
|
serverRetryCount.delete(directory);
|
|
113
141
|
}
|
|
114
142
|
});
|
|
115
|
-
|
|
143
|
+
try {
|
|
144
|
+
await waitForServer(port);
|
|
145
|
+
opencodeLogger.log(`Server ready on port ${port}`);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
// Dump buffered logs on failure
|
|
149
|
+
opencodeLogger.error(`Server failed to start for ${directory}:`);
|
|
150
|
+
for (const line of logBuffer) {
|
|
151
|
+
opencodeLogger.error(` ${line}`);
|
|
152
|
+
}
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
156
|
+
const fetchWithTimeout = (request) => fetch(request, {
|
|
157
|
+
// @ts-ignore
|
|
158
|
+
timeout: false,
|
|
159
|
+
});
|
|
116
160
|
const client = createOpencodeClient({
|
|
117
|
-
baseUrl
|
|
118
|
-
fetch:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
161
|
+
baseUrl,
|
|
162
|
+
fetch: fetchWithTimeout,
|
|
163
|
+
});
|
|
164
|
+
const clientV2 = createOpencodeClientV2({
|
|
165
|
+
baseUrl,
|
|
166
|
+
fetch: fetchWithTimeout,
|
|
122
167
|
});
|
|
123
168
|
opencodeServers.set(directory, {
|
|
124
169
|
process: serverProcess,
|
|
125
170
|
client,
|
|
171
|
+
clientV2,
|
|
126
172
|
port,
|
|
127
173
|
});
|
|
128
174
|
return () => {
|
|
@@ -136,3 +182,11 @@ export async function initializeOpencodeForDirectory(directory) {
|
|
|
136
182
|
export function getOpencodeServers() {
|
|
137
183
|
return opencodeServers;
|
|
138
184
|
}
|
|
185
|
+
export function getOpencodeServerPort(directory) {
|
|
186
|
+
const entry = opencodeServers.get(directory);
|
|
187
|
+
return entry?.port ?? null;
|
|
188
|
+
}
|
|
189
|
+
export function getOpencodeClientV2(directory) {
|
|
190
|
+
const entry = opencodeServers.get(directory);
|
|
191
|
+
return entry?.clientV2 ?? null;
|
|
192
|
+
}
|