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
package/dist/tools.js ADDED
@@ -0,0 +1,356 @@
1
+ // Voice assistant tool definitions for the GenAI worker.
2
+ // Provides tools for managing OpenCode sessions (create, submit, abort),
3
+ // listing chats, searching files, and reading session messages.
4
+ import { tool } from 'ai';
5
+ import { z } from 'zod';
6
+ import { spawn } from 'node:child_process';
7
+ import net from 'node:net';
8
+ import { createOpencodeClient, } from '@opencode-ai/sdk';
9
+ import { createLogger } from './logger.js';
10
+ const toolsLogger = createLogger('TOOLS');
11
+ import { ShareMarkdown } from './markdown.js';
12
+ import { formatDistanceToNow } from './utils.js';
13
+ import pc from 'picocolors';
14
+ import { initializeOpencodeForDirectory, getOpencodeSystemMessage, } from './discord-bot.js';
15
+ export async function getTools({ onMessageCompleted, directory, }) {
16
+ const getClient = await initializeOpencodeForDirectory(directory);
17
+ const client = getClient();
18
+ const markdownRenderer = new ShareMarkdown(client);
19
+ const providersResponse = await client.config.providers({});
20
+ const providers = providersResponse.data?.providers || [];
21
+ // Helper: get last assistant model for a session (non-summary)
22
+ const getSessionModel = async (sessionId) => {
23
+ const res = await getClient().session.messages({ path: { id: sessionId } });
24
+ const data = res.data;
25
+ if (!data || data.length === 0)
26
+ return undefined;
27
+ for (let i = data.length - 1; i >= 0; i--) {
28
+ const info = data?.[i]?.info;
29
+ if (info?.role === 'assistant') {
30
+ const ai = info;
31
+ if (!ai.summary && ai.providerID && ai.modelID) {
32
+ return { providerID: ai.providerID, modelID: ai.modelID };
33
+ }
34
+ }
35
+ }
36
+ return undefined;
37
+ };
38
+ const tools = {
39
+ submitMessage: tool({
40
+ description: 'Submit a message to an existing chat session. Does not wait for the message to complete',
41
+ inputSchema: z.object({
42
+ sessionId: z.string().describe('The session ID to send message to'),
43
+ message: z.string().describe('The message text to send'),
44
+ }),
45
+ execute: async ({ sessionId, message }) => {
46
+ const sessionModel = await getSessionModel(sessionId);
47
+ // do not await
48
+ getClient()
49
+ .session.prompt({
50
+ path: { id: sessionId },
51
+ body: {
52
+ parts: [{ type: 'text', text: message }],
53
+ model: sessionModel,
54
+ system: getOpencodeSystemMessage({ sessionId }),
55
+ },
56
+ })
57
+ .then(async (response) => {
58
+ const markdown = await markdownRenderer.generate({
59
+ sessionID: sessionId,
60
+ lastAssistantOnly: true,
61
+ });
62
+ onMessageCompleted?.({
63
+ sessionId,
64
+ messageId: '',
65
+ data: response.data,
66
+ markdown,
67
+ });
68
+ })
69
+ .catch((error) => {
70
+ onMessageCompleted?.({
71
+ sessionId,
72
+ messageId: '',
73
+ error,
74
+ });
75
+ });
76
+ return {
77
+ success: true,
78
+ sessionId,
79
+ directive: 'Tell user that message has been sent successfully',
80
+ };
81
+ },
82
+ }),
83
+ createNewChat: tool({
84
+ description: 'Start a new chat session with an initial message. Does not wait for the message to complete',
85
+ inputSchema: z.object({
86
+ message: z
87
+ .string()
88
+ .describe('The initial message to start the chat with'),
89
+ title: z.string().optional().describe('Optional title for the session'),
90
+ model: z
91
+ .object({
92
+ providerId: z
93
+ .string()
94
+ .describe('The provider ID (e.g., "anthropic", "openai")'),
95
+ modelId: z
96
+ .string()
97
+ .describe('The model ID (e.g., "claude-opus-4-20250514", "gpt-5")'),
98
+ })
99
+ .optional()
100
+ .describe('Optional model to use for this session'),
101
+ }),
102
+ execute: async ({ message, title, }) => {
103
+ if (!message.trim()) {
104
+ throw new Error(`message must be a non empty string`);
105
+ }
106
+ try {
107
+ const session = await getClient().session.create({
108
+ body: {
109
+ title: title || message.slice(0, 50),
110
+ },
111
+ });
112
+ if (!session.data) {
113
+ throw new Error('Failed to create session');
114
+ }
115
+ // do not await
116
+ getClient()
117
+ .session.prompt({
118
+ path: { id: session.data.id },
119
+ body: {
120
+ parts: [{ type: 'text', text: message }],
121
+ system: getOpencodeSystemMessage({ sessionId: session.data.id }),
122
+ },
123
+ })
124
+ .then(async (response) => {
125
+ const markdown = await markdownRenderer.generate({
126
+ sessionID: session.data.id,
127
+ lastAssistantOnly: true,
128
+ });
129
+ onMessageCompleted?.({
130
+ sessionId: session.data.id,
131
+ messageId: '',
132
+ data: response.data,
133
+ markdown,
134
+ });
135
+ })
136
+ .catch((error) => {
137
+ onMessageCompleted?.({
138
+ sessionId: session.data.id,
139
+ messageId: '',
140
+ error,
141
+ });
142
+ });
143
+ return {
144
+ success: true,
145
+ sessionId: session.data.id,
146
+ title: session.data.title,
147
+ };
148
+ }
149
+ catch (error) {
150
+ return {
151
+ success: false,
152
+ error: error instanceof Error
153
+ ? error.message
154
+ : 'Failed to create chat session',
155
+ };
156
+ }
157
+ },
158
+ }),
159
+ listChats: tool({
160
+ description: 'Get a list of available chat sessions sorted by most recent',
161
+ inputSchema: z.object({}),
162
+ execute: async () => {
163
+ toolsLogger.log(`Listing opencode sessions`);
164
+ const sessions = await getClient().session.list();
165
+ if (!sessions.data) {
166
+ return { success: false, error: 'No sessions found' };
167
+ }
168
+ const sortedSessions = [...sessions.data]
169
+ .sort((a, b) => {
170
+ return b.time.updated - a.time.updated;
171
+ })
172
+ .slice(0, 20);
173
+ const sessionList = sortedSessions.map(async (session) => {
174
+ const finishedAt = session.time.updated;
175
+ const status = await (async () => {
176
+ if (session.revert)
177
+ return 'error';
178
+ const messagesResponse = await getClient().session.messages({
179
+ path: { id: session.id },
180
+ });
181
+ const messages = messagesResponse.data || [];
182
+ const lastMessage = messages[messages.length - 1];
183
+ if (lastMessage?.info.role === 'assistant' &&
184
+ !lastMessage.info.time.completed) {
185
+ return 'in_progress';
186
+ }
187
+ return 'finished';
188
+ })();
189
+ return {
190
+ id: session.id,
191
+ folder: session.directory,
192
+ status,
193
+ finishedAt: formatDistanceToNow(new Date(finishedAt)),
194
+ title: session.title,
195
+ prompt: session.title,
196
+ };
197
+ });
198
+ const resolvedList = await Promise.all(sessionList);
199
+ return {
200
+ success: true,
201
+ sessions: resolvedList,
202
+ };
203
+ },
204
+ }),
205
+ searchFiles: tool({
206
+ description: 'Search for files in a folder',
207
+ inputSchema: z.object({
208
+ folder: z
209
+ .string()
210
+ .optional()
211
+ .describe('The folder path to search in, optional. only use if user specifically asks for it'),
212
+ query: z.string().describe('The search query for files'),
213
+ }),
214
+ execute: async ({ folder, query }) => {
215
+ const results = await getClient().find.files({
216
+ query: {
217
+ query,
218
+ directory: folder,
219
+ },
220
+ });
221
+ return {
222
+ success: true,
223
+ files: results.data || [],
224
+ };
225
+ },
226
+ }),
227
+ readSessionMessages: tool({
228
+ description: 'Read messages from a chat session',
229
+ inputSchema: z.object({
230
+ sessionId: z.string().describe('The session ID to read messages from'),
231
+ lastAssistantOnly: z
232
+ .boolean()
233
+ .optional()
234
+ .describe('Only read the last assistant message'),
235
+ }),
236
+ execute: async ({ sessionId, lastAssistantOnly = false }) => {
237
+ if (lastAssistantOnly) {
238
+ const messages = await getClient().session.messages({
239
+ path: { id: sessionId },
240
+ });
241
+ if (!messages.data) {
242
+ return { success: false, error: 'No messages found' };
243
+ }
244
+ const assistantMessages = messages.data.filter((m) => m.info.role === 'assistant');
245
+ if (assistantMessages.length === 0) {
246
+ return {
247
+ success: false,
248
+ error: 'No assistant messages found',
249
+ };
250
+ }
251
+ const lastMessage = assistantMessages[assistantMessages.length - 1];
252
+ const status = 'completed' in lastMessage.info.time &&
253
+ lastMessage.info.time.completed
254
+ ? 'completed'
255
+ : 'in_progress';
256
+ const markdown = await markdownRenderer.generate({
257
+ sessionID: sessionId,
258
+ lastAssistantOnly: true,
259
+ });
260
+ return {
261
+ success: true,
262
+ markdown,
263
+ status,
264
+ };
265
+ }
266
+ else {
267
+ const markdown = await markdownRenderer.generate({
268
+ sessionID: sessionId,
269
+ });
270
+ const messages = await getClient().session.messages({
271
+ path: { id: sessionId },
272
+ });
273
+ const lastMessage = messages.data?.[messages.data.length - 1];
274
+ const status = lastMessage?.info.role === 'assistant' &&
275
+ lastMessage?.info.time &&
276
+ 'completed' in lastMessage.info.time &&
277
+ !lastMessage.info.time.completed
278
+ ? 'in_progress'
279
+ : 'completed';
280
+ return {
281
+ success: true,
282
+ markdown,
283
+ status,
284
+ };
285
+ }
286
+ },
287
+ }),
288
+ abortChat: tool({
289
+ description: 'Abort/stop an in-progress chat session',
290
+ inputSchema: z.object({
291
+ sessionId: z.string().describe('The session ID to abort'),
292
+ }),
293
+ execute: async ({ sessionId }) => {
294
+ try {
295
+ const result = await getClient().session.abort({
296
+ path: { id: sessionId },
297
+ });
298
+ if (!result.data) {
299
+ return {
300
+ success: false,
301
+ error: 'Failed to abort session',
302
+ };
303
+ }
304
+ return {
305
+ success: true,
306
+ sessionId,
307
+ message: 'Session aborted successfully',
308
+ };
309
+ }
310
+ catch (error) {
311
+ return {
312
+ success: false,
313
+ error: error instanceof Error ? error.message : 'Unknown error occurred',
314
+ };
315
+ }
316
+ },
317
+ }),
318
+ getModels: tool({
319
+ description: 'Get all available AI models from all providers',
320
+ inputSchema: z.object({}),
321
+ execute: async () => {
322
+ try {
323
+ const providersResponse = await getClient().config.providers({});
324
+ const providers = providersResponse.data?.providers || [];
325
+ const models = [];
326
+ providers.forEach((provider) => {
327
+ if (provider.models && typeof provider.models === 'object') {
328
+ Object.entries(provider.models).forEach(([modelId, model]) => {
329
+ models.push({
330
+ providerId: provider.id,
331
+ modelId: modelId,
332
+ });
333
+ });
334
+ }
335
+ });
336
+ return {
337
+ success: true,
338
+ models,
339
+ totalCount: models.length,
340
+ };
341
+ }
342
+ catch (error) {
343
+ return {
344
+ success: false,
345
+ error: error instanceof Error ? error.message : 'Failed to fetch models',
346
+ models: [],
347
+ };
348
+ }
349
+ },
350
+ }),
351
+ };
352
+ return {
353
+ tools,
354
+ providers,
355
+ };
356
+ }
package/dist/utils.js ADDED
@@ -0,0 +1,85 @@
1
+ // General utility functions for the bot.
2
+ // Includes Discord OAuth URL generation, array deduplication,
3
+ // abort error detection, and date/time formatting helpers.
4
+ import { PermissionsBitField } from 'discord.js';
5
+ export function generateBotInstallUrl({ clientId, permissions = [
6
+ PermissionsBitField.Flags.ViewChannel,
7
+ PermissionsBitField.Flags.ManageChannels,
8
+ PermissionsBitField.Flags.SendMessages,
9
+ PermissionsBitField.Flags.SendMessagesInThreads,
10
+ PermissionsBitField.Flags.CreatePublicThreads,
11
+ PermissionsBitField.Flags.ManageThreads,
12
+ PermissionsBitField.Flags.ReadMessageHistory,
13
+ PermissionsBitField.Flags.AddReactions,
14
+ PermissionsBitField.Flags.ManageMessages,
15
+ PermissionsBitField.Flags.UseExternalEmojis,
16
+ PermissionsBitField.Flags.AttachFiles,
17
+ PermissionsBitField.Flags.Connect,
18
+ PermissionsBitField.Flags.Speak,
19
+ ], scopes = ['bot'], guildId, disableGuildSelect = false, }) {
20
+ const permissionsBitField = new PermissionsBitField(permissions);
21
+ const permissionsValue = permissionsBitField.bitfield.toString();
22
+ const url = new URL('https://discord.com/api/oauth2/authorize');
23
+ url.searchParams.set('client_id', clientId);
24
+ url.searchParams.set('permissions', permissionsValue);
25
+ url.searchParams.set('scope', scopes.join(' '));
26
+ if (guildId) {
27
+ url.searchParams.set('guild_id', guildId);
28
+ }
29
+ if (disableGuildSelect) {
30
+ url.searchParams.set('disable_guild_select', 'true');
31
+ }
32
+ return url.toString();
33
+ }
34
+ export function deduplicateByKey(arr, keyFn) {
35
+ const seen = new Set();
36
+ return arr.filter((item) => {
37
+ const key = keyFn(item);
38
+ if (seen.has(key)) {
39
+ return false;
40
+ }
41
+ seen.add(key);
42
+ return true;
43
+ });
44
+ }
45
+ export function isAbortError(error, signal) {
46
+ return ((error instanceof Error &&
47
+ (error.name === 'AbortError' ||
48
+ error.name === 'Aborterror' ||
49
+ error.name === 'aborterror' ||
50
+ error.name.toLowerCase() === 'aborterror' ||
51
+ error.message?.includes('aborted') ||
52
+ (signal?.aborted ?? false))) ||
53
+ (error instanceof DOMException && error.name === 'AbortError'));
54
+ }
55
+ const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
56
+ const TIME_DIVISIONS = [
57
+ { amount: 60, name: 'seconds' },
58
+ { amount: 60, name: 'minutes' },
59
+ { amount: 24, name: 'hours' },
60
+ { amount: 7, name: 'days' },
61
+ { amount: 4.34524, name: 'weeks' },
62
+ { amount: 12, name: 'months' },
63
+ { amount: Number.POSITIVE_INFINITY, name: 'years' },
64
+ ];
65
+ export function formatDistanceToNow(date) {
66
+ let duration = (date.getTime() - Date.now()) / 1000;
67
+ for (const division of TIME_DIVISIONS) {
68
+ if (Math.abs(duration) < division.amount) {
69
+ return rtf.format(Math.round(duration), division.name);
70
+ }
71
+ duration /= division.amount;
72
+ }
73
+ return rtf.format(Math.round(duration), 'years');
74
+ }
75
+ const dtf = new Intl.DateTimeFormat('en-US', {
76
+ month: 'short',
77
+ day: 'numeric',
78
+ year: 'numeric',
79
+ hour: 'numeric',
80
+ minute: '2-digit',
81
+ hour12: true,
82
+ });
83
+ export function formatDateTime(date) {
84
+ return dtf.format(date);
85
+ }