kimaki 0.4.21 → 0.4.23

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 (43) hide show
  1. package/dist/channel-management.js +92 -0
  2. package/dist/cli.js +10 -2
  3. package/dist/database.js +130 -0
  4. package/dist/discord-bot.js +381 -0
  5. package/dist/discord-utils.js +151 -0
  6. package/dist/discordBot.js +60 -31
  7. package/dist/escape-backticks.test.js +1 -1
  8. package/dist/fork.js +163 -0
  9. package/dist/format-tables.js +93 -0
  10. package/dist/format-tables.test.js +418 -0
  11. package/dist/interaction-handler.js +750 -0
  12. package/dist/markdown.js +3 -3
  13. package/dist/message-formatting.js +188 -0
  14. package/dist/model-command.js +293 -0
  15. package/dist/opencode.js +135 -0
  16. package/dist/session-handler.js +467 -0
  17. package/dist/system-message.js +92 -0
  18. package/dist/tools.js +3 -5
  19. package/dist/utils.js +31 -0
  20. package/dist/voice-handler.js +528 -0
  21. package/dist/voice.js +257 -35
  22. package/package.json +3 -2
  23. package/src/channel-management.ts +145 -0
  24. package/src/cli.ts +10 -2
  25. package/src/database.ts +155 -0
  26. package/src/discord-bot.ts +506 -0
  27. package/src/discord-utils.ts +208 -0
  28. package/src/escape-backticks.test.ts +1 -1
  29. package/src/fork.ts +224 -0
  30. package/src/format-tables.test.ts +440 -0
  31. package/src/format-tables.ts +106 -0
  32. package/src/interaction-handler.ts +1000 -0
  33. package/src/markdown.ts +3 -3
  34. package/src/message-formatting.ts +227 -0
  35. package/src/model-command.ts +380 -0
  36. package/src/opencode.ts +180 -0
  37. package/src/session-handler.ts +601 -0
  38. package/src/system-message.ts +92 -0
  39. package/src/tools.ts +3 -5
  40. package/src/utils.ts +37 -0
  41. package/src/voice-handler.ts +745 -0
  42. package/src/voice.ts +354 -36
  43. package/src/discordBot.ts +0 -3643
@@ -0,0 +1,467 @@
1
+ import prettyMilliseconds from 'pretty-ms';
2
+ import { getDatabase, getSessionModel, getChannelModel } from './database.js';
3
+ import { initializeOpencodeForDirectory, getOpencodeServers } from './opencode.js';
4
+ import { sendThreadMessage } from './discord-utils.js';
5
+ import { formatPart } from './message-formatting.js';
6
+ import { getOpencodeSystemMessage } from './system-message.js';
7
+ import { createLogger } from './logger.js';
8
+ import { isAbortError } from './utils.js';
9
+ const sessionLogger = createLogger('SESSION');
10
+ const voiceLogger = createLogger('VOICE');
11
+ const discordLogger = createLogger('DISCORD');
12
+ export function parseSlashCommand(text) {
13
+ const trimmed = text.trim();
14
+ if (!trimmed.startsWith('/')) {
15
+ return { isCommand: false };
16
+ }
17
+ const match = trimmed.match(/^\/(\S+)(?:\s+(.*))?$/);
18
+ if (!match) {
19
+ return { isCommand: false };
20
+ }
21
+ const command = match[1];
22
+ const args = match[2]?.trim() || '';
23
+ return { isCommand: true, command, arguments: args };
24
+ }
25
+ export const abortControllers = new Map();
26
+ export const pendingPermissions = new Map();
27
+ export async function handleOpencodeSession({ prompt, thread, projectDirectory, originalMessage, images = [], parsedCommand, channelId, }) {
28
+ voiceLogger.log(`[OPENCODE SESSION] Starting for thread ${thread.id} with prompt: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`);
29
+ const sessionStartTime = Date.now();
30
+ const directory = projectDirectory || process.cwd();
31
+ sessionLogger.log(`Using directory: ${directory}`);
32
+ const getClient = await initializeOpencodeForDirectory(directory);
33
+ const serverEntry = getOpencodeServers().get(directory);
34
+ const port = serverEntry?.port;
35
+ const row = getDatabase()
36
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
37
+ .get(thread.id);
38
+ let sessionId = row?.session_id;
39
+ let session;
40
+ if (sessionId) {
41
+ sessionLogger.log(`Attempting to reuse existing session ${sessionId}`);
42
+ try {
43
+ const sessionResponse = await getClient().session.get({
44
+ path: { id: sessionId },
45
+ });
46
+ session = sessionResponse.data;
47
+ sessionLogger.log(`Successfully reused session ${sessionId}`);
48
+ }
49
+ catch (error) {
50
+ voiceLogger.log(`[SESSION] Session ${sessionId} not found, will create new one`);
51
+ }
52
+ }
53
+ if (!session) {
54
+ const sessionTitle = prompt.length > 80 ? prompt.slice(0, 77) + '...' : prompt.slice(0, 80);
55
+ voiceLogger.log(`[SESSION] Creating new session with title: "${sessionTitle}"`);
56
+ const sessionResponse = await getClient().session.create({
57
+ body: { title: sessionTitle },
58
+ });
59
+ session = sessionResponse.data;
60
+ sessionLogger.log(`Created new session ${session?.id}`);
61
+ }
62
+ if (!session) {
63
+ throw new Error('Failed to create or get session');
64
+ }
65
+ getDatabase()
66
+ .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
67
+ .run(thread.id, session.id);
68
+ sessionLogger.log(`Stored session ${session.id} for thread ${thread.id}`);
69
+ const existingController = abortControllers.get(session.id);
70
+ if (existingController) {
71
+ voiceLogger.log(`[ABORT] Cancelling existing request for session: ${session.id}`);
72
+ existingController.abort(new Error('New request started'));
73
+ }
74
+ const pendingPerm = pendingPermissions.get(thread.id);
75
+ if (pendingPerm) {
76
+ try {
77
+ sessionLogger.log(`[PERMISSION] Auto-rejecting pending permission ${pendingPerm.permission.id} due to new message`);
78
+ await getClient().postSessionIdPermissionsPermissionId({
79
+ path: {
80
+ id: pendingPerm.permission.sessionID,
81
+ permissionID: pendingPerm.permission.id,
82
+ },
83
+ body: { response: 'reject' },
84
+ });
85
+ pendingPermissions.delete(thread.id);
86
+ await sendThreadMessage(thread, `⚠️ Previous permission request auto-rejected due to new message`);
87
+ }
88
+ catch (e) {
89
+ sessionLogger.log(`[PERMISSION] Failed to auto-reject permission:`, e);
90
+ pendingPermissions.delete(thread.id);
91
+ }
92
+ }
93
+ const abortController = new AbortController();
94
+ abortControllers.set(session.id, abortController);
95
+ if (existingController) {
96
+ await new Promise((resolve) => { setTimeout(resolve, 200); });
97
+ if (abortController.signal.aborted) {
98
+ sessionLogger.log(`[DEBOUNCE] Request was superseded during wait, exiting`);
99
+ return;
100
+ }
101
+ }
102
+ if (abortController.signal.aborted) {
103
+ sessionLogger.log(`[DEBOUNCE] Aborted before subscribe, exiting`);
104
+ return;
105
+ }
106
+ const eventsResult = await getClient().event.subscribe({
107
+ signal: abortController.signal,
108
+ });
109
+ if (abortController.signal.aborted) {
110
+ sessionLogger.log(`[DEBOUNCE] Aborted during subscribe, exiting`);
111
+ return;
112
+ }
113
+ const events = eventsResult.stream;
114
+ sessionLogger.log(`Subscribed to OpenCode events`);
115
+ const sentPartIds = new Set(getDatabase()
116
+ .prepare('SELECT part_id FROM part_messages WHERE thread_id = ?')
117
+ .all(thread.id)
118
+ .map((row) => row.part_id));
119
+ let currentParts = [];
120
+ let stopTyping = null;
121
+ let usedModel;
122
+ let usedProviderID;
123
+ let tokensUsedInSession = 0;
124
+ let lastDisplayedContextPercentage = 0;
125
+ let modelContextLimit;
126
+ let typingInterval = null;
127
+ function startTyping() {
128
+ if (abortController.signal.aborted) {
129
+ discordLogger.log(`Not starting typing, already aborted`);
130
+ return () => { };
131
+ }
132
+ if (typingInterval) {
133
+ clearInterval(typingInterval);
134
+ typingInterval = null;
135
+ }
136
+ thread.sendTyping().catch((e) => {
137
+ discordLogger.log(`Failed to send initial typing: ${e}`);
138
+ });
139
+ typingInterval = setInterval(() => {
140
+ thread.sendTyping().catch((e) => {
141
+ discordLogger.log(`Failed to send periodic typing: ${e}`);
142
+ });
143
+ }, 8000);
144
+ if (!abortController.signal.aborted) {
145
+ abortController.signal.addEventListener('abort', () => {
146
+ if (typingInterval) {
147
+ clearInterval(typingInterval);
148
+ typingInterval = null;
149
+ }
150
+ }, { once: true });
151
+ }
152
+ return () => {
153
+ if (typingInterval) {
154
+ clearInterval(typingInterval);
155
+ typingInterval = null;
156
+ }
157
+ };
158
+ }
159
+ const sendPartMessage = async (part) => {
160
+ const content = formatPart(part) + '\n\n';
161
+ if (!content.trim() || content.length === 0) {
162
+ discordLogger.log(`SKIP: Part ${part.id} has no content`);
163
+ return;
164
+ }
165
+ if (sentPartIds.has(part.id)) {
166
+ return;
167
+ }
168
+ try {
169
+ const firstMessage = await sendThreadMessage(thread, content);
170
+ sentPartIds.add(part.id);
171
+ getDatabase()
172
+ .prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)')
173
+ .run(part.id, firstMessage.id, thread.id);
174
+ }
175
+ catch (error) {
176
+ discordLogger.error(`ERROR: Failed to send part ${part.id}:`, error);
177
+ }
178
+ };
179
+ const eventHandler = async () => {
180
+ try {
181
+ let assistantMessageId;
182
+ for await (const event of events) {
183
+ if (event.type === 'message.updated') {
184
+ const msg = event.properties.info;
185
+ if (msg.sessionID !== session.id) {
186
+ continue;
187
+ }
188
+ if (msg.role === 'assistant') {
189
+ const newTokensTotal = msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write;
190
+ if (newTokensTotal > 0) {
191
+ tokensUsedInSession = newTokensTotal;
192
+ }
193
+ assistantMessageId = msg.id;
194
+ usedModel = msg.modelID;
195
+ usedProviderID = msg.providerID;
196
+ if (tokensUsedInSession > 0 && usedProviderID && usedModel) {
197
+ if (!modelContextLimit) {
198
+ try {
199
+ const providersResponse = await getClient().provider.list({ query: { directory } });
200
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
201
+ const model = provider?.models?.[usedModel];
202
+ if (model?.limit?.context) {
203
+ modelContextLimit = model.limit.context;
204
+ }
205
+ }
206
+ catch (e) {
207
+ sessionLogger.error('Failed to fetch provider info for context limit:', e);
208
+ }
209
+ }
210
+ if (modelContextLimit) {
211
+ const currentPercentage = Math.floor((tokensUsedInSession / modelContextLimit) * 100);
212
+ const thresholdCrossed = Math.floor(currentPercentage / 10) * 10;
213
+ if (thresholdCrossed > lastDisplayedContextPercentage && thresholdCrossed >= 10) {
214
+ lastDisplayedContextPercentage = thresholdCrossed;
215
+ await sendThreadMessage(thread, `◼︎ context usage ${currentPercentage}%`);
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ else if (event.type === 'message.part.updated') {
222
+ const part = event.properties.part;
223
+ if (part.sessionID !== session.id) {
224
+ continue;
225
+ }
226
+ if (part.messageID !== assistantMessageId) {
227
+ continue;
228
+ }
229
+ const existingIndex = currentParts.findIndex((p) => p.id === part.id);
230
+ if (existingIndex >= 0) {
231
+ currentParts[existingIndex] = part;
232
+ }
233
+ else {
234
+ currentParts.push(part);
235
+ }
236
+ if (part.type === 'step-start') {
237
+ stopTyping = startTyping();
238
+ }
239
+ if (part.type === 'tool' && part.state.status === 'running') {
240
+ await sendPartMessage(part);
241
+ }
242
+ if (part.type === 'reasoning') {
243
+ await sendPartMessage(part);
244
+ }
245
+ if (part.type === 'step-finish') {
246
+ for (const p of currentParts) {
247
+ if (p.type !== 'step-start' && p.type !== 'step-finish') {
248
+ await sendPartMessage(p);
249
+ }
250
+ }
251
+ setTimeout(() => {
252
+ if (abortController.signal.aborted)
253
+ return;
254
+ stopTyping = startTyping();
255
+ }, 300);
256
+ }
257
+ }
258
+ else if (event.type === 'session.error') {
259
+ sessionLogger.error(`ERROR:`, event.properties);
260
+ if (event.properties.sessionID === session.id) {
261
+ const errorData = event.properties.error;
262
+ const errorMessage = errorData?.data?.message || 'Unknown error';
263
+ sessionLogger.error(`Sending error to thread: ${errorMessage}`);
264
+ await sendThreadMessage(thread, `✗ opencode session error: ${errorMessage}`);
265
+ if (originalMessage) {
266
+ try {
267
+ await originalMessage.reactions.removeAll();
268
+ await originalMessage.react('❌');
269
+ voiceLogger.log(`[REACTION] Added error reaction due to session error`);
270
+ }
271
+ catch (e) {
272
+ discordLogger.log(`Could not update reaction:`, e);
273
+ }
274
+ }
275
+ }
276
+ else {
277
+ voiceLogger.log(`[SESSION ERROR IGNORED] Error for different session (expected: ${session.id}, got: ${event.properties.sessionID})`);
278
+ }
279
+ break;
280
+ }
281
+ else if (event.type === 'permission.updated') {
282
+ const permission = event.properties;
283
+ if (permission.sessionID !== session.id) {
284
+ voiceLogger.log(`[PERMISSION IGNORED] Permission for different session (expected: ${session.id}, got: ${permission.sessionID})`);
285
+ continue;
286
+ }
287
+ sessionLogger.log(`Permission requested: type=${permission.type}, title=${permission.title}`);
288
+ const patternStr = Array.isArray(permission.pattern)
289
+ ? permission.pattern.join(', ')
290
+ : permission.pattern || '';
291
+ const permissionMessage = await sendThreadMessage(thread, `⚠️ **Permission Required**\n\n` +
292
+ `**Type:** \`${permission.type}\`\n` +
293
+ `**Action:** ${permission.title}\n` +
294
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
295
+ `\nUse \`/accept\` or \`/reject\` to respond.`);
296
+ pendingPermissions.set(thread.id, {
297
+ permission,
298
+ messageId: permissionMessage.id,
299
+ directory,
300
+ });
301
+ }
302
+ else if (event.type === 'permission.replied') {
303
+ const { permissionID, response, sessionID } = event.properties;
304
+ if (sessionID !== session.id) {
305
+ continue;
306
+ }
307
+ sessionLogger.log(`Permission ${permissionID} replied with: ${response}`);
308
+ const pending = pendingPermissions.get(thread.id);
309
+ if (pending && pending.permission.id === permissionID) {
310
+ pendingPermissions.delete(thread.id);
311
+ }
312
+ }
313
+ }
314
+ }
315
+ catch (e) {
316
+ if (isAbortError(e, abortController.signal)) {
317
+ sessionLogger.log('AbortController aborted event handling (normal exit)');
318
+ return;
319
+ }
320
+ sessionLogger.error(`Unexpected error in event handling code`, e);
321
+ throw e;
322
+ }
323
+ finally {
324
+ for (const part of currentParts) {
325
+ if (!sentPartIds.has(part.id)) {
326
+ try {
327
+ await sendPartMessage(part);
328
+ }
329
+ catch (error) {
330
+ sessionLogger.error(`Failed to send part ${part.id}:`, error);
331
+ }
332
+ }
333
+ }
334
+ if (stopTyping) {
335
+ stopTyping();
336
+ stopTyping = null;
337
+ }
338
+ if (!abortController.signal.aborted ||
339
+ abortController.signal.reason === 'finished') {
340
+ const sessionDuration = prettyMilliseconds(Date.now() - sessionStartTime);
341
+ const attachCommand = port ? ` ⋅ ${session.id}` : '';
342
+ const modelInfo = usedModel ? ` ⋅ ${usedModel}` : '';
343
+ let contextInfo = '';
344
+ try {
345
+ const providersResponse = await getClient().provider.list({ query: { directory } });
346
+ const provider = providersResponse.data?.all?.find((p) => p.id === usedProviderID);
347
+ const model = provider?.models?.[usedModel || ''];
348
+ if (model?.limit?.context) {
349
+ const percentage = Math.round((tokensUsedInSession / model.limit.context) * 100);
350
+ contextInfo = ` ⋅ ${percentage}%`;
351
+ }
352
+ }
353
+ catch (e) {
354
+ sessionLogger.error('Failed to fetch provider info for context percentage:', e);
355
+ }
356
+ await sendThreadMessage(thread, `_Completed in ${sessionDuration}${contextInfo}_${attachCommand}${modelInfo}`);
357
+ sessionLogger.log(`DURATION: Session completed in ${sessionDuration}, port ${port}, model ${usedModel}, tokens ${tokensUsedInSession}`);
358
+ }
359
+ else {
360
+ sessionLogger.log(`Session was aborted (reason: ${abortController.signal.reason}), skipping duration message`);
361
+ }
362
+ }
363
+ };
364
+ try {
365
+ const eventHandlerPromise = eventHandler();
366
+ if (abortController.signal.aborted) {
367
+ sessionLogger.log(`[DEBOUNCE] Aborted before prompt, exiting`);
368
+ return;
369
+ }
370
+ stopTyping = startTyping();
371
+ let response;
372
+ if (parsedCommand?.isCommand) {
373
+ sessionLogger.log(`[COMMAND] Sending command /${parsedCommand.command} to session ${session.id} with args: "${parsedCommand.arguments.slice(0, 100)}${parsedCommand.arguments.length > 100 ? '...' : ''}"`);
374
+ response = await getClient().session.command({
375
+ path: { id: session.id },
376
+ body: {
377
+ command: parsedCommand.command,
378
+ arguments: parsedCommand.arguments,
379
+ },
380
+ signal: abortController.signal,
381
+ });
382
+ }
383
+ else {
384
+ voiceLogger.log(`[PROMPT] Sending prompt to session ${session.id}: "${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}"`);
385
+ if (images.length > 0) {
386
+ sessionLogger.log(`[PROMPT] Sending ${images.length} image(s):`, images.map((img) => ({ mime: img.mime, filename: img.filename, url: img.url.slice(0, 100) })));
387
+ }
388
+ const parts = [{ type: 'text', text: prompt }, ...images];
389
+ sessionLogger.log(`[PROMPT] Parts to send:`, parts.length);
390
+ // Get model preference: session-level overrides channel-level
391
+ const modelPreference = getSessionModel(session.id) || (channelId ? getChannelModel(channelId) : undefined);
392
+ const modelParam = (() => {
393
+ if (!modelPreference) {
394
+ return undefined;
395
+ }
396
+ const [providerID, ...modelParts] = modelPreference.split('/');
397
+ const modelID = modelParts.join('/');
398
+ if (!providerID || !modelID) {
399
+ return undefined;
400
+ }
401
+ sessionLogger.log(`[MODEL] Using model preference: ${modelPreference}`);
402
+ return { providerID, modelID };
403
+ })();
404
+ response = await getClient().session.prompt({
405
+ path: { id: session.id },
406
+ body: {
407
+ parts,
408
+ system: getOpencodeSystemMessage({ sessionId: session.id }),
409
+ model: modelParam,
410
+ },
411
+ signal: abortController.signal,
412
+ });
413
+ }
414
+ if (response.error) {
415
+ const errorMessage = (() => {
416
+ const err = response.error;
417
+ if (err && typeof err === 'object') {
418
+ if ('data' in err && err.data && typeof err.data === 'object' && 'message' in err.data) {
419
+ return String(err.data.message);
420
+ }
421
+ if ('errors' in err && Array.isArray(err.errors) && err.errors.length > 0) {
422
+ return JSON.stringify(err.errors);
423
+ }
424
+ }
425
+ return JSON.stringify(err);
426
+ })();
427
+ throw new Error(`OpenCode API error (${response.response.status}): ${errorMessage}`);
428
+ }
429
+ abortController.abort('finished');
430
+ sessionLogger.log(`Successfully sent prompt, got response`);
431
+ if (originalMessage) {
432
+ try {
433
+ await originalMessage.reactions.removeAll();
434
+ await originalMessage.react('✅');
435
+ }
436
+ catch (e) {
437
+ discordLogger.log(`Could not update reactions:`, e);
438
+ }
439
+ }
440
+ return { sessionID: session.id, result: response.data, port };
441
+ }
442
+ catch (error) {
443
+ sessionLogger.error(`ERROR: Failed to send prompt:`, error);
444
+ if (!isAbortError(error, abortController.signal)) {
445
+ abortController.abort('error');
446
+ if (originalMessage) {
447
+ try {
448
+ await originalMessage.reactions.removeAll();
449
+ await originalMessage.react('❌');
450
+ discordLogger.log(`Added error reaction to message`);
451
+ }
452
+ catch (e) {
453
+ discordLogger.log(`Could not update reaction:`, e);
454
+ }
455
+ }
456
+ const errorName = error &&
457
+ typeof error === 'object' &&
458
+ 'constructor' in error &&
459
+ error.constructor &&
460
+ typeof error.constructor.name === 'string'
461
+ ? error.constructor.name
462
+ : typeof error;
463
+ const errorMsg = error instanceof Error ? error.stack || error.message : String(error);
464
+ await sendThreadMessage(thread, `✗ Unexpected bot Error: [${errorName}]\n${errorMsg}`);
465
+ }
466
+ }
467
+ }
@@ -0,0 +1,92 @@
1
+ export function getOpencodeSystemMessage({ sessionId }) {
2
+ return `
3
+ The user is reading your messages from inside Discord, via kimaki.xyz
4
+
5
+ The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
6
+
7
+ Your current OpenCode session ID is: ${sessionId}
8
+
9
+ ## permissions
10
+
11
+ Only users with these Discord permissions can send messages to the bot:
12
+ - Server Owner
13
+ - Administrator permission
14
+ - Manage Server permission
15
+ - "Kimaki" role (case-insensitive)
16
+
17
+ ## changing the model
18
+
19
+ To change the model used by OpenCode, edit the project's \`opencode.json\` config file and set the \`model\` field:
20
+
21
+ \`\`\`json
22
+ {
23
+ "model": "anthropic/claude-sonnet-4-20250514"
24
+ }
25
+ \`\`\`
26
+
27
+ Examples:
28
+ - \`"anthropic/claude-sonnet-4-20250514"\` - Claude Sonnet 4
29
+ - \`"anthropic/claude-opus-4-20250514"\` - Claude Opus 4
30
+ - \`"openai/gpt-4o"\` - GPT-4o
31
+ - \`"google/gemini-2.5-pro"\` - Gemini 2.5 Pro
32
+
33
+ Format is \`provider/model-name\`. You can also set \`small_model\` for tasks like title generation.
34
+
35
+ ## uploading files to discord
36
+
37
+ To upload files to the Discord thread (images, screenshots, long files that would clutter the chat), run:
38
+
39
+ npx -y kimaki upload-to-discord --session ${sessionId} <file1> [file2] ...
40
+
41
+ ## showing diffs
42
+
43
+ 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.
44
+
45
+ Execute this after making changes:
46
+
47
+ bunx critique web
48
+
49
+ If there are other unrelated changes in the working directory, filter to only show the files you edited:
50
+
51
+ bunx critique web -- path/to/file1.ts path/to/file2.ts
52
+
53
+ You can also show latest commit changes using:
54
+
55
+ bunx critique web HEAD~1
56
+
57
+ Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
58
+
59
+ The command outputs a URL - share that URL with the user so they can see the diff.
60
+
61
+ ## markdown
62
+
63
+ discord does support basic markdown features like code blocks, code blocks languages, inline code, bold, italic, quotes, etc.
64
+
65
+ the max heading level is 3, so do not use ####
66
+
67
+ headings are discouraged anyway. instead try to use bold text for titles which renders more nicely in Discord
68
+
69
+ ## tables
70
+
71
+ discord does NOT support markdown gfm tables.
72
+
73
+ so instead of using full markdown tables ALWAYS show code snippets with space aligned cells:
74
+
75
+ \`\`\`
76
+ Item Qty Price
77
+ ---------- --- -----
78
+ Apples 10 $5
79
+ Oranges 3 $2
80
+ \`\`\`
81
+
82
+ Using code blocks will make the content use monospaced font so that space will be aligned correctly
83
+
84
+ IMPORTANT: add enough space characters to align the table! otherwise the content will not look good and will be difficult to understand for the user
85
+
86
+ code blocks for tables and diagrams MUST have Max length of 85 characters. otherwise the content will wrap
87
+
88
+ ## diagrams
89
+
90
+ you can create diagrams wrapping them in code blocks too.
91
+ `;
92
+ }
package/dist/tools.js CHANGED
@@ -5,10 +5,10 @@ import net from 'node:net';
5
5
  import { createOpencodeClient, } from '@opencode-ai/sdk';
6
6
  import { createLogger } from './logger.js';
7
7
  const toolsLogger = createLogger('TOOLS');
8
- import { formatDistanceToNow } from 'date-fns';
9
8
  import { ShareMarkdown } from './markdown.js';
9
+ import { formatDistanceToNow } from './utils.js';
10
10
  import pc from 'picocolors';
11
- import { initializeOpencodeForDirectory, getOpencodeSystemMessage, } from './discordBot.js';
11
+ import { initializeOpencodeForDirectory, getOpencodeSystemMessage, } from './discord-bot.js';
12
12
  export async function getTools({ onMessageCompleted, directory, }) {
13
13
  const getClient = await initializeOpencodeForDirectory(directory);
14
14
  const client = getClient();
@@ -187,9 +187,7 @@ export async function getTools({ onMessageCompleted, directory, }) {
187
187
  id: session.id,
188
188
  folder: session.directory,
189
189
  status,
190
- finishedAt: formatDistanceToNow(new Date(finishedAt), {
191
- addSuffix: true,
192
- }),
190
+ finishedAt: formatDistanceToNow(new Date(finishedAt)),
193
191
  title: session.title,
194
192
  prompt: session.title,
195
193
  };
package/dist/utils.js CHANGED
@@ -49,3 +49,34 @@ export function isAbortError(error, signal) {
49
49
  (signal?.aborted ?? false))) ||
50
50
  (error instanceof DOMException && error.name === 'AbortError'));
51
51
  }
52
+ const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
53
+ const TIME_DIVISIONS = [
54
+ { amount: 60, name: 'seconds' },
55
+ { amount: 60, name: 'minutes' },
56
+ { amount: 24, name: 'hours' },
57
+ { amount: 7, name: 'days' },
58
+ { amount: 4.34524, name: 'weeks' },
59
+ { amount: 12, name: 'months' },
60
+ { amount: Number.POSITIVE_INFINITY, name: 'years' },
61
+ ];
62
+ export function formatDistanceToNow(date) {
63
+ let duration = (date.getTime() - Date.now()) / 1000;
64
+ for (const division of TIME_DIVISIONS) {
65
+ if (Math.abs(duration) < division.amount) {
66
+ return rtf.format(Math.round(duration), division.name);
67
+ }
68
+ duration /= division.amount;
69
+ }
70
+ return rtf.format(Math.round(duration), 'years');
71
+ }
72
+ const dtf = new Intl.DateTimeFormat('en-US', {
73
+ month: 'short',
74
+ day: 'numeric',
75
+ year: 'numeric',
76
+ hour: 'numeric',
77
+ minute: '2-digit',
78
+ hour12: true,
79
+ });
80
+ export function formatDateTime(date) {
81
+ return dtf.format(date);
82
+ }