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/src/discord-bot.ts
CHANGED
|
@@ -25,9 +25,10 @@ import {
|
|
|
25
25
|
registerVoiceStateHandler,
|
|
26
26
|
} from './voice-handler.js'
|
|
27
27
|
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
} from './
|
|
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
|
|
244
|
-
|
|
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
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
274
|
+
}
|
|
269
275
|
} catch (e) {
|
|
270
|
-
voiceLogger.
|
|
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
|
-
|
|
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 {
|
package/src/discord-utils.ts
CHANGED
|
@@ -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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
}
|
package/src/markdown.test.ts
CHANGED
|
@@ -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
|
+
})
|