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,100 @@
1
+ import { ChannelType, AttachmentBuilder } from 'discord.js';
2
+ import { getDatabase } from '../database.js';
3
+ import { resolveTextChannel, getDisundayMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
4
+ import { initializeOpencodeForDirectory } from '../opencode.js';
5
+ export async function handleExportCommand({ command, }) {
6
+ const channel = command.channel;
7
+ if (!channel) {
8
+ await command.reply({
9
+ content: 'This command can only be used in a channel',
10
+ ephemeral: true,
11
+ flags: SILENT_MESSAGE_FLAGS,
12
+ });
13
+ return;
14
+ }
15
+ const isThread = [
16
+ ChannelType.PublicThread,
17
+ ChannelType.PrivateThread,
18
+ ChannelType.AnnouncementThread,
19
+ ].includes(channel.type);
20
+ if (!isThread) {
21
+ await command.reply({
22
+ content: 'This command can only be used in a thread with an active session',
23
+ ephemeral: true,
24
+ flags: SILENT_MESSAGE_FLAGS,
25
+ });
26
+ return;
27
+ }
28
+ const textChannel = await resolveTextChannel(channel);
29
+ const { projectDirectory: directory } = getDisundayMetadata(textChannel);
30
+ if (!directory) {
31
+ await command.reply({
32
+ content: 'Could not determine project directory for this channel',
33
+ ephemeral: true,
34
+ flags: SILENT_MESSAGE_FLAGS,
35
+ });
36
+ return;
37
+ }
38
+ const row = getDatabase()
39
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
40
+ .get(channel.id);
41
+ if (!row?.session_id) {
42
+ await command.reply({
43
+ content: 'No active session in this thread',
44
+ ephemeral: true,
45
+ flags: SILENT_MESSAGE_FLAGS,
46
+ });
47
+ return;
48
+ }
49
+ await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
50
+ const getClient = await initializeOpencodeForDirectory(directory);
51
+ if (getClient instanceof Error) {
52
+ await command.editReply({
53
+ content: `Failed to export: ${getClient.message}`,
54
+ });
55
+ return;
56
+ }
57
+ try {
58
+ const [sessionResponse, messagesResponse] = await Promise.all([
59
+ getClient().session.get({ path: { id: row.session_id } }),
60
+ getClient().session.messages({ path: { id: row.session_id } }),
61
+ ]);
62
+ const session = sessionResponse.data;
63
+ const messages = messagesResponse.data || [];
64
+ let markdown = `# Session Export\n\n`;
65
+ markdown += `**Session ID:** ${row.session_id}\n`;
66
+ markdown += `**Title:** ${session?.title || 'Untitled'}\n`;
67
+ markdown += `**Directory:** ${directory}\n`;
68
+ markdown += `**Messages:** ${messages.length}\n`;
69
+ markdown += `**Exported:** ${new Date().toISOString()}\n\n`;
70
+ markdown += `---\n\n`;
71
+ for (const msg of messages) {
72
+ const role = msg.info.role === 'user' ? '👤 User' : '🤖 Assistant';
73
+ markdown += `## ${role}\n\n`;
74
+ for (const part of msg.parts) {
75
+ if (part.type === 'text' && 'text' in part && part.text) {
76
+ markdown += part.text + '\n\n';
77
+ }
78
+ else if (part.type === 'reasoning' && 'text' in part && part.text) {
79
+ markdown += `_Thinking: ${part.text}_\n\n`;
80
+ }
81
+ else if (part.type === 'tool' && 'tool' in part) {
82
+ markdown += `\`Tool: ${part.tool}\`\n\n`;
83
+ }
84
+ }
85
+ markdown += `---\n\n`;
86
+ }
87
+ const attachment = new AttachmentBuilder(Buffer.from(markdown, 'utf-8'), {
88
+ name: `session-${row.session_id.slice(0, 8)}.md`,
89
+ });
90
+ await command.editReply({
91
+ content: '📄 **Session exported to Markdown**',
92
+ files: [attachment],
93
+ });
94
+ }
95
+ catch (error) {
96
+ await command.editReply({
97
+ content: `Failed to export: ${error instanceof Error ? error.message : 'Unknown error'}`,
98
+ });
99
+ }
100
+ }
@@ -0,0 +1,73 @@
1
+ import { ChannelType } from 'discord.js';
2
+ import { resolveTextChannel, getDisundayMetadata, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
3
+ import { getThreadWorktree } from '../database.js';
4
+ import { exec } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ const execAsync = promisify(exec);
7
+ export async function handleFilesCommand({ command, }) {
8
+ const channel = command.channel;
9
+ if (!channel) {
10
+ await command.reply({
11
+ content: 'This command can only be used in a channel',
12
+ ephemeral: true,
13
+ flags: SILENT_MESSAGE_FLAGS,
14
+ });
15
+ return;
16
+ }
17
+ const isThread = [
18
+ ChannelType.PublicThread,
19
+ ChannelType.PrivateThread,
20
+ ChannelType.AnnouncementThread,
21
+ ].includes(channel.type);
22
+ let directory;
23
+ if (isThread) {
24
+ const textChannel = await resolveTextChannel(channel);
25
+ const metadata = getDisundayMetadata(textChannel);
26
+ directory = metadata.projectDirectory;
27
+ const worktree = getThreadWorktree(channel.id);
28
+ if (worktree?.status === 'ready' && worktree.worktree_directory) {
29
+ directory = worktree.worktree_directory;
30
+ }
31
+ }
32
+ else {
33
+ const metadata = getDisundayMetadata(channel);
34
+ directory = metadata.projectDirectory;
35
+ }
36
+ if (!directory) {
37
+ await command.reply({
38
+ content: 'Could not determine project directory for this channel',
39
+ ephemeral: true,
40
+ flags: SILENT_MESSAGE_FLAGS,
41
+ });
42
+ return;
43
+ }
44
+ try {
45
+ const { stdout } = await execAsync('git ls-files | head -50', {
46
+ cwd: directory,
47
+ maxBuffer: 1024 * 1024,
48
+ });
49
+ const files = stdout.trim().split('\n').filter(Boolean);
50
+ const fileCount = files.length;
51
+ let message = `📁 **Project Files** (\`${directory}\`)\n\n`;
52
+ if (fileCount === 0) {
53
+ message += '_No tracked files found_';
54
+ }
55
+ else {
56
+ message += '```\n' + files.join('\n') + '\n```';
57
+ if (fileCount === 50) {
58
+ message += '\n_Showing first 50 files_';
59
+ }
60
+ }
61
+ await command.reply({
62
+ content: message.slice(0, 2000),
63
+ flags: SILENT_MESSAGE_FLAGS,
64
+ });
65
+ }
66
+ catch (error) {
67
+ await command.reply({
68
+ content: `Failed to list files: ${error instanceof Error ? error.message : 'Unknown error'}`,
69
+ ephemeral: true,
70
+ flags: SILENT_MESSAGE_FLAGS,
71
+ });
72
+ }
73
+ }
@@ -0,0 +1,199 @@
1
+ // /fork command - Fork the session from a past user message.
2
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
3
+ import { getDatabase } from '../database.js';
4
+ import { initializeOpencodeForDirectory } from '../opencode.js';
5
+ import { resolveTextChannel, getDisundayMetadata, sendThreadMessage } from '../discord-utils.js';
6
+ import { collectLastAssistantParts } from '../message-formatting.js';
7
+ import { createLogger, LogPrefix } from '../logger.js';
8
+ import * as errore from 'errore';
9
+ const sessionLogger = createLogger(LogPrefix.SESSION);
10
+ const forkLogger = createLogger(LogPrefix.FORK);
11
+ export async function handleForkCommand(interaction) {
12
+ const channel = interaction.channel;
13
+ if (!channel) {
14
+ await interaction.reply({
15
+ content: 'This command can only be used in a channel',
16
+ ephemeral: true,
17
+ });
18
+ return;
19
+ }
20
+ const isThread = [
21
+ ChannelType.PublicThread,
22
+ ChannelType.PrivateThread,
23
+ ChannelType.AnnouncementThread,
24
+ ].includes(channel.type);
25
+ if (!isThread) {
26
+ await interaction.reply({
27
+ content: 'This command can only be used in a thread with an active session',
28
+ ephemeral: true,
29
+ });
30
+ return;
31
+ }
32
+ const textChannel = await resolveTextChannel(channel);
33
+ const { projectDirectory: directory } = getDisundayMetadata(textChannel);
34
+ if (!directory) {
35
+ await interaction.reply({
36
+ content: 'Could not determine project directory for this channel',
37
+ ephemeral: true,
38
+ });
39
+ return;
40
+ }
41
+ const row = getDatabase()
42
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
43
+ .get(channel.id);
44
+ if (!row?.session_id) {
45
+ await interaction.reply({
46
+ content: 'No active session in this thread',
47
+ ephemeral: true,
48
+ });
49
+ return;
50
+ }
51
+ // Defer reply before API calls to avoid 3-second timeout
52
+ await interaction.deferReply({ ephemeral: true });
53
+ const sessionId = row.session_id;
54
+ const getClient = await initializeOpencodeForDirectory(directory);
55
+ if (getClient instanceof Error) {
56
+ await interaction.editReply({
57
+ content: `Failed to load messages: ${getClient.message}`,
58
+ });
59
+ return;
60
+ }
61
+ try {
62
+ const messagesResponse = await getClient().session.messages({
63
+ path: { id: sessionId },
64
+ });
65
+ if (!messagesResponse.data) {
66
+ await interaction.editReply({
67
+ content: 'Failed to fetch session messages',
68
+ });
69
+ return;
70
+ }
71
+ const userMessages = messagesResponse.data.filter((m) => m.info.role === 'user');
72
+ if (userMessages.length === 0) {
73
+ await interaction.editReply({
74
+ content: 'No user messages found in this session',
75
+ });
76
+ return;
77
+ }
78
+ const recentMessages = userMessages.slice(-25);
79
+ const options = recentMessages.map((m, index) => {
80
+ const textPart = m.parts.find((p) => p.type === 'text');
81
+ const preview = textPart?.text?.slice(0, 80) || '(no text)';
82
+ const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`;
83
+ return {
84
+ label: label.slice(0, 100),
85
+ value: m.info.id,
86
+ description: new Date(m.info.time.created).toLocaleString().slice(0, 50),
87
+ };
88
+ });
89
+ const encodedDir = Buffer.from(directory).toString('base64');
90
+ const selectMenu = new StringSelectMenuBuilder()
91
+ .setCustomId(`fork_select:${sessionId}:${encodedDir}`)
92
+ .setPlaceholder('Select a message to fork from')
93
+ .addOptions(options);
94
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
95
+ await interaction.editReply({
96
+ content: '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
97
+ components: [actionRow],
98
+ });
99
+ }
100
+ catch (error) {
101
+ forkLogger.error('Error loading messages:', error);
102
+ await interaction.editReply({
103
+ content: `Failed to load messages: ${error instanceof Error ? error.message : 'Unknown error'}`,
104
+ });
105
+ }
106
+ }
107
+ export async function handleForkSelectMenu(interaction) {
108
+ const customId = interaction.customId;
109
+ if (!customId.startsWith('fork_select:')) {
110
+ return;
111
+ }
112
+ const [, sessionId, encodedDir] = customId.split(':');
113
+ if (!sessionId || !encodedDir) {
114
+ await interaction.reply({
115
+ content: 'Invalid selection data',
116
+ ephemeral: true,
117
+ });
118
+ return;
119
+ }
120
+ const directory = Buffer.from(encodedDir, 'base64').toString('utf-8');
121
+ const selectedMessageId = interaction.values[0];
122
+ if (!selectedMessageId) {
123
+ await interaction.reply({
124
+ content: 'No message selected',
125
+ ephemeral: true,
126
+ });
127
+ return;
128
+ }
129
+ await interaction.deferReply({ ephemeral: false });
130
+ const getClient = await initializeOpencodeForDirectory(directory);
131
+ if (getClient instanceof Error) {
132
+ await interaction.editReply(`Failed to fork session: ${getClient.message}`);
133
+ return;
134
+ }
135
+ try {
136
+ const forkResponse = await getClient().session.fork({
137
+ path: { id: sessionId },
138
+ body: { messageID: selectedMessageId },
139
+ });
140
+ if (!forkResponse.data) {
141
+ await interaction.editReply('Failed to fork session');
142
+ return;
143
+ }
144
+ const forkedSession = forkResponse.data;
145
+ const parentChannel = interaction.channel;
146
+ if (!parentChannel ||
147
+ ![
148
+ ChannelType.PublicThread,
149
+ ChannelType.PrivateThread,
150
+ ChannelType.AnnouncementThread,
151
+ ].includes(parentChannel.type)) {
152
+ await interaction.editReply('Could not access parent channel');
153
+ return;
154
+ }
155
+ const textChannel = await resolveTextChannel(parentChannel);
156
+ if (!textChannel) {
157
+ await interaction.editReply('Could not resolve parent text channel');
158
+ return;
159
+ }
160
+ const thread = await textChannel.threads.create({
161
+ name: `Fork: ${forkedSession.title}`.slice(0, 100),
162
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
163
+ reason: `Forked from session ${sessionId}`,
164
+ });
165
+ // Add user to thread so it appears in their sidebar
166
+ await thread.members.add(interaction.user.id);
167
+ getDatabase()
168
+ .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
169
+ .run(thread.id, forkedSession.id);
170
+ sessionLogger.log(`Created forked session ${forkedSession.id} in thread ${thread.id}`);
171
+ await sendThreadMessage(thread, `**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}\``);
172
+ // Fetch and display the last assistant messages from the forked session
173
+ const messagesResponse = await getClient().session.messages({
174
+ path: { id: forkedSession.id },
175
+ });
176
+ if (messagesResponse.data) {
177
+ const { partIds, content } = collectLastAssistantParts({
178
+ messages: messagesResponse.data,
179
+ });
180
+ if (content.trim()) {
181
+ const discordMessage = await sendThreadMessage(thread, content);
182
+ // Store part-message mappings for future reference
183
+ const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
184
+ const transaction = getDatabase().transaction((ids) => {
185
+ for (const partId of ids) {
186
+ stmt.run(partId, discordMessage.id, thread.id);
187
+ }
188
+ });
189
+ transaction(partIds);
190
+ }
191
+ }
192
+ await sendThreadMessage(thread, `You can now continue the conversation from this point.`);
193
+ await interaction.editReply(`Session forked! Continue in ${thread.toString()}`);
194
+ }
195
+ catch (error) {
196
+ forkLogger.error('Error forking session:', error);
197
+ await interaction.editReply(`Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`);
198
+ }
199
+ }
@@ -0,0 +1,54 @@
1
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
2
+ export async function handleHelpCommand({ command, }) {
3
+ const helpMessage = `# Disunday Commands
4
+
5
+ ## Session Management
6
+ - \`/new-session\` - Start a new OpenCode session
7
+ - \`/resume\` - Resume a previous session
8
+ - \`/abort\` or \`/stop\` - Stop the current session
9
+ - \`/fork\` - Fork session from a previous message
10
+ - \`/compact\` - Summarize conversation history
11
+
12
+ ## Session Info
13
+ - \`/status\` - Check bot and session status
14
+ - \`/session-info\` - Get session ID and terminal command
15
+ - \`/context\` - Show context window usage
16
+ - \`/share\` - Generate a public share URL
17
+ - \`/export\` - Export session to markdown
18
+
19
+ ## Code & Files
20
+ - \`/diff\` - Show recent file changes
21
+ - \`/files\` - List project files
22
+ - \`/run\` - Execute a terminal command
23
+ - \`/undo\` / \`/redo\` - Undo/redo changes
24
+
25
+ ## Worktrees
26
+ - \`/new-worktree\` - Create a git worktree
27
+ - \`/merge-worktree\` - Merge worktree branch
28
+ - \`/toggle-worktrees\` - Toggle auto-worktree
29
+
30
+ ## Configuration
31
+ - \`/model\` - Change AI model
32
+ - \`/agent\` - Change agent
33
+ - \`/login\` - Authenticate with provider
34
+ - \`/verbosity\` - Set output detail level
35
+ - \`/run-config\` - Configure /run notifications
36
+
37
+ ## Project Management
38
+ - \`/add-project\` - Add project channels
39
+ - \`/remove-project\` - Remove project channels
40
+ - \`/create-new-project\` - Create new project
41
+
42
+ ## Utilities
43
+ - \`/ping\` - Check connection latency
44
+ - \`/help\` - Show this help message
45
+ - \`/queue\` - Queue a follow-up message
46
+ - \`/clear-queue\` - Clear message queue
47
+ - \`/sync\` - Sync terminal activity to Discord
48
+ - \`/rename\` - Rename current session`;
49
+ await command.reply({
50
+ content: helpMessage,
51
+ ephemeral: true,
52
+ flags: SILENT_MESSAGE_FLAGS,
53
+ });
54
+ }