shuvmaki 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.
Files changed (94) hide show
  1. package/bin.js +70 -0
  2. package/dist/ai-tool-to-genai.js +210 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/channel-management.js +97 -0
  5. package/dist/cli.js +709 -0
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +184 -0
  22. package/dist/discord-bot.js +384 -0
  23. package/dist/discord-utils.js +217 -0
  24. package/dist/escape-backticks.test.js +410 -0
  25. package/dist/format-tables.js +96 -0
  26. package/dist/format-tables.test.js +418 -0
  27. package/dist/genai-worker-wrapper.js +109 -0
  28. package/dist/genai-worker.js +297 -0
  29. package/dist/genai.js +232 -0
  30. package/dist/interaction-handler.js +144 -0
  31. package/dist/logger.js +51 -0
  32. package/dist/markdown.js +310 -0
  33. package/dist/markdown.test.js +262 -0
  34. package/dist/message-formatting.js +273 -0
  35. package/dist/message-formatting.test.js +73 -0
  36. package/dist/openai-realtime.js +228 -0
  37. package/dist/opencode.js +216 -0
  38. package/dist/session-handler.js +580 -0
  39. package/dist/system-message.js +61 -0
  40. package/dist/tools.js +356 -0
  41. package/dist/utils.js +85 -0
  42. package/dist/voice-handler.js +541 -0
  43. package/dist/voice.js +314 -0
  44. package/dist/worker-types.js +4 -0
  45. package/dist/xml.js +92 -0
  46. package/dist/xml.test.js +32 -0
  47. package/package.json +60 -0
  48. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  49. package/src/__snapshots__/compact-session-context.md +47 -0
  50. package/src/ai-tool-to-genai.test.ts +296 -0
  51. package/src/ai-tool-to-genai.ts +255 -0
  52. package/src/channel-management.ts +161 -0
  53. package/src/cli.ts +1010 -0
  54. package/src/commands/abort.ts +94 -0
  55. package/src/commands/add-project.ts +139 -0
  56. package/src/commands/agent.ts +201 -0
  57. package/src/commands/ask-question.ts +276 -0
  58. package/src/commands/create-new-project.ts +111 -0
  59. package/src/commands/fork.ts +257 -0
  60. package/src/commands/model.ts +402 -0
  61. package/src/commands/permissions.ts +146 -0
  62. package/src/commands/queue.ts +181 -0
  63. package/src/commands/resume.ts +230 -0
  64. package/src/commands/session.ts +184 -0
  65. package/src/commands/share.ts +96 -0
  66. package/src/commands/types.ts +25 -0
  67. package/src/commands/undo-redo.ts +213 -0
  68. package/src/commands/user-command.ts +178 -0
  69. package/src/database.ts +220 -0
  70. package/src/discord-bot.ts +513 -0
  71. package/src/discord-utils.ts +282 -0
  72. package/src/escape-backticks.test.ts +447 -0
  73. package/src/format-tables.test.ts +440 -0
  74. package/src/format-tables.ts +110 -0
  75. package/src/genai-worker-wrapper.ts +160 -0
  76. package/src/genai-worker.ts +366 -0
  77. package/src/genai.ts +321 -0
  78. package/src/interaction-handler.ts +187 -0
  79. package/src/logger.ts +57 -0
  80. package/src/markdown.test.ts +358 -0
  81. package/src/markdown.ts +365 -0
  82. package/src/message-formatting.test.ts +81 -0
  83. package/src/message-formatting.ts +340 -0
  84. package/src/openai-realtime.ts +363 -0
  85. package/src/opencode.ts +277 -0
  86. package/src/session-handler.ts +758 -0
  87. package/src/system-message.ts +62 -0
  88. package/src/tools.ts +428 -0
  89. package/src/utils.ts +118 -0
  90. package/src/voice-handler.ts +760 -0
  91. package/src/voice.ts +432 -0
  92. package/src/worker-types.ts +66 -0
  93. package/src/xml.test.ts +37 -0
  94. package/src/xml.ts +121 -0
@@ -0,0 +1,580 @@
1
+ // OpenCode session lifecycle manager.
2
+ // Creates, maintains, and sends prompts to OpenCode sessions from Discord threads.
3
+ // Handles streaming events, permissions, abort signals, and message queuing.
4
+ import prettyMilliseconds from 'pretty-ms';
5
+ import { getDatabase, getSessionModel, getChannelModel, getSessionAgent, getChannelAgent } from './database.js';
6
+ import { initializeOpencodeForDirectory, getOpencodeServers, getOpencodeClientV2 } from './opencode.js';
7
+ import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from './discord-utils.js';
8
+ import { formatPart } from './message-formatting.js';
9
+ import { getOpencodeSystemMessage } from './system-message.js';
10
+ import { createLogger } from './logger.js';
11
+ import { isAbortError } from './utils.js';
12
+ import { showAskUserQuestionDropdowns } from './commands/ask-question.js';
13
+ const sessionLogger = createLogger('SESSION');
14
+ const voiceLogger = createLogger('VOICE');
15
+ const discordLogger = createLogger('DISCORD');
16
+ export const abortControllers = new Map();
17
+ export const pendingPermissions = new Map();
18
+ // Queue of messages waiting to be sent after current response finishes
19
+ // Key is threadId, value is array of queued messages
20
+ export const messageQueue = new Map();
21
+ export function addToQueue({ threadId, message, }) {
22
+ const queue = messageQueue.get(threadId) || [];
23
+ queue.push(message);
24
+ messageQueue.set(threadId, queue);
25
+ return queue.length;
26
+ }
27
+ export function getQueueLength(threadId) {
28
+ return messageQueue.get(threadId)?.length || 0;
29
+ }
30
+ export function clearQueue(threadId) {
31
+ messageQueue.delete(threadId);
32
+ }
33
+ /**
34
+ * Abort a running session and retry with the last user message.
35
+ * Used when model preference changes mid-request.
36
+ * Fetches last user message from OpenCode API instead of tracking in memory.
37
+ * @returns true if aborted and retry scheduled, false if no active request
38
+ */
39
+ export async function abortAndRetrySession({ sessionId, thread, projectDirectory, }) {
40
+ const controller = abortControllers.get(sessionId);
41
+ if (!controller) {
42
+ sessionLogger.log(`[ABORT+RETRY] No active request for session ${sessionId}`);
43
+ return false;
44
+ }
45
+ sessionLogger.log(`[ABORT+RETRY] Aborting session ${sessionId} for model change`);
46
+ // Abort with special reason so we don't show "completed" message
47
+ controller.abort('model-change');
48
+ // Also call the API abort endpoint
49
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
50
+ try {
51
+ await getClient().session.abort({ path: { id: sessionId } });
52
+ }
53
+ catch (e) {
54
+ sessionLogger.log(`[ABORT+RETRY] API abort call failed (may already be done):`, e);
55
+ }
56
+ // Small delay to let the abort propagate
57
+ await new Promise((resolve) => { setTimeout(resolve, 300); });
58
+ // Fetch last user message from API
59
+ sessionLogger.log(`[ABORT+RETRY] Fetching last user message for session ${sessionId}`);
60
+ const messagesResponse = await getClient().session.messages({ path: { id: sessionId } });
61
+ const messages = messagesResponse.data || [];
62
+ const lastUserMessage = [...messages].reverse().find((m) => m.info.role === 'user');
63
+ if (!lastUserMessage) {
64
+ sessionLogger.log(`[ABORT+RETRY] No user message found in session ${sessionId}`);
65
+ return false;
66
+ }
67
+ // Extract text and images from parts
68
+ const textPart = lastUserMessage.parts.find((p) => p.type === 'text');
69
+ const prompt = textPart?.text || '';
70
+ const images = lastUserMessage.parts.filter((p) => p.type === 'file');
71
+ sessionLogger.log(`[ABORT+RETRY] Re-triggering session ${sessionId} with new model`);
72
+ // Use setImmediate to avoid blocking
73
+ setImmediate(() => {
74
+ handleOpencodeSession({
75
+ prompt,
76
+ thread,
77
+ projectDirectory,
78
+ images,
79
+ }).catch(async (e) => {
80
+ sessionLogger.error(`[ABORT+RETRY] Failed to retry:`, e);
81
+ const errorMsg = e instanceof Error ? e.message : String(e);
82
+ await sendThreadMessage(thread, `✗ Failed to retry with new model: ${errorMsg.slice(0, 200)}`);
83
+ });
84
+ });
85
+ return true;
86
+ }
87
+ export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], channelId, command, }) {
88
+ voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
89
+ const sessionStartTime = Date.now();
90
+ const directory = projectDirectory || process.cwd();
91
+ sessionLogger.log(`Using directory: ${directory}`);
92
+ const getClient = await initializeOpencodeForDirectory(directory);
93
+ const serverEntry = getOpencodeServers().get(directory);
94
+ const port = serverEntry?.port;
95
+ const row = getDatabase()
96
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
97
+ .get(thread.id);
98
+ let sessionId = row?.session_id;
99
+ let session;
100
+ if (sessionId) {
101
+ sessionLogger.log(`Attempting to reuse existing session ${sessionId}`);
102
+ try {
103
+ const sessionResponse = await getClient().session.get({
104
+ path: { id: sessionId },
105
+ });
106
+ session = sessionResponse.data;
107
+ sessionLogger.log(`Successfully reused session ${sessionId}`);
108
+ }
109
+ catch (error) {
110
+ voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`);
111
+ }
112
+ }
113
+ if (!session) {
114
+ const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80);
115
+ voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`);
116
+ const sessionResponse = await getClient().session.create({
117
+ body: { title: sessionTitle },
118
+ });
119
+ session = sessionResponse.data;
120
+ sessionLogger.log(`Created new session ${session?.id}`);
121
+ }
122
+ if (!session) {
123
+ throw new Error('Failed to create or get session');
124
+ }
125
+ getDatabase()
126
+ .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
127
+ .run(thread.id, session.id);
128
+ sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`);
129
+ const existingController = abortControllers.get(session.id);
130
+ if (existingController) {
131
+ voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
132
+ existingController.abort(new Error('New request started'));
133
+ }
134
+ const pendingPerm = pendingPermissions.get(thread.id);
135
+ if (pendingPerm) {
136
+ try {
137
+ sessionLogger.log(`[PERMISSION] Auto-rejecting pending permission ${pendingPerm.permission.id} due to new message`);
138
+ await getClient().postSessionIdPermissionsPermissionId({
139
+ path: {
140
+ id: pendingPerm.permission.sessionID,
141
+ permissionID: pendingPerm.permission.id,
142
+ },
143
+ body: { response: 'reject' },
144
+ });
145
+ pendingPermissions.delete(thread.id);
146
+ await sendThreadMessage(thread, `⚠️ Previous permission request auto-rejected due to new message`);
147
+ }
148
+ catch (e) {
149
+ sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e);
150
+ pendingPermissions.delete(thread.id);
151
+ }
152
+ }
153
+ const abortController = new AbortController();
154
+ abortControllers.set(session.id, abortController);
155
+ if (existingController) {
156
+ await new Promise((resolve) => { setTimeout(resolve, 200); });
157
+ if (abortController.signal.aborted) {
158
+ sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`);
159
+ return;
160
+ }
161
+ }
162
+ if (abortController.signal.aborted) {
163
+ sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
164
+ return;
165
+ }
166
+ // Use v2 client for event subscription (has proper types for question.asked events)
167
+ const clientV2 = getOpencodeClientV2(directory);
168
+ if (!clientV2) {
169
+ throw new Error(`OpenCode v2 client not found for directory: ${directory}`);
170
+ }
171
+ const eventsResult = await clientV2.event.subscribe({ directory }, { signal: abortController.signal });
172
+ if (abortController.signal.aborted) {
173
+ sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
174
+ return;
175
+ }
176
+ const events = eventsResult.stream;
177
+ sessionLogger.log(`Subscribed to OpenCode events`);
178
+ const sentPartIds = new Set(getDatabase()
179
+ .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
180
+ .all(thread.id)
181
+ .map((row) => row.part_id));
182
+ let currentParts = [];
183
+ let stopTyping = null;
184
+ let usedModel;
185
+ let usedProviderID;
186
+ let usedAgent;
187
+ let tokensUsedInSession = 0;
188
+ let lastDisplayedContextPercentage = 0;
189
+ let modelContextLimit;
190
+ let typingInterval = null;
191
+ function startTyping() {
192
+ if (abortController.signal.aborted) {
193
+ discordLogger.log(`Not starting typing, already aborted`);
194
+ return () => { };
195
+ }
196
+ if (typingInterval) {
197
+ clearInterval(typingInterval);
198
+ typingInterval = null;
199
+ }
200
+ thread.sendTyping().catch((e) => {
201
+ discordLogger.log(`Failed to send initial typing: ${e}`);
202
+ });
203
+ typingInterval = setInterval(() => {
204
+ thread.sendTyping().catch((e) => {
205
+ discordLogger.log(`Failed to send periodic typing: ${e}`);
206
+ });
207
+ }, 8000);
208
+ if (!abortController.signal.aborted) {
209
+ abortController.signal.addEventListener('abort', () => {
210
+ if (typingInterval) {
211
+ clearInterval(typingInterval);
212
+ typingInterval = null;
213
+ }
214
+ }, { once: true });
215
+ }
216
+ return () => {
217
+ if (typingInterval) {
218
+ clearInterval(typingInterval);
219
+ typingInterval = null;
220
+ }
221
+ };
222
+ }
223
+ const sendPartMessage = async (part) => {
224
+ const content = formatPart(part) + '\n\n';
225
+ if (!content.trim() || content.length === 0) {
226
+ // discordLogger.log(`SKIP: Part ${part.id} has no content`)
227
+ return;
228
+ }
229
+ if (sentPartIds.has(part.id)) {
230
+ return;
231
+ }
232
+ try {
233
+ const firstMessage = await sendThreadMessage(thread, content);
234
+ sentPartIds.add(part.id);
235
+ getDatabase()
236
+ .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
237
+ .run(part.id, firstMessage.id, thread.id);
238
+ }
239
+ catch (error) {
240
+ discordLogger.error(`ERROR: Failed to send part ${part.id}:`, error);
241
+ }
242
+ };
243
+ const eventHandler = async () => {
244
+ try {
245
+ let assistantMessageId;
246
+ for await (const event of events) {
247
+ if (event.type === 'message.updated') {
248
+ const msg = event.properties.info;
249
+ if (msg.sessionID !== session.id) {
250
+ continue;
251
+ }
252
+ if (msg.role === 'assistant') {
253
+ const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write;
254
+ if (newTokensTotal > 0) {
255
+ tokensUsedInSession = newTokensTotal;
256
+ }
257
+ assistantMessageId = msg.id;
258
+ usedModel = msg.modelID;
259
+ usedProviderID = msg.providerID;
260
+ usedAgent = msg.mode;
261
+ if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
262
+ if (!modelContextLimit) {
263
+ try {
264
+ const providersResponse = await getClient().provider.list({ query: { directory } });
265
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
266
+ const model = provider?.models?.[usedModel];
267
+ if (model?.limit?.context) {
268
+ modelContextLimit = model.limit.context;
269
+ }
270
+ }
271
+ catch (e) {
272
+ sessionLogger.error('Failed to fetch provider info for context limit:', e);
273
+ }
274
+ }
275
+ if (modelContextLimit) {
276
+ const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100);
277
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
278
+ if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
279
+ lastDisplayedContextPercentage = thresholdCrossed;
280
+ await sendThreadMessage(thread, `⬥ context usage ${currentPercentage}%`);
281
+ }
282
+ }
283
+ }
284
+ }
285
+ }
286
+ else if (event.type === 'message.part.updated') {
287
+ const part = event.properties.part;
288
+ if (part.sessionID !== session.id) {
289
+ continue;
290
+ }
291
+ if (part.messageID !== assistantMessageId) {
292
+ continue;
293
+ }
294
+ const existingIndex = currentParts.findIndex((p) => p.id === part.id);
295
+ if (existingIndex >= 0) {
296
+ currentParts[existingIndex] = part;
297
+ }
298
+ else {
299
+ currentParts.push(part);
300
+ }
301
+ if (part.type === 'step-start') {
302
+ stopTyping = startTyping();
303
+ }
304
+ if (part.type === 'tool' && part.state.status === 'running') {
305
+ await sendPartMessage(part);
306
+ }
307
+ if (part.type === 'reasoning') {
308
+ await sendPartMessage(part);
309
+ }
310
+ if (part.type === 'step-finish') {
311
+ for (const p of currentParts) {
312
+ if (p.type !== 'step-start' && p.type !== 'step-finish') {
313
+ await sendPartMessage(p);
314
+ }
315
+ }
316
+ setTimeout(() => {
317
+ if (abortController.signal.aborted)
318
+ return;
319
+ stopTyping = startTyping();
320
+ }, 300);
321
+ }
322
+ }
323
+ else if (event.type === 'session.error') {
324
+ sessionLogger.error(`ERROR:`, event.properties);
325
+ if (event.properties.sessionID === session.id) {
326
+ const errorData = event.properties.error;
327
+ const errorMessage = errorData?.data?.message || 'Unknown error';
328
+ sessionLogger.error(`Sending error to thread: ${errorMessage}`);
329
+ await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`);
330
+ if (originalMessage) {
331
+ try {
332
+ await originalMessage.reactions.removeAll();
333
+ await originalMessage.react('❌');
334
+ voiceLogger.log(`[REACTION] Added error reaction due to session error`);
335
+ }
336
+ catch (e) {
337
+ discordLogger.log(`Could not update reaction:`, e);
338
+ }
339
+ }
340
+ }
341
+ else {
342
+ voiceLogger.log(`[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${event.properties.sessionID})`);
343
+ }
344
+ break;
345
+ }
346
+ else if (event.type === 'permission.asked') {
347
+ const permission = event.properties;
348
+ if (permission.sessionID !== session.id) {
349
+ voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
350
+ continue;
351
+ }
352
+ sessionLogger.log(`Permission requested: permission=${permission.permission}, patterns=${permission.patterns.join(', ')}`);
353
+ const patternStr = permission.patterns.join(', ');
354
+ const permissionMessage = await sendThreadMessage(thread, `⚠️ **Permission Required**\n\n` +
355
+ `**Type:** \`${permission.permission}\`\n` +
356
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
357
+ `\nUse \`/accept\` or \`/reject\` to respond.`);
358
+ pendingPermissions.set(thread.id, {
359
+ permission,
360
+ messageId: permissionMessage.id,
361
+ directory,
362
+ });
363
+ }
364
+ else if (event.type === 'permission.replied') {
365
+ const { requestID, reply, sessionID } = event.properties;
366
+ if (sessionID !== session.id) {
367
+ continue;
368
+ }
369
+ sessionLogger.log(`Permission ${requestID} replied with: ${reply}`);
370
+ const pending = pendingPermissions.get(thread.id);
371
+ if (pending && pending.permission.id === requestID) {
372
+ pendingPermissions.delete(thread.id);
373
+ }
374
+ }
375
+ else if (event.type === 'question.asked') {
376
+ const questionRequest = event.properties;
377
+ if (questionRequest.sessionID !== session.id) {
378
+ sessionLogger.log(`[QUESTION IGNORED] Question for different session (expected: ${session.id}, got: ${questionRequest.sessionID})`);
379
+ continue;
380
+ }
381
+ sessionLogger.log(`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`);
382
+ await showAskUserQuestionDropdowns({
383
+ thread,
384
+ sessionId: session.id,
385
+ directory,
386
+ requestId: questionRequest.id,
387
+ input: { questions: questionRequest.questions },
388
+ });
389
+ }
390
+ }
391
+ }
392
+ catch (e) {
393
+ if (isAbortError(e, abortController.signal)) {
394
+ sessionLogger.log('AbortController aborted event handling (normal exit)');
395
+ return;
396
+ }
397
+ sessionLogger.error(`Unexpected error in event handling code`, e);
398
+ throw e;
399
+ }
400
+ finally {
401
+ for (const part of currentParts) {
402
+ if (!sentPartIds.has(part.id)) {
403
+ try {
404
+ await sendPartMessage(part);
405
+ }
406
+ catch (error) {
407
+ sessionLogger.error(`Failed to send part ${part.id}:`, error);
408
+ }
409
+ }
410
+ }
411
+ if (stopTyping) {
412
+ stopTyping();
413
+ stopTyping = null;
414
+ }
415
+ if (!abortController.signal.aborted ||
416
+ abortController.signal.reason === 'finished') {
417
+ const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
418
+ const attachCommand = port ? ` ⋅ ${session.id}` : '';
419
+ const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
420
+ const agentInfo = usedAgent && usedAgent.toLowerCase() !== 'build' ? ` ⋅ **${usedAgent}**` : '';
421
+ let contextInfo = '';
422
+ try {
423
+ const providersResponse = await getClient().provider.list({ query: { directory } });
424
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
425
+ const model = provider?.models?.[usedModel || ''];
426
+ if (model?.limit?.context) {
427
+ const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100);
428
+ contextInfo = ` ⋅ ${percentage}%`;
429
+ }
430
+ }
431
+ catch (e) {
432
+ sessionLogger.error('Failed to fetch provider info for context percentage:', e);
433
+ }
434
+ await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}${agentInfo}`, { flags: NOTIFY_MESSAGE_FLAGS });
435
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
436
+ // Process queued messages after completion
437
+ const queue = messageQueue.get(thread.id);
438
+ if (queue && queue.length > 0) {
439
+ const nextMessage = queue.shift();
440
+ if (queue.length === 0) {
441
+ messageQueue.delete(thread.id);
442
+ }
443
+ sessionLogger.log(`[QUEUE] Processing queued message from ${nextMessage.username}`);
444
+ // Show that queued message is being sent
445
+ await sendThreadMessage(thread, `» **${nextMessage.username}:** ${nextMessage.prompt.slice(0, 150)}${nextMessage.prompt.length > 150 ? '...' : ''}`);
446
+ // Send the queued message as a new prompt (recursive call)
447
+ // Use setImmediate to avoid blocking and allow this finally to complete
448
+ setImmediate(() => {
449
+ handleOpencodeSession({
450
+ prompt: nextMessage.prompt,
451
+ thread,
452
+ projectDirectory,
453
+ images: nextMessage.images,
454
+ channelId,
455
+ }).catch(async (e) => {
456
+ sessionLogger.error(`[QUEUE] Failed to process queued message:`, e);
457
+ const errorMsg = e instanceof Error ? e.message : String(e);
458
+ await sendThreadMessage(thread, `✗ Queued message failed: ${errorMsg.slice(0, 200)}`);
459
+ });
460
+ });
461
+ }
462
+ }
463
+ else {
464
+ sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
465
+ }
466
+ }
467
+ };
468
+ try {
469
+ const eventHandlerPromise = eventHandler();
470
+ if (abortController.signal.aborted) {
471
+ sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
472
+ return;
473
+ }
474
+ stopTyping = startTyping();
475
+ voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
476
+ // append image paths to prompt so ai knows where they are on disk
477
+ const promptWithImagePaths = (() => {
478
+ if (images.length === 0) {
479
+ return prompt;
480
+ }
481
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
482
+ const imagePathsList = images.map((img) => `- ${img.filename}: ${img.url}`).join('\n');
483
+ return `${prompt}\n\n**attached images:**\n${imagePathsList}`;
484
+ })();
485
+ const parts = [{ type: 'text', text: promptWithImagePaths }, ...images];
486
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
487
+ // Get model preference: session-level overrides channel-level
488
+ const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
489
+ const modelParam = (() => {
490
+ if (!modelPreference) {
491
+ return undefined;
492
+ }
493
+ const [providerID, ...modelParts] = modelPreference.split('/');
494
+ const modelID = modelParts.join('/');
495
+ if (!providerID || !modelID) {
496
+ return undefined;
497
+ }
498
+ sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
499
+ return { providerID, modelID };
500
+ })();
501
+ // Get agent preference: session-level overrides channel-level
502
+ const agentPreference = getSessionAgent(session.id) || (channelId ? getChannelAgent(channelId) : undefined);
503
+ if (agentPreference) {
504
+ sessionLogger.log(`[AGENT] Using agent preference: ${agentPreference}`);
505
+ }
506
+ // Use session.command API for slash commands, session.prompt for regular messages
507
+ const response = command
508
+ ? await getClient().session.command({
509
+ path: { id: session.id },
510
+ body: {
511
+ command: command.name,
512
+ arguments: command.arguments,
513
+ agent: agentPreference,
514
+ },
515
+ signal: abortController.signal,
516
+ })
517
+ : await getClient().session.prompt({
518
+ path: { id: session.id },
519
+ body: {
520
+ parts,
521
+ system: getOpencodeSystemMessage({ sessionId: session.id }),
522
+ model: modelParam,
523
+ agent: agentPreference,
524
+ },
525
+ signal: abortController.signal,
526
+ });
527
+ if (response.error) {
528
+ const errorMessage = (() => {
529
+ const err = response.error;
530
+ if (err && typeof err === 'object') {
531
+ if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
532
+ return String(err.data.message);
533
+ }
534
+ if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
535
+ return JSON.stringify(err.errors);
536
+ }
537
+ }
538
+ return JSON.stringify(err);
539
+ })();
540
+ throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`);
541
+ }
542
+ abortController.abort('finished');
543
+ sessionLogger.log(`Successfully sent prompt, got response`);
544
+ if (originalMessage) {
545
+ try {
546
+ await originalMessage.reactions.removeAll();
547
+ await originalMessage.react('✅');
548
+ }
549
+ catch (e) {
550
+ discordLogger.log(`Could not update reactions:`, e);
551
+ }
552
+ }
553
+ return { sessionID: session.id, result: response.data, port };
554
+ }
555
+ catch (error) {
556
+ sessionLogger.error(`ERROR: Failed to send prompt:`, error);
557
+ if (!isAbortError(error, abortController.signal)) {
558
+ abortController.abort('error');
559
+ if (originalMessage) {
560
+ try {
561
+ await originalMessage.reactions.removeAll();
562
+ await originalMessage.react('❌');
563
+ discordLogger.log(`Added error reaction to message`);
564
+ }
565
+ catch (e) {
566
+ discordLogger.log(`Could not update reaction:`, e);
567
+ }
568
+ }
569
+ const errorName = error &&
570
+ typeof error === 'object' &&
571
+ 'constructor' in error &&
572
+ error.constructor &&
573
+ typeof error.constructor.name === 'string'
574
+ ? error.constructor.name
575
+ : typeof error;
576
+ const errorMsg = error instanceof Error ? error.stack || error.message : String(error);
577
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`);
578
+ }
579
+ }
580
+ }
@@ -0,0 +1,61 @@
1
+ // OpenCode system prompt generator.
2
+ // Creates the system message injected into every OpenCode session,
3
+ // including Discord-specific formatting rules, diff commands, and permissions info.
4
+ export function getOpencodeSystemMessage({ sessionId }) {
5
+ return `
6
+ The user is reading your messages from inside Discord, via kimaki.xyz
7
+
8
+ The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
9
+
10
+ Your current OpenCode session ID is: ${sessionId}
11
+
12
+ ## permissions
13
+
14
+ Only users with these Discord permissions can send messages to the bot:
15
+ - Server Owner
16
+ - Administrator permission
17
+ - Manage Server permission
18
+ - "Kimaki" role (case-insensitive)
19
+
20
+ ## uploading files to discord
21
+
22
+ To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
23
+
24
+ npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
25
+
26
+ ## showing diffs
27
+
28
+ IMPORTANT: After editing any files, you MUST execute the critique command using the Bash tool to get a diff URL, then share that URL with the user.
29
+
30
+ Execute this after making changes:
31
+
32
+ bunx critique web
33
+
34
+ If there are other unrelated changes in the working directory, filter to only show the files you edited:
35
+
36
+ bunx critique web -- path/to/file1.ts path/to/file2.ts
37
+
38
+ You can also show latest commit changes using:
39
+
40
+ bunx critique web HEAD
41
+
42
+ bunx critique web HEAD~1 to get the one before last
43
+
44
+ Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
45
+
46
+ The command outputs a URL - share that URL with the user so they can see the diff.
47
+
48
+ ## markdown
49
+
50
+ discord does support basic markdown features like code blocks, code blocks languages, inline code, bold, italic, quotes, etc.
51
+
52
+ the max heading level is 3, so do not use ####
53
+
54
+ headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
55
+
56
+
57
+ ## diagrams
58
+
59
+ you can create diagrams wrapping them in code blocks.
60
+ `;
61
+ }