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.
Files changed (50) hide show
  1. package/dist/acp-client.test.js +149 -0
  2. package/dist/channel-management.js +11 -9
  3. package/dist/cli.js +59 -7
  4. package/dist/commands/add-project.js +1 -0
  5. package/dist/commands/agent.js +152 -0
  6. package/dist/commands/ask-question.js +183 -0
  7. package/dist/commands/model.js +23 -4
  8. package/dist/commands/session.js +1 -3
  9. package/dist/commands/user-command.js +145 -0
  10. package/dist/database.js +51 -0
  11. package/dist/discord-bot.js +32 -32
  12. package/dist/discord-utils.js +71 -14
  13. package/dist/interaction-handler.js +20 -0
  14. package/dist/logger.js +43 -5
  15. package/dist/markdown.js +104 -0
  16. package/dist/markdown.test.js +31 -1
  17. package/dist/message-formatting.js +72 -22
  18. package/dist/message-formatting.test.js +73 -0
  19. package/dist/opencode.js +70 -16
  20. package/dist/session-handler.js +131 -62
  21. package/dist/system-message.js +4 -51
  22. package/dist/voice-handler.js +18 -8
  23. package/dist/voice.js +28 -12
  24. package/package.json +14 -13
  25. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  26. package/src/__snapshots__/compact-session-context.md +47 -0
  27. package/src/channel-management.ts +20 -8
  28. package/src/cli.ts +74 -8
  29. package/src/commands/add-project.ts +1 -0
  30. package/src/commands/agent.ts +201 -0
  31. package/src/commands/ask-question.ts +276 -0
  32. package/src/commands/fork.ts +1 -2
  33. package/src/commands/model.ts +24 -4
  34. package/src/commands/session.ts +1 -3
  35. package/src/commands/user-command.ts +178 -0
  36. package/src/database.ts +61 -0
  37. package/src/discord-bot.ts +36 -33
  38. package/src/discord-utils.ts +76 -14
  39. package/src/interaction-handler.ts +25 -0
  40. package/src/logger.ts +47 -10
  41. package/src/markdown.test.ts +45 -1
  42. package/src/markdown.ts +132 -0
  43. package/src/message-formatting.test.ts +81 -0
  44. package/src/message-formatting.ts +93 -25
  45. package/src/opencode.ts +80 -21
  46. package/src/session-handler.ts +180 -90
  47. package/src/system-message.ts +4 -51
  48. package/src/voice-handler.ts +20 -9
  49. package/src/voice.ts +32 -13
  50. package/LICENSE +0 -21
@@ -25,9 +25,10 @@ import {
25
25
  registerVoiceStateHandler,
26
26
  } from './voice-handler.js'
27
27
  import {
28
- handleOpencodeSession,
29
- parseSlashCommand,
30
- } from './session-handler.js'
28
+ getCompactSessionContext,
29
+ getLastSessionId,
30
+ } from './markdown.js'
31
+ import { handleOpencodeSession } from './session-handler.js'
31
32
  import { registerInteractionHandler } from './interaction-handler.js'
32
33
 
33
34
  export { getDatabase, closeDatabase } from './database.js'
@@ -240,34 +241,39 @@ export async function startDiscordBot({
240
241
 
241
242
  let messageContent = message.content || ''
242
243
 
243
- let sessionMessagesText: string | undefined
244
- if (projectDirectory && row.session_id) {
244
+ let currentSessionContext: string | undefined
245
+ let lastSessionContext: string | undefined
246
+
247
+ if (projectDirectory) {
245
248
  try {
246
249
  const getClient = await initializeOpencodeForDirectory(projectDirectory)
247
- const messagesResponse = await getClient().session.messages({
248
- path: { id: row.session_id },
250
+ const client = getClient()
251
+
252
+ // get current session context (without system prompt, it would be duplicated)
253
+ if (row.session_id) {
254
+ currentSessionContext = await getCompactSessionContext({
255
+ client,
256
+ sessionId: row.session_id,
257
+ includeSystemPrompt: false,
258
+ maxMessages: 15,
259
+ })
260
+ }
261
+
262
+ // get last session context (with system prompt for project context)
263
+ const lastSessionId = await getLastSessionId({
264
+ client,
265
+ excludeSessionId: row.session_id,
249
266
  })
250
- const messages = messagesResponse.data || []
251
- const recentMessages = messages.slice(-10)
252
- sessionMessagesText = recentMessages
253
- .map((m) => {
254
- const role = m.info.role === 'user' ? 'User' : 'Assistant'
255
- const text = (() => {
256
- if (m.info.role === 'user') {
257
- const textParts = (m.parts || []).filter((p) => p.type === 'text')
258
- return textParts
259
- .map((p) => ('text' in p ? p.text : ''))
260
- .filter(Boolean)
261
- .join('\n')
262
- }
263
- const assistantInfo = m.info as { text?: string }
264
- return assistantInfo.text?.slice(0, 500)
265
- })()
266
- return `[${role}]: ${text || '(no text)'}`
267
+ if (lastSessionId) {
268
+ lastSessionContext = await getCompactSessionContext({
269
+ client,
270
+ sessionId: lastSessionId,
271
+ includeSystemPrompt: true,
272
+ maxMessages: 10,
267
273
  })
268
- .join('\n\n')
274
+ }
269
275
  } catch (e) {
270
- voiceLogger.log(`Could not get session messages:`, e)
276
+ voiceLogger.error(`Could not get session context:`, e)
271
277
  }
272
278
  }
273
279
 
@@ -276,25 +282,24 @@ export async function startDiscordBot({
276
282
  thread,
277
283
  projectDirectory,
278
284
  appId: currentAppId,
279
- sessionMessages: sessionMessagesText,
285
+ currentSessionContext,
286
+ lastSessionContext,
280
287
  })
281
288
  if (transcription) {
282
289
  messageContent = transcription
283
290
  }
284
291
 
285
- const fileAttachments = getFileAttachments(message)
292
+ const fileAttachments = await getFileAttachments(message)
286
293
  const textAttachmentsContent = await getTextAttachments(message)
287
294
  const promptWithAttachments = textAttachmentsContent
288
295
  ? `${messageContent}\n\n${textAttachmentsContent}`
289
296
  : messageContent
290
- const parsedCommand = parseSlashCommand(messageContent)
291
297
  await handleOpencodeSession({
292
298
  prompt: promptWithAttachments,
293
299
  thread,
294
300
  projectDirectory,
295
301
  originalMessage: message,
296
302
  images: fileAttachments,
297
- parsedCommand,
298
303
  channelId: parent?.id,
299
304
  })
300
305
  return
@@ -380,19 +385,17 @@ export async function startDiscordBot({
380
385
  messageContent = transcription
381
386
  }
382
387
 
383
- const fileAttachments = getFileAttachments(message)
388
+ const fileAttachments = await getFileAttachments(message)
384
389
  const textAttachmentsContent = await getTextAttachments(message)
385
390
  const promptWithAttachments = textAttachmentsContent
386
391
  ? `${messageContent}\n\n${textAttachmentsContent}`
387
392
  : messageContent
388
- const parsedCommand = parseSlashCommand(messageContent)
389
393
  await handleOpencodeSession({
390
394
  prompt: promptWithAttachments,
391
395
  thread,
392
396
  projectDirectory,
393
397
  originalMessage: message,
394
398
  images: fileAttachments,
395
- parsedCommand,
396
399
  channelId: textChannel.id,
397
400
  })
398
401
  } else {
@@ -85,31 +85,93 @@ export function splitMarkdownForDiscord({
85
85
  let currentChunk = ''
86
86
  let currentLang: string | null = null
87
87
 
88
+ // helper to split a long line into smaller pieces at word boundaries or hard breaks
89
+ const splitLongLine = (text: string, available: number, inCode: boolean): string[] => {
90
+ const pieces: string[] = []
91
+ let remaining = text
92
+
93
+ while (remaining.length > available) {
94
+ let splitAt = available
95
+ // for non-code, try to split at word boundary
96
+ if (!inCode) {
97
+ const lastSpace = remaining.lastIndexOf(' ', available)
98
+ if (lastSpace > available * 0.5) {
99
+ splitAt = lastSpace + 1
100
+ }
101
+ }
102
+ pieces.push(remaining.slice(0, splitAt))
103
+ remaining = remaining.slice(splitAt)
104
+ }
105
+ if (remaining) {
106
+ pieces.push(remaining)
107
+ }
108
+ return pieces
109
+ }
110
+
88
111
  for (const line of lines) {
89
112
  const wouldExceed = currentChunk.length + line.text.length > maxLength
90
113
 
91
- if (wouldExceed && currentChunk) {
92
- if (currentLang !== null) {
93
- currentChunk += '```\n'
94
- }
95
- chunks.push(currentChunk)
114
+ if (wouldExceed) {
115
+ // handle case where single line is longer than maxLength
116
+ if (line.text.length > maxLength) {
117
+ // first, flush current chunk if any
118
+ if (currentChunk) {
119
+ if (currentLang !== null) {
120
+ currentChunk += '```\n'
121
+ }
122
+ chunks.push(currentChunk)
123
+ currentChunk = ''
124
+ }
125
+
126
+ // calculate overhead for code block markers
127
+ const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0
128
+ const availablePerChunk = maxLength - codeBlockOverhead - 50 // safety margin
129
+
130
+ const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock)
131
+
132
+ for (let i = 0; i < pieces.length; i++) {
133
+ const piece = pieces[i]!
134
+ if (line.inCodeBlock) {
135
+ chunks.push('```' + line.lang + '\n' + piece + '```\n')
136
+ } else {
137
+ chunks.push(piece)
138
+ }
139
+ }
96
140
 
97
- if (line.isClosingFence && currentLang !== null) {
98
- currentChunk = ''
99
141
  currentLang = null
100
142
  continue
101
143
  }
102
144
 
103
- if (line.inCodeBlock || line.isOpeningFence) {
104
- const lang = line.lang
105
- currentChunk = '```' + lang + '\n'
106
- if (!line.isOpeningFence) {
107
- currentChunk += line.text
145
+ // normal case: line fits in a chunk but current chunk would overflow
146
+ if (currentChunk) {
147
+ if (currentLang !== null) {
148
+ currentChunk += '```\n'
149
+ }
150
+ chunks.push(currentChunk)
151
+
152
+ if (line.isClosingFence && currentLang !== null) {
153
+ currentChunk = ''
154
+ currentLang = null
155
+ continue
156
+ }
157
+
158
+ if (line.inCodeBlock || line.isOpeningFence) {
159
+ const lang = line.lang
160
+ currentChunk = '```' + lang + '\n'
161
+ if (!line.isOpeningFence) {
162
+ currentChunk += line.text
163
+ }
164
+ currentLang = lang
165
+ } else {
166
+ currentChunk = line.text
167
+ currentLang = null
108
168
  }
109
- currentLang = lang
110
169
  } else {
170
+ // currentChunk is empty but line still exceeds - shouldn't happen after above check
111
171
  currentChunk = line.text
112
- currentLang = null
172
+ if (line.inCodeBlock || line.isOpeningFence) {
173
+ currentLang = line.lang
174
+ }
113
175
  }
114
176
  } else {
115
177
  currentChunk += line.text
@@ -12,12 +12,16 @@ import { handleAbortCommand } from './commands/abort.js'
12
12
  import { handleShareCommand } from './commands/share.js'
13
13
  import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js'
14
14
  import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu } from './commands/model.js'
15
+ import { handleAgentCommand, handleAgentSelectMenu } from './commands/agent.js'
16
+ import { handleAskQuestionSelectMenu } from './commands/ask-question.js'
15
17
  import { handleQueueCommand, handleClearQueueCommand } from './commands/queue.js'
16
18
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js'
19
+ import { handleUserCommand } from './commands/user-command.js'
17
20
  import { createLogger } from './logger.js'
18
21
 
19
22
  const interactionLogger = createLogger('INTERACTION')
20
23
 
24
+
21
25
  export function registerInteractionHandler({
22
26
  discordClient,
23
27
  appId,
@@ -91,6 +95,7 @@ export function registerInteractionHandler({
91
95
  return
92
96
 
93
97
  case 'abort':
98
+ case 'stop':
94
99
  await handleAbortCommand({ command: interaction, appId })
95
100
  return
96
101
 
@@ -106,6 +111,10 @@ export function registerInteractionHandler({
106
111
  await handleModelCommand({ interaction, appId })
107
112
  return
108
113
 
114
+ case 'agent':
115
+ await handleAgentCommand({ interaction, appId })
116
+ return
117
+
109
118
  case 'queue':
110
119
  await handleQueueCommand({ command: interaction, appId })
111
120
  return
@@ -122,6 +131,12 @@ export function registerInteractionHandler({
122
131
  await handleRedoCommand({ command: interaction, appId })
123
132
  return
124
133
  }
134
+
135
+ // Handle user-defined commands (ending with -cmd suffix)
136
+ if (interaction.commandName.endsWith('-cmd')) {
137
+ await handleUserCommand({ command: interaction, appId })
138
+ return
139
+ }
125
140
  return
126
141
  }
127
142
 
@@ -142,6 +157,16 @@ export function registerInteractionHandler({
142
157
  await handleModelSelectMenu(interaction)
143
158
  return
144
159
  }
160
+
161
+ if (customId.startsWith('agent_select:')) {
162
+ await handleAgentSelectMenu(interaction)
163
+ return
164
+ }
165
+
166
+ if (customId.startsWith('ask_question:')) {
167
+ await handleAskQuestionSelectMenu(interaction)
168
+ return
169
+ }
145
170
  return
146
171
  }
147
172
  } catch (error) {
package/src/logger.ts CHANGED
@@ -3,18 +3,55 @@
3
3
  // (DISCORD, VOICE, SESSION, etc.) for easier debugging.
4
4
 
5
5
  import { log } from '@clack/prompts'
6
+ import fs from 'node:fs'
7
+ import path, { dirname } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ const __filename = fileURLToPath(import.meta.url)
11
+ const __dirname = dirname(__filename)
12
+ const isDev = !__dirname.includes('node_modules')
13
+
14
+ const logFilePath = path.join(__dirname, '..', 'tmp', 'kimaki.log')
15
+
16
+ // reset log file on startup in dev mode
17
+ if (isDev) {
18
+ const logDir = path.dirname(logFilePath)
19
+ if (!fs.existsSync(logDir)) {
20
+ fs.mkdirSync(logDir, { recursive: true })
21
+ }
22
+ fs.writeFileSync(logFilePath, `--- kimaki log started at ${new Date().toISOString()} ---\n`)
23
+ }
24
+
25
+ function writeToFile(level: string, prefix: string, args: any[]) {
26
+ if (!isDev) {
27
+ return
28
+ }
29
+ const timestamp = new Date().toISOString()
30
+ const message = `[${timestamp}] [${level}] [${prefix}] ${args.map((arg) => String(arg)).join(' ')}\n`
31
+ fs.appendFileSync(logFilePath, message)
32
+ }
6
33
 
7
34
  export function createLogger(prefix: string) {
8
35
  return {
9
- log: (...args: any[]) =>
10
- log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
11
- error: (...args: any[]) =>
12
- log.error([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
13
- warn: (...args: any[]) =>
14
- log.warn([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
15
- info: (...args: any[]) =>
16
- log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
17
- debug: (...args: any[]) =>
18
- log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' ')),
36
+ log: (...args: any[]) => {
37
+ writeToFile('INFO', prefix, args)
38
+ log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
39
+ },
40
+ error: (...args: any[]) => {
41
+ writeToFile('ERROR', prefix, args)
42
+ log.error([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
43
+ },
44
+ warn: (...args: any[]) => {
45
+ writeToFile('WARN', prefix, args)
46
+ log.warn([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
47
+ },
48
+ info: (...args: any[]) => {
49
+ writeToFile('INFO', prefix, args)
50
+ log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
51
+ },
52
+ debug: (...args: any[]) => {
53
+ writeToFile('DEBUG', prefix, args)
54
+ log.info([`[${prefix}]`, ...args.map((arg) => String(arg))].join(' '))
55
+ },
19
56
  }
20
57
  }
@@ -1,7 +1,7 @@
1
1
  import { test, expect, beforeAll, afterAll } from 'vitest'
2
2
  import { spawn, type ChildProcess } 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
 
6
6
  let serverProcess: ChildProcess
7
7
  let client: OpencodeClient
@@ -312,3 +312,47 @@ test('generate markdown from multiple sessions', async () => {
312
312
  }
313
313
  }
314
314
  })
315
+
316
+ // test for getCompactSessionContext - disabled in CI since it requires a specific session
317
+ test.skipIf(process.env.CI)('getCompactSessionContext generates compact format', async () => {
318
+ const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM'
319
+
320
+ const context = await getCompactSessionContext({
321
+ client,
322
+ sessionId,
323
+ includeSystemPrompt: true,
324
+ maxMessages: 15,
325
+ })
326
+
327
+ console.log(`Generated compact context length: ${context.length} characters`)
328
+
329
+ expect(context).toBeTruthy()
330
+ expect(context.length).toBeGreaterThan(0)
331
+ // should have tool calls or messages
332
+ expect(context).toMatch(/\[Tool \w+\]:|\[User\]:|\[Assistant\]:/)
333
+
334
+ await expect(context).toMatchFileSnapshot(
335
+ './__snapshots__/compact-session-context.md',
336
+ )
337
+ })
338
+
339
+ test.skipIf(process.env.CI)('getCompactSessionContext without system prompt', async () => {
340
+ const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM'
341
+
342
+ const context = await getCompactSessionContext({
343
+ client,
344
+ sessionId,
345
+ includeSystemPrompt: false,
346
+ maxMessages: 10,
347
+ })
348
+
349
+ console.log(`Generated compact context (no system) length: ${context.length} characters`)
350
+
351
+ expect(context).toBeTruthy()
352
+ // should NOT have system prompt
353
+ expect(context).not.toContain('[System Prompt]')
354
+
355
+ await expect(context).toMatchFileSnapshot(
356
+ './__snapshots__/compact-session-context-no-system.md',
357
+ )
358
+ })
package/src/markdown.ts CHANGED
@@ -6,6 +6,9 @@ import type { OpencodeClient } from '@opencode-ai/sdk'
6
6
  import * as yaml from 'js-yaml'
7
7
  import { formatDateTime } from './utils.js'
8
8
  import { extractNonXmlContent } from './xml.js'
9
+ import { createLogger } from './logger.js'
10
+
11
+ const markdownLogger = createLogger('MARKDOWN')
9
12
 
10
13
  export class ShareMarkdown {
11
14
  constructor(private client: OpencodeClient) {}
@@ -231,3 +234,132 @@ export class ShareMarkdown {
231
234
  return `${minutes}m ${seconds}s`
232
235
  }
233
236
  }
237
+
238
+ /**
239
+ * Generate compact session context for voice transcription.
240
+ * Includes system prompt (optional), user messages, assistant text,
241
+ * and tool calls in compact form (name + params only, no output).
242
+ */
243
+ export async function getCompactSessionContext({
244
+ client,
245
+ sessionId,
246
+ includeSystemPrompt = false,
247
+ maxMessages = 20,
248
+ }: {
249
+ client: OpencodeClient
250
+ sessionId: string
251
+ includeSystemPrompt?: boolean
252
+ maxMessages?: number
253
+ }): Promise<string> {
254
+ try {
255
+ const messagesResponse = await client.session.messages({
256
+ path: { id: sessionId },
257
+ })
258
+ const messages = messagesResponse.data || []
259
+
260
+ const lines: string[] = []
261
+
262
+ // Get system prompt if requested
263
+ // Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
264
+ // 1. session.system field (if available in future SDK versions)
265
+ // 2. synthetic text part in first assistant message (current approach)
266
+ if (includeSystemPrompt && messages.length > 0) {
267
+ const firstAssistant = messages.find((m) => m.info.role === 'assistant')
268
+ if (firstAssistant) {
269
+ // look for text part marked as synthetic (system prompt)
270
+ const systemPart = (firstAssistant.parts || []).find(
271
+ (p) => p.type === 'text' && (p as any).synthetic === true,
272
+ )
273
+ if (systemPart && 'text' in systemPart && systemPart.text) {
274
+ lines.push('[System Prompt]')
275
+ const truncated = systemPart.text.slice(0, 3000)
276
+ lines.push(truncated)
277
+ if (systemPart.text.length > 3000) {
278
+ lines.push('...(truncated)')
279
+ }
280
+ lines.push('')
281
+ }
282
+ }
283
+ }
284
+
285
+ // Process recent messages
286
+ const recentMessages = messages.slice(-maxMessages)
287
+
288
+ for (const msg of recentMessages) {
289
+ if (msg.info.role === 'user') {
290
+ const textParts = (msg.parts || [])
291
+ .filter((p) => p.type === 'text' && 'text' in p)
292
+ .map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
293
+ .filter(Boolean)
294
+ if (textParts.length > 0) {
295
+ lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`)
296
+ lines.push('')
297
+ }
298
+ } else if (msg.info.role === 'assistant') {
299
+ // Get assistant text parts (non-synthetic, non-empty)
300
+ const textParts = (msg.parts || [])
301
+ .filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
302
+ .map((p) => ('text' in p ? p.text : ''))
303
+ .filter(Boolean)
304
+ if (textParts.length > 0) {
305
+ lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`)
306
+ lines.push('')
307
+ }
308
+
309
+ // Get tool calls in compact form (name + params only)
310
+ const toolParts = (msg.parts || []).filter(
311
+ (p) =>
312
+ p.type === 'tool' &&
313
+ 'state' in p &&
314
+ p.state?.status === 'completed',
315
+ )
316
+ for (const part of toolParts) {
317
+ if (part.type === 'tool' && 'tool' in part && 'state' in part) {
318
+ const toolName = part.tool
319
+ // skip noisy tools
320
+ if (toolName === 'todoread' || toolName === 'todowrite') {
321
+ continue
322
+ }
323
+ const input = part.state?.input || {}
324
+ // compact params: just key=value on one line
325
+ const params = Object.entries(input)
326
+ .map(([k, v]) => {
327
+ const val = typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100)
328
+ return `${k}=${val}`
329
+ })
330
+ .join(', ')
331
+ lines.push(`[Tool ${toolName}]: ${params}`)
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ return lines.join('\n').slice(0, 8000)
338
+ } catch (e) {
339
+ markdownLogger.error('Failed to get compact session context:', e)
340
+ return ''
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Get the last session for a directory (excluding the current one).
346
+ */
347
+ export async function getLastSessionId({
348
+ client,
349
+ excludeSessionId,
350
+ }: {
351
+ client: OpencodeClient
352
+ excludeSessionId?: string
353
+ }): Promise<string | null> {
354
+ try {
355
+ const sessionsResponse = await client.session.list()
356
+ const sessions = sessionsResponse.data || []
357
+
358
+ // Sessions are sorted by time, get the most recent one that isn't the current
359
+ const lastSession = sessions.find((s) => s.id !== excludeSessionId)
360
+ return lastSession?.id || null
361
+ } catch (e) {
362
+ markdownLogger.error('Failed to get last session:', e)
363
+ return null
364
+ }
365
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, test, expect } from 'vitest'
2
+ import { formatTodoList } from './message-formatting.js'
3
+ import type { Part } from '@opencode-ai/sdk'
4
+
5
+ describe('formatTodoList', () => {
6
+ test('formats active todo with monospace numbers', () => {
7
+ const part: Part = {
8
+ id: 'test',
9
+ type: 'tool',
10
+ tool: 'todowrite',
11
+ sessionID: 'ses_test',
12
+ messageID: 'msg_test',
13
+ callID: 'call_test',
14
+ state: {
15
+ status: 'completed',
16
+ input: {
17
+ todos: [
18
+ { content: 'First task', status: 'completed' },
19
+ { content: 'Second task', status: 'in_progress' },
20
+ { content: 'Third task', status: 'pending' },
21
+ ],
22
+ },
23
+ output: '',
24
+ title: 'todowrite',
25
+ metadata: {},
26
+ time: { start: 0, end: 0 },
27
+ },
28
+ }
29
+
30
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"⑵ **second task**"`)
31
+ })
32
+
33
+ test('formats double digit todo numbers', () => {
34
+ const todos = Array.from({ length: 12 }, (_, i) => ({
35
+ content: `Task ${i + 1}`,
36
+ status: i === 11 ? 'in_progress' : 'completed',
37
+ }))
38
+
39
+ const part: Part = {
40
+ id: 'test',
41
+ type: 'tool',
42
+ tool: 'todowrite',
43
+ sessionID: 'ses_test',
44
+ messageID: 'msg_test',
45
+ callID: 'call_test',
46
+ state: {
47
+ status: 'completed',
48
+ input: { todos },
49
+ output: '',
50
+ title: 'todowrite',
51
+ metadata: {},
52
+ time: { start: 0, end: 0 },
53
+ },
54
+ }
55
+
56
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"⑿ **task 12**"`)
57
+ })
58
+
59
+ test('lowercases first letter of content', () => {
60
+ const part: Part = {
61
+ id: 'test',
62
+ type: 'tool',
63
+ tool: 'todowrite',
64
+ sessionID: 'ses_test',
65
+ messageID: 'msg_test',
66
+ callID: 'call_test',
67
+ state: {
68
+ status: 'completed',
69
+ input: {
70
+ todos: [{ content: 'Fix the bug', status: 'in_progress' }],
71
+ },
72
+ output: '',
73
+ title: 'todowrite',
74
+ metadata: {},
75
+ time: { start: 0, end: 0 },
76
+ },
77
+ }
78
+
79
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"⑴ **fix the bug**"`)
80
+ })
81
+ })