disunday 1.0.0

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 (83) hide show
  1. package/dist/ai-tool-to-genai.js +208 -0
  2. package/dist/ai-tool-to-genai.test.js +267 -0
  3. package/dist/channel-management.js +96 -0
  4. package/dist/cli.js +1674 -0
  5. package/dist/commands/abort.js +89 -0
  6. package/dist/commands/add-project.js +117 -0
  7. package/dist/commands/agent.js +250 -0
  8. package/dist/commands/ask-question.js +219 -0
  9. package/dist/commands/compact.js +126 -0
  10. package/dist/commands/context-menu.js +171 -0
  11. package/dist/commands/context.js +89 -0
  12. package/dist/commands/cost.js +93 -0
  13. package/dist/commands/create-new-project.js +111 -0
  14. package/dist/commands/diff.js +77 -0
  15. package/dist/commands/export.js +100 -0
  16. package/dist/commands/files.js +73 -0
  17. package/dist/commands/fork.js +199 -0
  18. package/dist/commands/help.js +54 -0
  19. package/dist/commands/login.js +488 -0
  20. package/dist/commands/merge-worktree.js +165 -0
  21. package/dist/commands/model.js +325 -0
  22. package/dist/commands/permissions.js +140 -0
  23. package/dist/commands/ping.js +13 -0
  24. package/dist/commands/queue.js +133 -0
  25. package/dist/commands/remove-project.js +119 -0
  26. package/dist/commands/rename.js +70 -0
  27. package/dist/commands/restart-opencode-server.js +77 -0
  28. package/dist/commands/resume.js +276 -0
  29. package/dist/commands/run-config.js +79 -0
  30. package/dist/commands/run.js +240 -0
  31. package/dist/commands/schedule.js +170 -0
  32. package/dist/commands/session-info.js +58 -0
  33. package/dist/commands/session.js +191 -0
  34. package/dist/commands/settings.js +84 -0
  35. package/dist/commands/share.js +89 -0
  36. package/dist/commands/status.js +79 -0
  37. package/dist/commands/sync.js +119 -0
  38. package/dist/commands/theme.js +53 -0
  39. package/dist/commands/types.js +2 -0
  40. package/dist/commands/undo-redo.js +170 -0
  41. package/dist/commands/user-command.js +135 -0
  42. package/dist/commands/verbosity.js +59 -0
  43. package/dist/commands/worktree-settings.js +50 -0
  44. package/dist/commands/worktree.js +288 -0
  45. package/dist/config.js +139 -0
  46. package/dist/database.js +585 -0
  47. package/dist/discord-bot.js +700 -0
  48. package/dist/discord-utils.js +336 -0
  49. package/dist/discord-utils.test.js +20 -0
  50. package/dist/errors.js +193 -0
  51. package/dist/escape-backticks.test.js +429 -0
  52. package/dist/format-tables.js +96 -0
  53. package/dist/format-tables.test.js +418 -0
  54. package/dist/genai-worker-wrapper.js +109 -0
  55. package/dist/genai-worker.js +299 -0
  56. package/dist/genai.js +230 -0
  57. package/dist/image-utils.js +107 -0
  58. package/dist/interaction-handler.js +289 -0
  59. package/dist/limit-heading-depth.js +25 -0
  60. package/dist/limit-heading-depth.test.js +105 -0
  61. package/dist/logger.js +111 -0
  62. package/dist/markdown.js +323 -0
  63. package/dist/markdown.test.js +269 -0
  64. package/dist/message-formatting.js +447 -0
  65. package/dist/message-formatting.test.js +73 -0
  66. package/dist/openai-realtime.js +226 -0
  67. package/dist/opencode.js +224 -0
  68. package/dist/reaction-handler.js +128 -0
  69. package/dist/scheduler.js +93 -0
  70. package/dist/security.js +200 -0
  71. package/dist/session-handler.js +1436 -0
  72. package/dist/system-message.js +138 -0
  73. package/dist/tools.js +354 -0
  74. package/dist/unnest-code-blocks.js +117 -0
  75. package/dist/unnest-code-blocks.test.js +432 -0
  76. package/dist/utils.js +95 -0
  77. package/dist/voice-handler.js +569 -0
  78. package/dist/voice.js +344 -0
  79. package/dist/worker-types.js +4 -0
  80. package/dist/worktree-utils.js +134 -0
  81. package/dist/xml.js +90 -0
  82. package/dist/xml.test.js +32 -0
  83. package/package.json +84 -0
@@ -0,0 +1,226 @@
1
+ /* eslint-disable @typescript-eslint/ban-ts-comment */
2
+ /* istanbul ignore file */
3
+ // @ts-nocheck
4
+ import { RealtimeClient } from '@openai/realtime-api-beta';
5
+ import { writeFile } from 'fs';
6
+ import { createLogger, LogPrefix } from './logger.js';
7
+ const openaiLogger = createLogger(LogPrefix.OPENAI);
8
+ const audioParts = [];
9
+ function saveBinaryFile(fileName, content) {
10
+ writeFile(fileName, content, 'utf8', (err) => {
11
+ if (err) {
12
+ openaiLogger.error(`Error writing file ${fileName}:`, err);
13
+ return;
14
+ }
15
+ openaiLogger.log(`Appending stream content to file ${fileName}.`);
16
+ });
17
+ }
18
+ function convertToWav(rawData, mimeType) {
19
+ const options = parseMimeType(mimeType);
20
+ const dataLength = rawData.reduce((a, b) => a + b.length, 0);
21
+ const wavHeader = createWavHeader(dataLength, options);
22
+ const buffer = Buffer.concat(rawData);
23
+ return Buffer.concat([wavHeader, buffer]);
24
+ }
25
+ function parseMimeType(mimeType) {
26
+ const [fileType, ...params] = mimeType.split(';').map((s) => s.trim());
27
+ const [_, format] = fileType?.split('/') || [];
28
+ const options = {
29
+ numChannels: 1,
30
+ bitsPerSample: 16,
31
+ };
32
+ if (format && format.startsWith('L')) {
33
+ const bits = parseInt(format.slice(1), 10);
34
+ if (!isNaN(bits)) {
35
+ options.bitsPerSample = bits;
36
+ }
37
+ }
38
+ for (const param of params) {
39
+ const [key, value] = param.split('=').map((s) => s.trim());
40
+ if (key === 'rate') {
41
+ options.sampleRate = parseInt(value || '', 10);
42
+ }
43
+ }
44
+ return options;
45
+ }
46
+ function createWavHeader(dataLength, options) {
47
+ const { numChannels, sampleRate, bitsPerSample } = options;
48
+ // http://soundfile.sapp.org/doc/WaveFormat
49
+ const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
50
+ const blockAlign = (numChannels * bitsPerSample) / 8;
51
+ const buffer = Buffer.alloc(44);
52
+ buffer.write('RIFF', 0); // ChunkID
53
+ buffer.writeUInt32LE(36 + dataLength, 4); // ChunkSize
54
+ buffer.write('WAVE', 8); // Format
55
+ buffer.write('fmt ', 12); // Subchunk1ID
56
+ buffer.writeUInt32LE(16, 16); // Subchunk1Size (PCM)
57
+ buffer.writeUInt16LE(1, 20); // AudioFormat (1 = PCM)
58
+ buffer.writeUInt16LE(numChannels, 22); // NumChannels
59
+ buffer.writeUInt32LE(sampleRate, 24); // SampleRate
60
+ buffer.writeUInt32LE(byteRate, 28); // ByteRate
61
+ buffer.writeUInt16LE(blockAlign, 32); // BlockAlign
62
+ buffer.writeUInt16LE(bitsPerSample, 34); // BitsPerSample
63
+ buffer.write('data', 36); // Subchunk2ID
64
+ buffer.writeUInt32LE(dataLength, 40); // Subchunk2Size
65
+ return buffer;
66
+ }
67
+ function defaultAudioChunkHandler({ data, mimeType }) {
68
+ audioParts.push(data);
69
+ const fileName = 'audio.wav';
70
+ const buffer = convertToWav(audioParts, mimeType);
71
+ saveBinaryFile(fileName, buffer);
72
+ }
73
+ export async function startGenAiSession({ onAssistantAudioChunk, onAssistantStartSpeaking, onAssistantStopSpeaking, onAssistantInterruptSpeaking, systemMessage, tools, } = {}) {
74
+ if (!process.env.OPENAI_API_KEY) {
75
+ throw new Error('OPENAI_API_KEY environment variable is required');
76
+ }
77
+ const client = new RealtimeClient({
78
+ apiKey: process.env.OPENAI_API_KEY,
79
+ });
80
+ const audioChunkHandler = onAssistantAudioChunk || defaultAudioChunkHandler;
81
+ let isAssistantSpeaking = false;
82
+ // Configure session with 24kHz sample rate
83
+ client.updateSession({
84
+ instructions: systemMessage || '',
85
+ voice: 'alloy',
86
+ input_audio_format: 'pcm16',
87
+ output_audio_format: 'pcm16',
88
+ input_audio_transcription: { model: 'whisper-1' },
89
+ turn_detection: { type: 'server_vad' },
90
+ modalities: ['text', 'audio'],
91
+ temperature: 0.8,
92
+ });
93
+ // Add tools if provided
94
+ if (tools) {
95
+ for (const [name, tool] of Object.entries(tools)) {
96
+ // Convert AI SDK tool to OpenAI Realtime format
97
+ // The tool.inputSchema is a Zod schema, we need to convert it to JSON Schema
98
+ let parameters = {
99
+ type: 'object',
100
+ properties: {},
101
+ required: [],
102
+ };
103
+ // If the tool has a Zod schema, we can try to extract basic structure
104
+ // For now, we'll use a simple placeholder
105
+ if (tool.description?.includes('session')) {
106
+ parameters = {
107
+ type: 'object',
108
+ properties: {
109
+ sessionId: { type: 'string', description: 'The session ID' },
110
+ message: { type: 'string', description: 'The message text' },
111
+ },
112
+ required: ['sessionId'],
113
+ };
114
+ }
115
+ client.addTool({
116
+ type: 'function',
117
+ name,
118
+ description: tool.description || '',
119
+ parameters,
120
+ }, async (params) => {
121
+ try {
122
+ if (!tool.execute || typeof tool.execute !== 'function') {
123
+ return { error: 'Tool execute function not found' };
124
+ }
125
+ // Call the execute function with params
126
+ // The Tool type from 'ai' expects (input, options) but we need to handle this safely
127
+ const result = await tool.execute(params, {
128
+ abortSignal: new AbortController().signal,
129
+ toolCallId: '',
130
+ messages: [],
131
+ });
132
+ return result;
133
+ }
134
+ catch (error) {
135
+ openaiLogger.error(`Tool ${name} execution error:`, error);
136
+ return { error: String(error) };
137
+ }
138
+ });
139
+ }
140
+ }
141
+ // Set up event handlers
142
+ client.on('conversation.item.created', ({ item }) => {
143
+ if ('role' in item && item.role === 'assistant' && item.type === 'message') {
144
+ // Check if this is the first audio content
145
+ const hasAudio = 'content' in item &&
146
+ Array.isArray(item.content) &&
147
+ item.content.some((c) => 'type' in c && c.type === 'audio');
148
+ if (hasAudio && !isAssistantSpeaking && onAssistantStartSpeaking) {
149
+ isAssistantSpeaking = true;
150
+ onAssistantStartSpeaking();
151
+ }
152
+ }
153
+ });
154
+ client.on('conversation.updated', ({ item, delta }) => {
155
+ // Handle audio chunks
156
+ if (delta?.audio && 'role' in item && item.role === 'assistant') {
157
+ if (!isAssistantSpeaking && onAssistantStartSpeaking) {
158
+ isAssistantSpeaking = true;
159
+ onAssistantStartSpeaking();
160
+ }
161
+ // OpenAI provides audio as Int16Array or base64
162
+ let audioBuffer;
163
+ if (delta.audio instanceof Int16Array) {
164
+ audioBuffer = Buffer.from(delta.audio.buffer);
165
+ }
166
+ else {
167
+ // Assume base64 string
168
+ audioBuffer = Buffer.from(delta.audio, 'base64');
169
+ }
170
+ // OpenAI uses 24kHz PCM16 format
171
+ audioChunkHandler({
172
+ data: audioBuffer,
173
+ mimeType: 'audio/pcm;rate=24000',
174
+ });
175
+ }
176
+ // Handle transcriptions
177
+ if (delta?.transcript) {
178
+ if ('role' in item) {
179
+ if (item.role === 'user') {
180
+ openaiLogger.log('User transcription:', delta.transcript);
181
+ }
182
+ else if (item.role === 'assistant') {
183
+ openaiLogger.log('Assistant transcription:', delta.transcript);
184
+ }
185
+ }
186
+ }
187
+ });
188
+ client.on('conversation.item.completed', ({ item }) => {
189
+ if ('role' in item &&
190
+ item.role === 'assistant' &&
191
+ isAssistantSpeaking &&
192
+ onAssistantStopSpeaking) {
193
+ isAssistantSpeaking = false;
194
+ onAssistantStopSpeaking();
195
+ }
196
+ });
197
+ client.on('conversation.interrupted', () => {
198
+ openaiLogger.log('Assistant was interrupted');
199
+ if (isAssistantSpeaking && onAssistantInterruptSpeaking) {
200
+ isAssistantSpeaking = false;
201
+ onAssistantInterruptSpeaking();
202
+ }
203
+ });
204
+ // Connect to the Realtime API
205
+ await client.connect();
206
+ const sessionResult = {
207
+ session: {
208
+ send: (audioData) => {
209
+ // Convert ArrayBuffer to Int16Array for OpenAI
210
+ const int16Data = new Int16Array(audioData);
211
+ client.appendInputAudio(int16Data);
212
+ },
213
+ sendText: (text) => {
214
+ // Send text message to OpenAI
215
+ client.sendUserMessageContent([{ type: 'input_text', text }]);
216
+ },
217
+ close: () => {
218
+ client.disconnect();
219
+ },
220
+ },
221
+ stop: () => {
222
+ client.disconnect();
223
+ },
224
+ };
225
+ return sessionResult;
226
+ }
@@ -0,0 +1,224 @@
1
+ // OpenCode server process manager.
2
+ // Spawns and maintains OpenCode API servers per project directory,
3
+ // handles automatic restarts on failure, and provides typed SDK clients.
4
+ // Uses errore for type-safe error handling.
5
+ import { spawn } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import net from 'node:net';
8
+ import { createOpencodeClient, } from '@opencode-ai/sdk';
9
+ import { createOpencodeClient as createOpencodeClientV2, } from '@opencode-ai/sdk/v2';
10
+ import * as errore from 'errore';
11
+ import { createLogger, LogPrefix } from './logger.js';
12
+ import { getBashWhitelist } from './config.js';
13
+ import { DirectoryNotAccessibleError, ServerStartError, ServerNotReadyError, FetchError, } from './errors.js';
14
+ const opencodeLogger = createLogger(LogPrefix.OPENCODE);
15
+ const opencodeServers = new Map();
16
+ const serverRetryCount = new Map();
17
+ async function getOpenPort() {
18
+ return new Promise((resolve, reject) => {
19
+ const server = net.createServer();
20
+ server.listen(0, () => {
21
+ const address = server.address();
22
+ if (address && typeof address === 'object') {
23
+ const port = address.port;
24
+ server.close(() => {
25
+ resolve(port);
26
+ });
27
+ }
28
+ else {
29
+ reject(new Error('Failed to get port'));
30
+ }
31
+ });
32
+ server.on('error', reject);
33
+ });
34
+ }
35
+ async function waitForServer(port, maxAttempts = 30) {
36
+ const endpoint = `http://127.0.0.1:${port}/api/health`;
37
+ for (let i = 0; i < maxAttempts; i++) {
38
+ const response = await errore.tryAsync({
39
+ try: () => fetch(endpoint),
40
+ catch: (e) => new FetchError({ url: endpoint, cause: e }),
41
+ });
42
+ if (response instanceof Error) {
43
+ // Connection refused or other transient errors - continue polling
44
+ await new Promise((resolve) => setTimeout(resolve, 1000));
45
+ continue;
46
+ }
47
+ if (response.status < 500) {
48
+ return true;
49
+ }
50
+ const body = await response.text();
51
+ // Fatal errors that won't resolve with retrying
52
+ if (body.includes('BunInstallFailedError')) {
53
+ return new ServerStartError({ port, reason: body.slice(0, 200) });
54
+ }
55
+ await new Promise((resolve) => setTimeout(resolve, 1000));
56
+ }
57
+ return new ServerStartError({
58
+ port,
59
+ reason: `Server did not start after ${maxAttempts} seconds`,
60
+ });
61
+ }
62
+ export async function initializeOpencodeForDirectory(directory) {
63
+ const existing = opencodeServers.get(directory);
64
+ if (existing && !existing.process.killed) {
65
+ opencodeLogger.log(`Reusing existing server on port ${existing.port} for directory: ${directory}`);
66
+ return () => {
67
+ const entry = opencodeServers.get(directory);
68
+ if (!entry?.client) {
69
+ throw new ServerNotReadyError({ directory });
70
+ }
71
+ return entry.client;
72
+ };
73
+ }
74
+ // Verify directory exists and is accessible before spawning
75
+ const accessCheck = errore.tryFn({
76
+ try: () => {
77
+ fs.accessSync(directory, fs.constants.R_OK | fs.constants.X_OK);
78
+ },
79
+ catch: () => new DirectoryNotAccessibleError({ directory }),
80
+ });
81
+ if (accessCheck instanceof Error) {
82
+ return accessCheck;
83
+ }
84
+ const port = await getOpenPort();
85
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
86
+ const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
87
+ stdio: 'pipe',
88
+ detached: false,
89
+ cwd: directory,
90
+ env: {
91
+ ...process.env,
92
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
93
+ $schema: 'https://opencode.ai/config.json',
94
+ lsp: false,
95
+ formatter: false,
96
+ permission: {
97
+ edit: 'allow',
98
+ bash: (() => {
99
+ const whitelist = getBashWhitelist();
100
+ if (whitelist.includes('*')) {
101
+ return 'allow';
102
+ }
103
+ if (whitelist.length === 0) {
104
+ return 'deny';
105
+ }
106
+ // Convert whitelist array to permission object: { cmd: 'allow', ... }
107
+ return Object.fromEntries(whitelist.map((cmd) => {
108
+ return [cmd, 'allow'];
109
+ }));
110
+ })(),
111
+ webfetch: 'allow',
112
+ },
113
+ }),
114
+ OPENCODE_PORT: port.toString(),
115
+ },
116
+ });
117
+ // Buffer logs until we know if server started successfully
118
+ const logBuffer = [];
119
+ logBuffer.push(`Spawned opencode serve --port ${port} in ${directory} (pid: ${serverProcess.pid})`);
120
+ serverProcess.stdout?.on('data', (data) => {
121
+ logBuffer.push(`[stdout] ${data.toString().trim()}`);
122
+ });
123
+ serverProcess.stderr?.on('data', (data) => {
124
+ logBuffer.push(`[stderr] ${data.toString().trim()}`);
125
+ });
126
+ serverProcess.on('error', (error) => {
127
+ logBuffer.push(`Failed to start server on port ${port}: ${error}`);
128
+ });
129
+ serverProcess.on('exit', (code) => {
130
+ opencodeLogger.log(`Opencode server on ${directory} exited with code:`, code);
131
+ opencodeServers.delete(directory);
132
+ if (code !== 0) {
133
+ const retryCount = serverRetryCount.get(directory) || 0;
134
+ if (retryCount < 5) {
135
+ serverRetryCount.set(directory, retryCount + 1);
136
+ opencodeLogger.log(`Restarting server for directory: ${directory} (attempt ${retryCount + 1}/5)`);
137
+ initializeOpencodeForDirectory(directory).then((result) => {
138
+ if (result instanceof Error) {
139
+ opencodeLogger.error(`Failed to restart opencode server:`, result);
140
+ }
141
+ });
142
+ }
143
+ else {
144
+ opencodeLogger.error(`Server for ${directory} crashed too many times (5), not restarting`);
145
+ }
146
+ }
147
+ else {
148
+ serverRetryCount.delete(directory);
149
+ }
150
+ });
151
+ const waitResult = await waitForServer(port);
152
+ if (waitResult instanceof Error) {
153
+ // Dump buffered logs on failure
154
+ opencodeLogger.error(`Server failed to start for ${directory}:`);
155
+ for (const line of logBuffer) {
156
+ opencodeLogger.error(` ${line}`);
157
+ }
158
+ return waitResult;
159
+ }
160
+ opencodeLogger.log(`Server ready on port ${port}`);
161
+ const baseUrl = `http://127.0.0.1:${port}`;
162
+ const fetchWithTimeout = (request) => fetch(request, {
163
+ // @ts-ignore
164
+ timeout: false,
165
+ });
166
+ const client = createOpencodeClient({
167
+ baseUrl,
168
+ fetch: fetchWithTimeout,
169
+ });
170
+ const clientV2 = createOpencodeClientV2({
171
+ baseUrl,
172
+ fetch: fetchWithTimeout,
173
+ });
174
+ opencodeServers.set(directory, {
175
+ process: serverProcess,
176
+ client,
177
+ clientV2,
178
+ port,
179
+ });
180
+ return () => {
181
+ const entry = opencodeServers.get(directory);
182
+ if (!entry?.client) {
183
+ throw new ServerNotReadyError({ directory });
184
+ }
185
+ return entry.client;
186
+ };
187
+ }
188
+ export function getOpencodeServers() {
189
+ return opencodeServers;
190
+ }
191
+ export function getOpencodeServerPort(directory) {
192
+ const entry = opencodeServers.get(directory);
193
+ return entry?.port ?? null;
194
+ }
195
+ export function getOpencodeClientV2(directory) {
196
+ const entry = opencodeServers.get(directory);
197
+ return entry?.clientV2 ?? null;
198
+ }
199
+ /**
200
+ * Restart the opencode server for a directory.
201
+ * Kills the existing process and reinitializes a new one.
202
+ * Used for resolving opencode state issues, refreshing auth, plugins, etc.
203
+ */
204
+ export async function restartOpencodeServer(directory) {
205
+ const existing = opencodeServers.get(directory);
206
+ if (existing) {
207
+ opencodeLogger.log(`Killing existing server for directory: ${directory} (pid: ${existing.process.pid})`);
208
+ // Reset retry count so the exit handler doesn't auto-restart
209
+ serverRetryCount.set(directory, 999);
210
+ existing.process.kill('SIGTERM');
211
+ opencodeServers.delete(directory);
212
+ // Give the process time to fully terminate
213
+ await new Promise((resolve) => {
214
+ setTimeout(resolve, 1000);
215
+ });
216
+ }
217
+ // Reset retry count for the fresh start
218
+ serverRetryCount.delete(directory);
219
+ const result = await initializeOpencodeForDirectory(directory);
220
+ if (result instanceof Error) {
221
+ return result;
222
+ }
223
+ return true;
224
+ }
@@ -0,0 +1,128 @@
1
+ import { Events, ChannelType, } from 'discord.js';
2
+ import * as errore from 'errore';
3
+ import { getDatabase } from './database.js';
4
+ import { abortSession } from './session-handler.js';
5
+ import { createLogger, LogPrefix } from './logger.js';
6
+ import { hasRequiredPermissions, getDisundayMetadata, resolveTextChannel } from './discord-utils.js';
7
+ const reactionLogger = createLogger(LogPrefix.REACTION);
8
+ const REACTION_COMMANDS = {
9
+ '🔄': 'retry',
10
+ '❌': 'abort',
11
+ '📌': 'pin',
12
+ };
13
+ export function registerReactionHandler({ discordClient, appId, }) {
14
+ discordClient.on(Events.MessageReactionAdd, async (reaction, user) => {
15
+ try {
16
+ if (user.bot) {
17
+ return;
18
+ }
19
+ if (reaction.partial) {
20
+ const fetched = await errore.tryAsync(() => reaction.fetch());
21
+ if (fetched instanceof Error) {
22
+ return;
23
+ }
24
+ reaction = fetched;
25
+ }
26
+ const emoji = reaction.emoji.name;
27
+ if (!emoji || !(emoji in REACTION_COMMANDS)) {
28
+ return;
29
+ }
30
+ const command = REACTION_COMMANDS[emoji];
31
+ const message = reaction.message;
32
+ if (message.partial) {
33
+ const fetched = await errore.tryAsync(() => message.fetch());
34
+ if (fetched instanceof Error) {
35
+ return;
36
+ }
37
+ }
38
+ const channel = message.channel;
39
+ const isThread = [
40
+ ChannelType.PublicThread,
41
+ ChannelType.PrivateThread,
42
+ ChannelType.AnnouncementThread,
43
+ ].includes(channel.type);
44
+ if (!isThread) {
45
+ return;
46
+ }
47
+ const thread = channel;
48
+ const member = await errore.tryAsync(() => thread.guild.members.fetch(user.id));
49
+ if (member instanceof Error) {
50
+ return;
51
+ }
52
+ const hasPerms = hasRequiredPermissions(member, thread.guild);
53
+ if (!hasPerms) {
54
+ return;
55
+ }
56
+ reactionLogger.log(`[REACTION] ${command} triggered by ${user.username} in thread ${thread.id}`);
57
+ switch (command) {
58
+ case 'abort':
59
+ await handleAbortReaction(thread, appId);
60
+ break;
61
+ case 'retry':
62
+ await handleRetryReaction(thread, message, appId);
63
+ break;
64
+ case 'pin':
65
+ await handlePinReaction(message);
66
+ break;
67
+ }
68
+ await reaction.users.remove(user.id).catch(() => { });
69
+ }
70
+ catch (error) {
71
+ reactionLogger.error('[REACTION] Error handling reaction:', error);
72
+ }
73
+ });
74
+ reactionLogger.log('[REACTION] Handler registered');
75
+ }
76
+ async function handleAbortReaction(thread, appId) {
77
+ const row = getDatabase()
78
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
79
+ .get(thread.id);
80
+ if (!row?.session_id) {
81
+ return;
82
+ }
83
+ const aborted = abortSession(row.session_id);
84
+ if (aborted) {
85
+ await thread.send('⏹️ Session aborted via reaction');
86
+ reactionLogger.log(`[REACTION] Aborted session ${row.session_id}`);
87
+ }
88
+ }
89
+ async function handleRetryReaction(thread, message, appId) {
90
+ const textChannel = await resolveTextChannel(thread);
91
+ const { projectDirectory } = getDisundayMetadata(textChannel);
92
+ if (!projectDirectory) {
93
+ return;
94
+ }
95
+ const row = getDatabase()
96
+ .prepare(`SELECT session_id FROM thread_sessions WHERE thread_id = ?`)
97
+ .get(thread.id);
98
+ if (!row?.session_id) {
99
+ return;
100
+ }
101
+ const lastUserMessage = await findLastUserMessage(thread);
102
+ if (!lastUserMessage) {
103
+ await thread.send('❌ No previous message to retry');
104
+ return;
105
+ }
106
+ const { handleOpencodeSession } = await import('./session-handler.js');
107
+ await thread.send(`🔄 Retrying: "${lastUserMessage.slice(0, 50)}${lastUserMessage.length > 50 ? '...' : ''}"`);
108
+ await handleOpencodeSession({
109
+ prompt: lastUserMessage,
110
+ thread,
111
+ projectDirectory,
112
+ });
113
+ }
114
+ async function handlePinReaction(message) {
115
+ const pinResult = await errore.tryAsync(() => message.pin());
116
+ if (pinResult instanceof Error) {
117
+ reactionLogger.log(`[REACTION] Could not pin message: ${pinResult.message}`);
118
+ }
119
+ else {
120
+ reactionLogger.log(`[REACTION] Pinned message ${message.id}`);
121
+ }
122
+ }
123
+ async function findLastUserMessage(thread) {
124
+ const messages = await thread.messages.fetch({ limit: 20 });
125
+ const userMessages = messages.filter((m) => !m.author.bot && m.content?.trim());
126
+ const lastUserMsg = userMessages.first();
127
+ return lastUserMsg?.content || null;
128
+ }
@@ -0,0 +1,93 @@
1
+ import { ChannelType } from 'discord.js';
2
+ import * as errore from 'errore';
3
+ import { getPendingSchedules, updateScheduleStatus, runScheduleMigrations, getChannelDirectory, } from './database.js';
4
+ import { handleOpencodeSession } from './session-handler.js';
5
+ import { sendThreadMessage, SILENT_MESSAGE_FLAGS } from './discord-utils.js';
6
+ import { createLogger, LogPrefix } from './logger.js';
7
+ const schedulerLogger = createLogger(LogPrefix.SESSION);
8
+ let schedulerInterval = null;
9
+ export function startScheduler(client) {
10
+ runScheduleMigrations();
11
+ if (schedulerInterval) {
12
+ clearInterval(schedulerInterval);
13
+ }
14
+ schedulerInterval = setInterval(() => {
15
+ void processSchedules(client);
16
+ }, 10_000);
17
+ schedulerLogger.log('[SCHEDULER] Started (checking every 10s)');
18
+ }
19
+ export function stopScheduler() {
20
+ if (schedulerInterval) {
21
+ clearInterval(schedulerInterval);
22
+ schedulerInterval = null;
23
+ schedulerLogger.log('[SCHEDULER] Stopped');
24
+ }
25
+ }
26
+ async function processSchedules(client) {
27
+ const pendingSchedules = getPendingSchedules();
28
+ for (const schedule of pendingSchedules) {
29
+ schedulerLogger.log(`[SCHEDULER] Processing schedule #${schedule.id}`);
30
+ const result = await errore.tryAsync(async () => {
31
+ const targetChannelId = schedule.thread_id || schedule.channel_id;
32
+ const channel = await client.channels.fetch(targetChannelId);
33
+ if (!channel) {
34
+ throw new Error(`Channel ${targetChannelId} not found`);
35
+ }
36
+ const isThread = [
37
+ ChannelType.PublicThread,
38
+ ChannelType.PrivateThread,
39
+ ChannelType.AnnouncementThread,
40
+ ].includes(channel.type);
41
+ if (isThread) {
42
+ const thread = channel;
43
+ const parentId = thread.parentId;
44
+ if (!parentId) {
45
+ throw new Error('Thread has no parent channel');
46
+ }
47
+ const channelConfig = getChannelDirectory(parentId);
48
+ if (!channelConfig?.directory) {
49
+ throw new Error(`No project directory configured for channel ${parentId}`);
50
+ }
51
+ await sendThreadMessage(thread, `⏰ **Scheduled message** (from <@${schedule.created_by}>):\n${schedule.prompt}`);
52
+ await handleOpencodeSession({
53
+ prompt: schedule.prompt,
54
+ thread,
55
+ projectDirectory: channelConfig.directory,
56
+ channelId: parentId,
57
+ });
58
+ }
59
+ else if (channel.type === ChannelType.GuildText) {
60
+ const textChannel = channel;
61
+ const channelConfig = getChannelDirectory(textChannel.id);
62
+ if (!channelConfig?.directory) {
63
+ throw new Error(`No project directory configured for channel ${textChannel.id}`);
64
+ }
65
+ const starterMessage = await textChannel.send({
66
+ content: `⏰ **Scheduled** (from <@${schedule.created_by}>): ${schedule.prompt.slice(0, 100)}${schedule.prompt.length > 100 ? '...' : ''}`,
67
+ flags: SILENT_MESSAGE_FLAGS,
68
+ });
69
+ const thread = await starterMessage.startThread({
70
+ name: `Scheduled: ${schedule.prompt.slice(0, 50)}${schedule.prompt.length > 50 ? '...' : ''}`,
71
+ autoArchiveDuration: 1440,
72
+ });
73
+ await handleOpencodeSession({
74
+ prompt: schedule.prompt,
75
+ thread,
76
+ projectDirectory: channelConfig.directory,
77
+ channelId: textChannel.id,
78
+ });
79
+ }
80
+ else {
81
+ throw new Error(`Unsupported channel type: ${channel.type}`);
82
+ }
83
+ });
84
+ if (result instanceof Error) {
85
+ schedulerLogger.error(`[SCHEDULER] Failed schedule #${schedule.id}:`, result);
86
+ updateScheduleStatus(schedule.id, 'failed', result.message);
87
+ }
88
+ else {
89
+ schedulerLogger.log(`[SCHEDULER] Completed schedule #${schedule.id}`);
90
+ updateScheduleStatus(schedule.id, 'completed');
91
+ }
92
+ }
93
+ }