shuvmaki 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/bin.js +70 -0
  2. package/dist/ai-tool-to-genai.js +210 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/channel-management.js +97 -0
  5. package/dist/cli.js +709 -0
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +98 -0
  8. package/dist/commands/agent.js +152 -0
  9. package/dist/commands/ask-question.js +183 -0
  10. package/dist/commands/create-new-project.js +78 -0
  11. package/dist/commands/fork.js +186 -0
  12. package/dist/commands/model.js +313 -0
  13. package/dist/commands/permissions.js +126 -0
  14. package/dist/commands/queue.js +129 -0
  15. package/dist/commands/resume.js +145 -0
  16. package/dist/commands/session.js +142 -0
  17. package/dist/commands/share.js +80 -0
  18. package/dist/commands/types.js +2 -0
  19. package/dist/commands/undo-redo.js +161 -0
  20. package/dist/commands/user-command.js +145 -0
  21. package/dist/database.js +184 -0
  22. package/dist/discord-bot.js +384 -0
  23. package/dist/discord-utils.js +217 -0
  24. package/dist/escape-backticks.test.js +410 -0
  25. package/dist/format-tables.js +96 -0
  26. package/dist/format-tables.test.js +418 -0
  27. package/dist/genai-worker-wrapper.js +109 -0
  28. package/dist/genai-worker.js +297 -0
  29. package/dist/genai.js +232 -0
  30. package/dist/interaction-handler.js +144 -0
  31. package/dist/logger.js +51 -0
  32. package/dist/markdown.js +310 -0
  33. package/dist/markdown.test.js +262 -0
  34. package/dist/message-formatting.js +273 -0
  35. package/dist/message-formatting.test.js +73 -0
  36. package/dist/openai-realtime.js +228 -0
  37. package/dist/opencode.js +216 -0
  38. package/dist/session-handler.js +580 -0
  39. package/dist/system-message.js +61 -0
  40. package/dist/tools.js +356 -0
  41. package/dist/utils.js +85 -0
  42. package/dist/voice-handler.js +541 -0
  43. package/dist/voice.js +314 -0
  44. package/dist/worker-types.js +4 -0
  45. package/dist/xml.js +92 -0
  46. package/dist/xml.test.js +32 -0
  47. package/package.json +60 -0
  48. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  49. package/src/__snapshots__/compact-session-context.md +47 -0
  50. package/src/ai-tool-to-genai.test.ts +296 -0
  51. package/src/ai-tool-to-genai.ts +255 -0
  52. package/src/channel-management.ts +161 -0
  53. package/src/cli.ts +1010 -0
  54. package/src/commands/abort.ts +94 -0
  55. package/src/commands/add-project.ts +139 -0
  56. package/src/commands/agent.ts +201 -0
  57. package/src/commands/ask-question.ts +276 -0
  58. package/src/commands/create-new-project.ts +111 -0
  59. package/src/commands/fork.ts +257 -0
  60. package/src/commands/model.ts +402 -0
  61. package/src/commands/permissions.ts +146 -0
  62. package/src/commands/queue.ts +181 -0
  63. package/src/commands/resume.ts +230 -0
  64. package/src/commands/session.ts +184 -0
  65. package/src/commands/share.ts +96 -0
  66. package/src/commands/types.ts +25 -0
  67. package/src/commands/undo-redo.ts +213 -0
  68. package/src/commands/user-command.ts +178 -0
  69. package/src/database.ts +220 -0
  70. package/src/discord-bot.ts +513 -0
  71. package/src/discord-utils.ts +282 -0
  72. package/src/escape-backticks.test.ts +447 -0
  73. package/src/format-tables.test.ts +440 -0
  74. package/src/format-tables.ts +110 -0
  75. package/src/genai-worker-wrapper.ts +160 -0
  76. package/src/genai-worker.ts +366 -0
  77. package/src/genai.ts +321 -0
  78. package/src/interaction-handler.ts +187 -0
  79. package/src/logger.ts +57 -0
  80. package/src/markdown.test.ts +358 -0
  81. package/src/markdown.ts +365 -0
  82. package/src/message-formatting.test.ts +81 -0
  83. package/src/message-formatting.ts +340 -0
  84. package/src/openai-realtime.ts +363 -0
  85. package/src/opencode.ts +277 -0
  86. package/src/session-handler.ts +758 -0
  87. package/src/system-message.ts +62 -0
  88. package/src/tools.ts +428 -0
  89. package/src/utils.ts +118 -0
  90. package/src/voice-handler.ts +760 -0
  91. package/src/voice.ts +432 -0
  92. package/src/worker-types.ts +66 -0
  93. package/src/xml.test.ts +37 -0
  94. package/src/xml.ts +121 -0
@@ -0,0 +1,365 @@
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
+
5
+ import type { OpencodeClient } from '@opencode-ai/sdk'
6
+ import * as yaml from 'js-yaml'
7
+ import { formatDateTime } from './utils.js'
8
+ import { extractNonXmlContent } from './xml.js'
9
+ import { createLogger } from './logger.js'
10
+
11
+ const markdownLogger = createLogger('MARKDOWN')
12
+
13
+ export class ShareMarkdown {
14
+ constructor(private client: OpencodeClient) {}
15
+
16
+ /**
17
+ * Generate a markdown representation of a session
18
+ * @param options Configuration options
19
+ * @returns Markdown string representation of the session
20
+ */
21
+ async generate(options: {
22
+ sessionID: string
23
+ includeSystemInfo?: boolean
24
+ lastAssistantOnly?: boolean
25
+ }): Promise<string> {
26
+ const { sessionID, includeSystemInfo, lastAssistantOnly } = options
27
+
28
+ // Get session info
29
+ const sessionResponse = await this.client.session.get({
30
+ path: { id: sessionID },
31
+ })
32
+ if (!sessionResponse.data) {
33
+ throw new Error(`Session ${sessionID} not found`)
34
+ }
35
+ const session = sessionResponse.data
36
+
37
+ // Get all messages
38
+ const messagesResponse = await this.client.session.messages({
39
+ path: { id: sessionID },
40
+ })
41
+ if (!messagesResponse.data) {
42
+ throw new Error(`No messages found for session ${sessionID}`)
43
+ }
44
+ const messages = messagesResponse.data
45
+
46
+ // If lastAssistantOnly, filter to only the last assistant message
47
+ const messagesToRender = lastAssistantOnly
48
+ ? (() => {
49
+ const assistantMessages = messages.filter(
50
+ (m) => m.info.role === 'assistant',
51
+ )
52
+ return assistantMessages.length > 0
53
+ ? [assistantMessages[assistantMessages.length - 1]]
54
+ : []
55
+ })()
56
+ : messages
57
+
58
+ // Build markdown
59
+ const lines: string[] = []
60
+
61
+ // Only include header and session info if not lastAssistantOnly
62
+ if (!lastAssistantOnly) {
63
+ // Header
64
+ lines.push(`# ${session.title || 'Untitled Session'}`)
65
+ lines.push('')
66
+
67
+ // Session metadata
68
+ if (includeSystemInfo === true) {
69
+ lines.push('## Session Information')
70
+ lines.push('')
71
+ lines.push(
72
+ `- **Created**: ${formatDateTime(new Date(session.time.created))}`,
73
+ )
74
+ lines.push(
75
+ `- **Updated**: ${formatDateTime(new Date(session.time.updated))}`,
76
+ )
77
+ if (session.version) {
78
+ lines.push(`- **OpenCode Version**: v${session.version}`)
79
+ }
80
+ lines.push('')
81
+ }
82
+
83
+ // Process messages
84
+ lines.push('## Conversation')
85
+ lines.push('')
86
+ }
87
+
88
+ for (const message of messagesToRender) {
89
+ const messageLines = this.renderMessage(message!.info, message!.parts)
90
+ lines.push(...messageLines)
91
+ lines.push('')
92
+ }
93
+
94
+ return lines.join('\n')
95
+ }
96
+
97
+ private renderMessage(message: any, parts: any[]): string[] {
98
+ const lines: string[] = []
99
+
100
+ if (message.role === 'user') {
101
+ lines.push('### 👤 User')
102
+ lines.push('')
103
+
104
+ for (const part of parts) {
105
+ if (part.type === 'text' && part.text) {
106
+ const cleanedText = extractNonXmlContent(part.text)
107
+ if (cleanedText.trim()) {
108
+ lines.push(cleanedText)
109
+ lines.push('')
110
+ }
111
+ } else if (part.type === 'file') {
112
+ lines.push(`📎 **Attachment**: ${part.filename || 'unnamed file'}`)
113
+ if (part.url) {
114
+ lines.push(` - URL: ${part.url}`)
115
+ }
116
+ lines.push('')
117
+ }
118
+ }
119
+ } else if (message.role === 'assistant') {
120
+ lines.push(`### 🤖 Assistant (${message.modelID || 'unknown model'})`)
121
+ lines.push('')
122
+
123
+ // Filter and process parts
124
+ const filteredParts = parts.filter((part) => {
125
+ if (part.type === 'step-start' && parts.indexOf(part) > 0) return false
126
+ if (part.type === 'snapshot') return false
127
+ if (part.type === 'patch') return false
128
+ if (part.type === 'step-finish') return false
129
+ if (part.type === 'text' && part.synthetic === true) return false
130
+ if (part.type === 'tool' && part.tool === 'todoread') return false
131
+ if (part.type === 'text' && !part.text) return false
132
+ if (
133
+ part.type === 'tool' &&
134
+ (part.state.status === 'pending' || part.state.status === 'running')
135
+ )
136
+ return false
137
+ return true
138
+ })
139
+
140
+ for (const part of filteredParts) {
141
+ const partLines = this.renderPart(part, message)
142
+ lines.push(...partLines)
143
+ }
144
+
145
+ // Add completion time if available
146
+ if (message.time?.completed) {
147
+ const duration = message.time.completed - message.time.created
148
+ lines.push('')
149
+ lines.push(`*Completed in ${this.formatDuration(duration)}*`)
150
+ }
151
+ }
152
+
153
+ return lines
154
+ }
155
+
156
+ private renderPart(part: any, message: any): string[] {
157
+ const lines: string[] = []
158
+
159
+ switch (part.type) {
160
+ case 'text':
161
+ if (part.text) {
162
+ lines.push(part.text)
163
+ lines.push('')
164
+ }
165
+ break
166
+
167
+ case 'reasoning':
168
+ if (part.text) {
169
+ lines.push('<details>')
170
+ lines.push('<summary>💭 Thinking</summary>')
171
+ lines.push('')
172
+ lines.push(part.text)
173
+ lines.push('')
174
+ lines.push('</details>')
175
+ lines.push('')
176
+ }
177
+ break
178
+
179
+ case 'tool':
180
+ if (part.state.status === 'completed') {
181
+ lines.push(`#### 🛠️ Tool: ${part.tool}`)
182
+ lines.push('')
183
+
184
+ // Render input parameters in YAML
185
+ if (part.state.input && Object.keys(part.state.input).length > 0) {
186
+ lines.push('**Input:**')
187
+ lines.push('```yaml')
188
+ lines.push(yaml.dump(part.state.input, { lineWidth: -1 }))
189
+ lines.push('```')
190
+ lines.push('')
191
+ }
192
+
193
+ // Render output
194
+ if (part.state.output) {
195
+ lines.push('**Output:**')
196
+ lines.push('```')
197
+ lines.push(part.state.output)
198
+ lines.push('```')
199
+ lines.push('')
200
+ }
201
+
202
+ // Add timing info if significant
203
+ if (part.state.time?.start && part.state.time?.end) {
204
+ const duration = part.state.time.end - part.state.time.start
205
+ if (duration > 2000) {
206
+ lines.push(`*Duration: ${this.formatDuration(duration)}*`)
207
+ lines.push('')
208
+ }
209
+ }
210
+ } else if (part.state.status === 'error') {
211
+ lines.push(`#### ❌ Tool Error: ${part.tool}`)
212
+ lines.push('')
213
+ lines.push('```')
214
+ lines.push(part.state.error || 'Unknown error')
215
+ lines.push('```')
216
+ lines.push('')
217
+ }
218
+ break
219
+
220
+ case 'step-start':
221
+ lines.push(`**Started using ${message.providerID}/${message.modelID}**`)
222
+ lines.push('')
223
+ break
224
+ }
225
+
226
+ return lines
227
+ }
228
+
229
+ private formatDuration(ms: number): string {
230
+ if (ms < 1000) return `${ms}ms`
231
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
232
+ const minutes = Math.floor(ms / 60000)
233
+ const seconds = Math.floor((ms % 60000) / 1000)
234
+ return `${minutes}m ${seconds}s`
235
+ }
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
+ })