kimaki 0.4.24 → 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/bin.js +6 -1
- package/dist/acp-client.test.js +149 -0
- package/dist/ai-tool-to-genai.js +3 -0
- package/dist/channel-management.js +14 -9
- package/dist/cli.js +148 -17
- package/dist/commands/abort.js +78 -0
- package/dist/commands/add-project.js +98 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/create-new-project.js +78 -0
- package/dist/commands/fork.js +186 -0
- package/dist/commands/model.js +313 -0
- package/dist/commands/permissions.js +126 -0
- package/dist/commands/queue.js +129 -0
- package/dist/commands/resume.js +145 -0
- package/dist/commands/session.js +142 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +161 -0
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +54 -0
- package/dist/discord-bot.js +35 -32
- package/dist/discord-utils.js +81 -15
- package/dist/format-tables.js +3 -0
- package/dist/genai-worker-wrapper.js +3 -0
- package/dist/genai-worker.js +3 -0
- package/dist/genai.js +3 -0
- package/dist/interaction-handler.js +89 -695
- package/dist/logger.js +46 -5
- package/dist/markdown.js +107 -0
- package/dist/markdown.test.js +31 -1
- package/dist/message-formatting.js +113 -28
- package/dist/message-formatting.test.js +73 -0
- package/dist/opencode.js +73 -16
- package/dist/session-handler.js +176 -63
- package/dist/system-message.js +7 -38
- package/dist/tools.js +3 -0
- package/dist/utils.js +3 -0
- package/dist/voice-handler.js +21 -8
- package/dist/voice.js +31 -12
- package/dist/worker-types.js +3 -0
- package/dist/xml.js +3 -0
- package/package.json +3 -3
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/ai-tool-to-genai.ts +4 -0
- package/src/channel-management.ts +24 -8
- package/src/cli.ts +163 -18
- package/src/commands/abort.ts +94 -0
- package/src/commands/add-project.ts +139 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/create-new-project.ts +111 -0
- package/src/{fork.ts → commands/fork.ts} +40 -7
- package/src/{model-command.ts → commands/model.ts} +31 -9
- package/src/commands/permissions.ts +146 -0
- package/src/commands/queue.ts +181 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/session.ts +184 -0
- package/src/commands/share.ts +96 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +213 -0
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +65 -0
- package/src/discord-bot.ts +40 -33
- package/src/discord-utils.ts +88 -14
- package/src/format-tables.ts +4 -0
- package/src/genai-worker-wrapper.ts +4 -0
- package/src/genai-worker.ts +4 -0
- package/src/genai.ts +4 -0
- package/src/interaction-handler.ts +111 -924
- package/src/logger.ts +51 -10
- package/src/markdown.test.ts +45 -1
- package/src/markdown.ts +136 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +143 -30
- package/src/opencode.ts +84 -21
- package/src/session-handler.ts +248 -91
- package/src/system-message.ts +8 -38
- package/src/tools.ts +4 -0
- package/src/utils.ts +4 -0
- package/src/voice-handler.ts +24 -9
- package/src/voice.ts +36 -13
- package/src/worker-types.ts +4 -0
- package/src/xml.ts +4 -0
- package/README.md +0 -48
package/src/logger.ts
CHANGED
|
@@ -1,16 +1,57 @@
|
|
|
1
|
+
// Prefixed logging utility using @clack/prompts.
|
|
2
|
+
// Creates loggers with consistent prefixes for different subsystems
|
|
3
|
+
// (DISCORD, VOICE, SESSION, etc.) for easier debugging.
|
|
4
|
+
|
|
1
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
|
+
}
|
|
2
33
|
|
|
3
34
|
export function createLogger(prefix: string) {
|
|
4
35
|
return {
|
|
5
|
-
log: (...args: any[]) =>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
},
|
|
15
56
|
}
|
|
16
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
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
// Session-to-markdown renderer for sharing.
|
|
2
|
+
// Generates shareable markdown from OpenCode sessions, formatting
|
|
3
|
+
// user messages, assistant responses, tool calls, and reasoning blocks.
|
|
4
|
+
|
|
1
5
|
import type { OpencodeClient } from '@opencode-ai/sdk'
|
|
2
6
|
import * as yaml from 'js-yaml'
|
|
3
7
|
import { formatDateTime } from './utils.js'
|
|
4
8
|
import { extractNonXmlContent } from './xml.js'
|
|
9
|
+
import { createLogger } from './logger.js'
|
|
10
|
+
|
|
11
|
+
const markdownLogger = createLogger('MARKDOWN')
|
|
5
12
|
|
|
6
13
|
export class ShareMarkdown {
|
|
7
14
|
constructor(private client: OpencodeClient) {}
|
|
@@ -227,3 +234,132 @@ export class ShareMarkdown {
|
|
|
227
234
|
return `${minutes}m ${seconds}s`
|
|
228
235
|
}
|
|
229
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
|
+
})
|
|
@@ -1,9 +1,64 @@
|
|
|
1
|
-
|
|
1
|
+
// OpenCode message part formatting for Discord.
|
|
2
|
+
// Converts SDK message parts (text, tools, reasoning) to Discord-friendly format,
|
|
3
|
+
// handles file attachments, and provides tool summary generation.
|
|
4
|
+
|
|
5
|
+
import type { Part } from '@opencode-ai/sdk/v2'
|
|
6
|
+
import type { FilePartInput } from '@opencode-ai/sdk'
|
|
2
7
|
import type { Message } from 'discord.js'
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
3
10
|
import { createLogger } from './logger.js'
|
|
4
11
|
|
|
12
|
+
// Generic message type compatible with both v1 and v2 SDK
|
|
13
|
+
type GenericSessionMessage = {
|
|
14
|
+
info: { role: string; id?: string }
|
|
15
|
+
parts: Part[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments')
|
|
19
|
+
|
|
5
20
|
const logger = createLogger('FORMATTING')
|
|
6
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Escapes Discord inline markdown characters so dynamic content
|
|
24
|
+
* doesn't break formatting when wrapped in *, _, **, etc.
|
|
25
|
+
*/
|
|
26
|
+
function escapeInlineMarkdown(text: string): string {
|
|
27
|
+
return text.replace(/([*_~|`\\])/g, '\\$1')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Collects and formats the last N assistant parts from session messages.
|
|
32
|
+
* Used by both /resume and /fork to show recent assistant context.
|
|
33
|
+
*/
|
|
34
|
+
export function collectLastAssistantParts({
|
|
35
|
+
messages,
|
|
36
|
+
limit = 30,
|
|
37
|
+
}: {
|
|
38
|
+
messages: GenericSessionMessage[]
|
|
39
|
+
limit?: number
|
|
40
|
+
}): { partIds: string[]; content: string; skippedCount: number } {
|
|
41
|
+
const allAssistantParts: { id: string; content: string }[] = []
|
|
42
|
+
|
|
43
|
+
for (const message of messages) {
|
|
44
|
+
if (message.info.role === 'assistant') {
|
|
45
|
+
for (const part of message.parts) {
|
|
46
|
+
const content = formatPart(part)
|
|
47
|
+
if (content.trim()) {
|
|
48
|
+
allAssistantParts.push({ id: part.id, content: content.trimEnd() })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const partsToRender = allAssistantParts.slice(-limit)
|
|
55
|
+
const partIds = partsToRender.map((p) => p.id)
|
|
56
|
+
const content = partsToRender.map((p) => p.content).join('\n')
|
|
57
|
+
const skippedCount = allAssistantParts.length - partsToRender.length
|
|
58
|
+
|
|
59
|
+
return { partIds, content, skippedCount }
|
|
60
|
+
}
|
|
61
|
+
|
|
7
62
|
export const TEXT_MIME_TYPES = [
|
|
8
63
|
'text/',
|
|
9
64
|
'application/json',
|
|
@@ -49,7 +104,7 @@ export async function getTextAttachments(message: Message): Promise<string> {
|
|
|
49
104
|
return textContents.join('\n\n')
|
|
50
105
|
}
|
|
51
106
|
|
|
52
|
-
export function getFileAttachments(message: Message): FilePartInput[] {
|
|
107
|
+
export async function getFileAttachments(message: Message): Promise<FilePartInput[]> {
|
|
53
108
|
const fileAttachments = Array.from(message.attachments.values()).filter(
|
|
54
109
|
(attachment) => {
|
|
55
110
|
const contentType = attachment.contentType || ''
|
|
@@ -59,12 +114,44 @@ export function getFileAttachments(message: Message): FilePartInput[] {
|
|
|
59
114
|
},
|
|
60
115
|
)
|
|
61
116
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
117
|
+
if (fileAttachments.length === 0) {
|
|
118
|
+
return []
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ensure tmp directory exists
|
|
122
|
+
if (!fs.existsSync(ATTACHMENTS_DIR)) {
|
|
123
|
+
fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const results = await Promise.all(
|
|
127
|
+
fileAttachments.map(async (attachment) => {
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetch(attachment.url)
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
logger.error(`Failed to fetch attachment ${attachment.name}: ${response.status}`)
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
136
|
+
const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`)
|
|
137
|
+
fs.writeFileSync(localPath, buffer)
|
|
138
|
+
|
|
139
|
+
logger.log(`Downloaded attachment to ${localPath}`)
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
type: 'file' as const,
|
|
143
|
+
mime: attachment.contentType || 'application/octet-stream',
|
|
144
|
+
filename: attachment.name,
|
|
145
|
+
url: localPath,
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
logger.error(`Error downloading attachment ${attachment.name}:`, error)
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
}),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return results.filter((r) => r !== null) as FilePartInput[]
|
|
68
155
|
}
|
|
69
156
|
|
|
70
157
|
export function getToolSummaryText(part: Part): string {
|
|
@@ -77,7 +164,7 @@ export function getToolSummaryText(part: Part): string {
|
|
|
77
164
|
const added = newString.split('\n').length
|
|
78
165
|
const removed = oldString.split('\n').length
|
|
79
166
|
const fileName = filePath.split('/').pop() || ''
|
|
80
|
-
return fileName ? `*${fileName}* (+${added}-${removed})` : `(+${added}-${removed})`
|
|
167
|
+
return fileName ? `*${escapeInlineMarkdown(fileName)}* (+${added}-${removed})` : `(+${added}-${removed})`
|
|
81
168
|
}
|
|
82
169
|
|
|
83
170
|
if (part.tool === 'write') {
|
|
@@ -85,35 +172,35 @@ export function getToolSummaryText(part: Part): string {
|
|
|
85
172
|
const content = (part.state.input?.content as string) || ''
|
|
86
173
|
const lines = content.split('\n').length
|
|
87
174
|
const fileName = filePath.split('/').pop() || ''
|
|
88
|
-
return fileName ? `*${fileName}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
|
|
175
|
+
return fileName ? `*${escapeInlineMarkdown(fileName)}* (${lines} line${lines === 1 ? '' : 's'})` : `(${lines} line${lines === 1 ? '' : 's'})`
|
|
89
176
|
}
|
|
90
177
|
|
|
91
178
|
if (part.tool === 'webfetch') {
|
|
92
179
|
const url = (part.state.input?.url as string) || ''
|
|
93
180
|
const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
|
|
94
|
-
return urlWithoutProtocol ? `*${urlWithoutProtocol}*` : ''
|
|
181
|
+
return urlWithoutProtocol ? `*${escapeInlineMarkdown(urlWithoutProtocol)}*` : ''
|
|
95
182
|
}
|
|
96
183
|
|
|
97
184
|
if (part.tool === 'read') {
|
|
98
185
|
const filePath = (part.state.input?.filePath as string) || ''
|
|
99
186
|
const fileName = filePath.split('/').pop() || ''
|
|
100
|
-
return fileName ? `*${fileName}*` : ''
|
|
187
|
+
return fileName ? `*${escapeInlineMarkdown(fileName)}*` : ''
|
|
101
188
|
}
|
|
102
189
|
|
|
103
190
|
if (part.tool === 'list') {
|
|
104
191
|
const path = (part.state.input?.path as string) || ''
|
|
105
192
|
const dirName = path.split('/').pop() || path
|
|
106
|
-
return dirName ? `*${dirName}*` : ''
|
|
193
|
+
return dirName ? `*${escapeInlineMarkdown(dirName)}*` : ''
|
|
107
194
|
}
|
|
108
195
|
|
|
109
196
|
if (part.tool === 'glob') {
|
|
110
197
|
const pattern = (part.state.input?.pattern as string) || ''
|
|
111
|
-
return pattern ? `*${pattern}*` : ''
|
|
198
|
+
return pattern ? `*${escapeInlineMarkdown(pattern)}*` : ''
|
|
112
199
|
}
|
|
113
200
|
|
|
114
201
|
if (part.tool === 'grep') {
|
|
115
202
|
const pattern = (part.state.input?.pattern as string) || ''
|
|
116
|
-
return pattern ? `*${pattern}*` : ''
|
|
203
|
+
return pattern ? `*${escapeInlineMarkdown(pattern)}*` : ''
|
|
117
204
|
}
|
|
118
205
|
|
|
119
206
|
if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
|
|
@@ -122,12 +209,12 @@ export function getToolSummaryText(part: Part): string {
|
|
|
122
209
|
|
|
123
210
|
if (part.tool === 'task') {
|
|
124
211
|
const description = (part.state.input?.description as string) || ''
|
|
125
|
-
return description ? `_${description}_` : ''
|
|
212
|
+
return description ? `_${escapeInlineMarkdown(description)}_` : ''
|
|
126
213
|
}
|
|
127
214
|
|
|
128
215
|
if (part.tool === 'skill') {
|
|
129
216
|
const name = (part.state.input?.name as string) || ''
|
|
130
|
-
return name ? `_${name}_` : ''
|
|
217
|
+
return name ? `_${escapeInlineMarkdown(name)}_` : ''
|
|
131
218
|
}
|
|
132
219
|
|
|
133
220
|
if (!part.state.input) return ''
|
|
@@ -136,7 +223,7 @@ export function getToolSummaryText(part: Part): string {
|
|
|
136
223
|
.map(([key, value]) => {
|
|
137
224
|
if (value === null || value === undefined) return null
|
|
138
225
|
const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
|
|
139
|
-
const truncatedValue = stringValue.length >
|
|
226
|
+
const truncatedValue = stringValue.length > 50 ? stringValue.slice(0, 50) + '…' : stringValue
|
|
140
227
|
return `${key}: ${truncatedValue}`
|
|
141
228
|
})
|
|
142
229
|
.filter(Boolean)
|
|
@@ -158,17 +245,31 @@ export function formatTodoList(part: Part): string {
|
|
|
158
245
|
})
|
|
159
246
|
const activeTodo = todos[activeIndex]
|
|
160
247
|
if (activeIndex === -1 || !activeTodo) return ''
|
|
161
|
-
|
|
248
|
+
// parenthesized digits ⑴-⒇ for 1-20, fallback to regular number for 21+
|
|
249
|
+
const parenthesizedDigits = '⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽⑾⑿⒀⒁⒂⒃⒄⒅⒆⒇'
|
|
250
|
+
const todoNumber = activeIndex + 1
|
|
251
|
+
const num = todoNumber <= 20 ? parenthesizedDigits[todoNumber - 1] : `(${todoNumber})`
|
|
252
|
+
const content = activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1)
|
|
253
|
+
return `${num} **${escapeInlineMarkdown(content)}**`
|
|
162
254
|
}
|
|
163
255
|
|
|
164
256
|
export function formatPart(part: Part): string {
|
|
165
257
|
if (part.type === 'text') {
|
|
166
|
-
|
|
258
|
+
if (!part.text?.trim()) return ''
|
|
259
|
+
const trimmed = part.text.trimStart()
|
|
260
|
+
const firstChar = trimmed[0] || ''
|
|
261
|
+
const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|']
|
|
262
|
+
const startsWithMarkdown =
|
|
263
|
+
markdownStarters.includes(firstChar) || /^\d+\./.test(trimmed)
|
|
264
|
+
if (startsWithMarkdown) {
|
|
265
|
+
return `\n${part.text}`
|
|
266
|
+
}
|
|
267
|
+
return `⬥ ${part.text}`
|
|
167
268
|
}
|
|
168
269
|
|
|
169
270
|
if (part.type === 'reasoning') {
|
|
170
271
|
if (!part.text?.trim()) return ''
|
|
171
|
-
return
|
|
272
|
+
return `┣ thinking`
|
|
172
273
|
}
|
|
173
274
|
|
|
174
275
|
if (part.type === 'file') {
|
|
@@ -180,11 +281,11 @@ export function formatPart(part: Part): string {
|
|
|
180
281
|
}
|
|
181
282
|
|
|
182
283
|
if (part.type === 'agent') {
|
|
183
|
-
return
|
|
284
|
+
return `┣ agent ${part.id}`
|
|
184
285
|
}
|
|
185
286
|
|
|
186
287
|
if (part.type === 'snapshot') {
|
|
187
|
-
return
|
|
288
|
+
return `┣ snapshot ${part.snapshot}`
|
|
188
289
|
}
|
|
189
290
|
|
|
190
291
|
if (part.type === 'tool') {
|
|
@@ -192,6 +293,11 @@ export function formatPart(part: Part): string {
|
|
|
192
293
|
return formatTodoList(part)
|
|
193
294
|
}
|
|
194
295
|
|
|
296
|
+
// Question tool is handled via Discord dropdowns, not text
|
|
297
|
+
if (part.tool === 'question') {
|
|
298
|
+
return ''
|
|
299
|
+
}
|
|
300
|
+
|
|
195
301
|
if (part.state.status === 'pending') {
|
|
196
302
|
return ''
|
|
197
303
|
}
|
|
@@ -206,19 +312,26 @@ export function formatPart(part: Part): string {
|
|
|
206
312
|
const command = (part.state.input?.command as string) || ''
|
|
207
313
|
const description = (part.state.input?.description as string) || ''
|
|
208
314
|
const isSingleLine = !command.includes('\n')
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
toolTitle = `_${command}_`
|
|
315
|
+
if (isSingleLine && command.length <= 50) {
|
|
316
|
+
toolTitle = `_${escapeInlineMarkdown(command)}_`
|
|
212
317
|
} else if (description) {
|
|
213
|
-
toolTitle = `_${description}_`
|
|
318
|
+
toolTitle = `_${escapeInlineMarkdown(description)}_`
|
|
214
319
|
} else if (stateTitle) {
|
|
215
|
-
toolTitle = `_${stateTitle}_`
|
|
320
|
+
toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`
|
|
216
321
|
}
|
|
217
322
|
} else if (stateTitle) {
|
|
218
|
-
toolTitle = `_${stateTitle}_`
|
|
323
|
+
toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`
|
|
219
324
|
}
|
|
220
325
|
|
|
221
|
-
const icon =
|
|
326
|
+
const icon = (() => {
|
|
327
|
+
if (part.state.status === 'error') {
|
|
328
|
+
return '⨯'
|
|
329
|
+
}
|
|
330
|
+
if (part.tool === 'edit' || part.tool === 'write') {
|
|
331
|
+
return '◼︎'
|
|
332
|
+
}
|
|
333
|
+
return '┣'
|
|
334
|
+
})()
|
|
222
335
|
return `${icon} ${part.tool} ${toolTitle} ${summaryText}`
|
|
223
336
|
}
|
|
224
337
|
|