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.
- package/dist/ai-tool-to-genai.js +208 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/channel-management.js +96 -0
- package/dist/cli.js +1674 -0
- package/dist/commands/abort.js +89 -0
- package/dist/commands/add-project.js +117 -0
- package/dist/commands/agent.js +250 -0
- package/dist/commands/ask-question.js +219 -0
- package/dist/commands/compact.js +126 -0
- package/dist/commands/context-menu.js +171 -0
- package/dist/commands/context.js +89 -0
- package/dist/commands/cost.js +93 -0
- package/dist/commands/create-new-project.js +111 -0
- package/dist/commands/diff.js +77 -0
- package/dist/commands/export.js +100 -0
- package/dist/commands/files.js +73 -0
- package/dist/commands/fork.js +199 -0
- package/dist/commands/help.js +54 -0
- package/dist/commands/login.js +488 -0
- package/dist/commands/merge-worktree.js +165 -0
- package/dist/commands/model.js +325 -0
- package/dist/commands/permissions.js +140 -0
- package/dist/commands/ping.js +13 -0
- package/dist/commands/queue.js +133 -0
- package/dist/commands/remove-project.js +119 -0
- package/dist/commands/rename.js +70 -0
- package/dist/commands/restart-opencode-server.js +77 -0
- package/dist/commands/resume.js +276 -0
- package/dist/commands/run-config.js +79 -0
- package/dist/commands/run.js +240 -0
- package/dist/commands/schedule.js +170 -0
- package/dist/commands/session-info.js +58 -0
- package/dist/commands/session.js +191 -0
- package/dist/commands/settings.js +84 -0
- package/dist/commands/share.js +89 -0
- package/dist/commands/status.js +79 -0
- package/dist/commands/sync.js +119 -0
- package/dist/commands/theme.js +53 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +170 -0
- package/dist/commands/user-command.js +135 -0
- package/dist/commands/verbosity.js +59 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +288 -0
- package/dist/config.js +139 -0
- package/dist/database.js +585 -0
- package/dist/discord-bot.js +700 -0
- package/dist/discord-utils.js +336 -0
- package/dist/discord-utils.test.js +20 -0
- package/dist/errors.js +193 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +96 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/genai-worker-wrapper.js +109 -0
- package/dist/genai-worker.js +299 -0
- package/dist/genai.js +230 -0
- package/dist/image-utils.js +107 -0
- package/dist/interaction-handler.js +289 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +111 -0
- package/dist/markdown.js +323 -0
- package/dist/markdown.test.js +269 -0
- package/dist/message-formatting.js +447 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +226 -0
- package/dist/opencode.js +224 -0
- package/dist/reaction-handler.js +128 -0
- package/dist/scheduler.js +93 -0
- package/dist/security.js +200 -0
- package/dist/session-handler.js +1436 -0
- package/dist/system-message.js +138 -0
- package/dist/tools.js +354 -0
- package/dist/unnest-code-blocks.js +117 -0
- package/dist/unnest-code-blocks.test.js +432 -0
- package/dist/utils.js +95 -0
- package/dist/voice-handler.js +569 -0
- package/dist/voice.js +344 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +134 -0
- package/dist/xml.js +90 -0
- package/dist/xml.test.js +32 -0
- package/package.json +84 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// /compact command - Trigger context compaction (summarization) for the current session.
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { getDatabase } from '../database.js';
|
|
4
|
+
import { initializeOpencodeForDirectory, getOpencodeClientV2 } from '../opencode.js';
|
|
5
|
+
import { resolveTextChannel, getDisundayMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
6
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
7
|
+
const logger = createLogger(LogPrefix.COMPACT);
|
|
8
|
+
export async function handleCompactCommand({ command }) {
|
|
9
|
+
const channel = command.channel;
|
|
10
|
+
if (!channel) {
|
|
11
|
+
await command.reply({
|
|
12
|
+
content: 'This command can only be used in a channel',
|
|
13
|
+
ephemeral: true,
|
|
14
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const isThread = [
|
|
19
|
+
ChannelType.PublicThread,
|
|
20
|
+
ChannelType.PrivateThread,
|
|
21
|
+
ChannelType.AnnouncementThread,
|
|
22
|
+
].includes(channel.type);
|
|
23
|
+
if (!isThread) {
|
|
24
|
+
await command.reply({
|
|
25
|
+
content: 'This command can only be used in a thread with an active session',
|
|
26
|
+
ephemeral: true,
|
|
27
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
28
|
+
});
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const textChannel = await resolveTextChannel(channel);
|
|
32
|
+
const { projectDirectory: directory } = getDisundayMetadata(textChannel);
|
|
33
|
+
if (!directory) {
|
|
34
|
+
await command.reply({
|
|
35
|
+
content: 'Could not determine project directory for this channel',
|
|
36
|
+
ephemeral: true,
|
|
37
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
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 command.reply({
|
|
46
|
+
content: 'No active session in this thread',
|
|
47
|
+
ephemeral: true,
|
|
48
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const sessionId = row.session_id;
|
|
53
|
+
// Ensure server is running for this directory
|
|
54
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
55
|
+
if (getClient instanceof Error) {
|
|
56
|
+
await command.reply({
|
|
57
|
+
content: `Failed to compact: ${getClient.message}`,
|
|
58
|
+
ephemeral: true,
|
|
59
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const clientV2 = getOpencodeClientV2(directory);
|
|
64
|
+
if (!clientV2) {
|
|
65
|
+
await command.reply({
|
|
66
|
+
content: 'Failed to get OpenCode client',
|
|
67
|
+
ephemeral: true,
|
|
68
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Defer reply since compaction may take a moment
|
|
73
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS });
|
|
74
|
+
try {
|
|
75
|
+
// Get session messages to find the model from the last user message
|
|
76
|
+
const messagesResult = await clientV2.session.messages({
|
|
77
|
+
sessionID: sessionId,
|
|
78
|
+
directory,
|
|
79
|
+
});
|
|
80
|
+
if (messagesResult.error || !messagesResult.data) {
|
|
81
|
+
logger.error('[COMPACT] Failed to get messages:', messagesResult.error);
|
|
82
|
+
await command.editReply({
|
|
83
|
+
content: 'Failed to compact: Could not retrieve session messages',
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Find the last user message to get the model
|
|
88
|
+
const lastUserMessage = [...messagesResult.data]
|
|
89
|
+
.reverse()
|
|
90
|
+
.find((msg) => msg.info.role === 'user');
|
|
91
|
+
if (!lastUserMessage || lastUserMessage.info.role !== 'user') {
|
|
92
|
+
await command.editReply({
|
|
93
|
+
content: 'Failed to compact: No user message found in session',
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const { providerID, modelID } = lastUserMessage.info.model;
|
|
98
|
+
const result = await clientV2.session.summarize({
|
|
99
|
+
sessionID: sessionId,
|
|
100
|
+
directory,
|
|
101
|
+
providerID,
|
|
102
|
+
modelID,
|
|
103
|
+
auto: false,
|
|
104
|
+
});
|
|
105
|
+
if (result.error) {
|
|
106
|
+
logger.error('[COMPACT] Error:', result.error);
|
|
107
|
+
const errorMessage = 'data' in result.error && result.error.data
|
|
108
|
+
? result.error.data.message || 'Unknown error'
|
|
109
|
+
: 'Unknown error';
|
|
110
|
+
await command.editReply({
|
|
111
|
+
content: `Failed to compact: ${errorMessage}`,
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
await command.editReply({
|
|
116
|
+
content: `š¦ Session **compacted** successfully`,
|
|
117
|
+
});
|
|
118
|
+
logger.log(`Session ${sessionId} compacted by user`);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
logger.error('[COMPACT] Error:', error);
|
|
122
|
+
await command.editReply({
|
|
123
|
+
content: `Failed to compact: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { ChannelType, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
2
|
+
import * as errore from 'errore';
|
|
3
|
+
import { getDatabase } from '../database.js';
|
|
4
|
+
import { getDisundayMetadata, resolveTextChannel, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
5
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
6
|
+
import { handleOpencodeSession } from '../session-handler.js';
|
|
7
|
+
const contextMenuLogger = createLogger(LogPrefix.INTERACTION);
|
|
8
|
+
export async function handleRetryContextMenu({ interaction, appId, }) {
|
|
9
|
+
const channel = interaction.channel;
|
|
10
|
+
if (!channel) {
|
|
11
|
+
await interaction.reply({
|
|
12
|
+
content: 'ā Could not access channel',
|
|
13
|
+
ephemeral: true,
|
|
14
|
+
});
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const isThread = [
|
|
18
|
+
ChannelType.PublicThread,
|
|
19
|
+
ChannelType.PrivateThread,
|
|
20
|
+
ChannelType.AnnouncementThread,
|
|
21
|
+
].includes(channel.type);
|
|
22
|
+
if (!isThread) {
|
|
23
|
+
await interaction.reply({
|
|
24
|
+
content: 'ā This command only works in session threads',
|
|
25
|
+
ephemeral: true,
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const thread = channel;
|
|
30
|
+
const message = interaction.targetMessage;
|
|
31
|
+
if (message.author.bot) {
|
|
32
|
+
await interaction.reply({
|
|
33
|
+
content: 'ā Cannot retry a bot message. Select a user message to retry.',
|
|
34
|
+
ephemeral: true,
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const prompt = message.content;
|
|
39
|
+
if (!prompt?.trim()) {
|
|
40
|
+
await interaction.reply({
|
|
41
|
+
content: 'ā Selected message has no text content',
|
|
42
|
+
ephemeral: true,
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const textChannel = await resolveTextChannel(thread);
|
|
47
|
+
const { projectDirectory } = getDisundayMetadata(textChannel);
|
|
48
|
+
if (!projectDirectory) {
|
|
49
|
+
await interaction.reply({
|
|
50
|
+
content: 'ā Could not find project directory for this channel',
|
|
51
|
+
ephemeral: true,
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
await interaction.reply({
|
|
56
|
+
content: `š Retrying: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
|
|
57
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
58
|
+
});
|
|
59
|
+
contextMenuLogger.log(`[CONTEXT-MENU] Retry triggered for message ${message.id}`);
|
|
60
|
+
const result = await errore.tryAsync(() => {
|
|
61
|
+
return handleOpencodeSession({
|
|
62
|
+
prompt,
|
|
63
|
+
thread,
|
|
64
|
+
projectDirectory,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
if (result instanceof Error) {
|
|
68
|
+
contextMenuLogger.error('[CONTEXT-MENU] Retry failed:', result);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function handleForkContextMenu({ interaction, appId, }) {
|
|
72
|
+
const channel = interaction.channel;
|
|
73
|
+
if (!channel) {
|
|
74
|
+
await interaction.reply({
|
|
75
|
+
content: 'ā Could not access channel',
|
|
76
|
+
ephemeral: true,
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const isThread = [
|
|
81
|
+
ChannelType.PublicThread,
|
|
82
|
+
ChannelType.PrivateThread,
|
|
83
|
+
ChannelType.AnnouncementThread,
|
|
84
|
+
].includes(channel.type);
|
|
85
|
+
if (!isThread) {
|
|
86
|
+
await interaction.reply({
|
|
87
|
+
content: 'ā This command only works in session threads',
|
|
88
|
+
ephemeral: true,
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const thread = channel;
|
|
93
|
+
const message = interaction.targetMessage;
|
|
94
|
+
const textChannel = await resolveTextChannel(thread);
|
|
95
|
+
const { projectDirectory } = getDisundayMetadata(textChannel);
|
|
96
|
+
if (!projectDirectory) {
|
|
97
|
+
await interaction.reply({
|
|
98
|
+
content: 'ā Could not find project directory for this channel',
|
|
99
|
+
ephemeral: true,
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const row = getDatabase()
|
|
104
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
105
|
+
.get(thread.id);
|
|
106
|
+
if (!row?.session_id) {
|
|
107
|
+
await interaction.reply({
|
|
108
|
+
content: 'ā No session found for this thread',
|
|
109
|
+
ephemeral: true,
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const partRow = getDatabase()
|
|
114
|
+
.prepare('SELECT part_id FROM part_messages WHERE message_id = ?')
|
|
115
|
+
.get(message.id);
|
|
116
|
+
if (!partRow?.part_id) {
|
|
117
|
+
await interaction.reply({
|
|
118
|
+
content: 'ā This message is not linked to a session part. Select a message from the AI response.',
|
|
119
|
+
ephemeral: true,
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await interaction.deferReply({ ephemeral: true });
|
|
124
|
+
const { initializeOpencodeForDirectory } = await import('../opencode.js');
|
|
125
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
126
|
+
if (getClient instanceof Error) {
|
|
127
|
+
await interaction.editReply({
|
|
128
|
+
content: `ā ${getClient.message}`,
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const forkResult = await errore.tryAsync(() => {
|
|
133
|
+
return getClient().session.fork({
|
|
134
|
+
path: { id: row.session_id },
|
|
135
|
+
body: { messageID: partRow.part_id },
|
|
136
|
+
query: { directory: projectDirectory },
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
if (forkResult instanceof Error) {
|
|
140
|
+
await interaction.editReply({
|
|
141
|
+
content: `ā Fork failed: ${forkResult.message}`,
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const forkedSession = forkResult.data;
|
|
146
|
+
if (!forkedSession) {
|
|
147
|
+
await interaction.editReply({
|
|
148
|
+
content: 'ā Fork returned no session data',
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const parentTextChannel = await resolveTextChannel(thread);
|
|
153
|
+
if (!parentTextChannel) {
|
|
154
|
+
await interaction.editReply({
|
|
155
|
+
content: 'ā Could not find parent channel',
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const newThread = await parentTextChannel.threads.create({
|
|
160
|
+
name: `Fork: ${forkedSession.title || 'Untitled'}`.slice(0, 100),
|
|
161
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
162
|
+
reason: 'Session fork via context menu',
|
|
163
|
+
});
|
|
164
|
+
getDatabase()
|
|
165
|
+
.prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
|
|
166
|
+
.run(newThread.id, forkedSession.id);
|
|
167
|
+
await interaction.editReply({
|
|
168
|
+
content: `ā
Forked session to ${newThread}`,
|
|
169
|
+
});
|
|
170
|
+
contextMenuLogger.log(`[CONTEXT-MENU] Forked session ${row.session_id} to ${forkedSession.id} in thread ${newThread.id}`);
|
|
171
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { ChannelType } 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 handleContextCommand({ 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
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
50
|
+
if (getClient instanceof Error) {
|
|
51
|
+
await command.reply({
|
|
52
|
+
content: `Failed to get context: ${getClient.message}`,
|
|
53
|
+
ephemeral: true,
|
|
54
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const messagesResponse = await getClient().session.messages({
|
|
60
|
+
path: { id: row.session_id },
|
|
61
|
+
});
|
|
62
|
+
const messages = messagesResponse.data || [];
|
|
63
|
+
const messageCount = messages.length;
|
|
64
|
+
const userMessages = messages.filter((m) => m.info.role === 'user').length;
|
|
65
|
+
const assistantMessages = messages.filter((m) => m.info.role === 'assistant').length;
|
|
66
|
+
const contextMessage = [
|
|
67
|
+
'š **Context Usage**',
|
|
68
|
+
'',
|
|
69
|
+
`**Session:** \`${row.session_id.slice(0, 8)}...\``,
|
|
70
|
+
`**Total Messages:** ${messageCount}`,
|
|
71
|
+
`**User:** ${userMessages} | **Assistant:** ${assistantMessages}`,
|
|
72
|
+
'',
|
|
73
|
+
messageCount > 50
|
|
74
|
+
? 'ā ļø Consider using `/compact` to reduce context'
|
|
75
|
+
: 'ā
Context size is healthy',
|
|
76
|
+
].join('\n');
|
|
77
|
+
await command.reply({
|
|
78
|
+
content: contextMessage,
|
|
79
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
await command.reply({
|
|
84
|
+
content: `Failed to get context: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
85
|
+
ephemeral: true,
|
|
86
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { ChannelType } 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 handleCostCommand({ 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
|
+
const getClient = await initializeOpencodeForDirectory(directory);
|
|
50
|
+
if (getClient instanceof Error) {
|
|
51
|
+
await command.reply({
|
|
52
|
+
content: `Failed to get cost: ${getClient.message}`,
|
|
53
|
+
ephemeral: true,
|
|
54
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const messagesResponse = await getClient().session.messages({
|
|
60
|
+
path: { id: row.session_id },
|
|
61
|
+
});
|
|
62
|
+
const messages = messagesResponse.data || [];
|
|
63
|
+
const messageCount = messages.length;
|
|
64
|
+
const assistantMessages = messages.filter((m) => m.info.role === 'assistant').length;
|
|
65
|
+
const estimatedInputTokens = messageCount * 500;
|
|
66
|
+
const estimatedOutputTokens = assistantMessages * 1000;
|
|
67
|
+
const inputCost = (estimatedInputTokens / 1_000_000) * 3;
|
|
68
|
+
const outputCost = (estimatedOutputTokens / 1_000_000) * 15;
|
|
69
|
+
const totalCost = inputCost + outputCost;
|
|
70
|
+
const costMessage = [
|
|
71
|
+
'š° **Session Cost Estimate**',
|
|
72
|
+
'',
|
|
73
|
+
`**Messages:** ${messageCount}`,
|
|
74
|
+
`**Est. Input Tokens:** ~${estimatedInputTokens.toLocaleString()}`,
|
|
75
|
+
`**Est. Output Tokens:** ~${estimatedOutputTokens.toLocaleString()}`,
|
|
76
|
+
'',
|
|
77
|
+
`**Estimated Cost:** ~$${totalCost.toFixed(4)}`,
|
|
78
|
+
'',
|
|
79
|
+
'_Based on Claude Sonnet pricing ($3/M input, $15/M output)_',
|
|
80
|
+
].join('\n');
|
|
81
|
+
await command.reply({
|
|
82
|
+
content: costMessage,
|
|
83
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
await command.reply({
|
|
88
|
+
content: `Failed to get cost: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
89
|
+
ephemeral: true,
|
|
90
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// /create-new-project command - Create a new project folder, initialize git, and start a session.
|
|
2
|
+
// Also exports createNewProject() for reuse during onboarding (welcome channel creation).
|
|
3
|
+
import { ChannelType } from 'discord.js';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { getProjectsDir } from '../config.js';
|
|
8
|
+
import { createProjectChannels } from '../channel-management.js';
|
|
9
|
+
import { handleOpencodeSession } from '../session-handler.js';
|
|
10
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
11
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
12
|
+
const logger = createLogger(LogPrefix.CREATE_PROJECT);
|
|
13
|
+
/**
|
|
14
|
+
* Core project creation logic: creates directory, inits git, creates Discord channels.
|
|
15
|
+
* Reused by the slash command handler and by onboarding (welcome channel).
|
|
16
|
+
* Returns null if the project directory already exists.
|
|
17
|
+
*/
|
|
18
|
+
export async function createNewProject({ guild, projectName, appId, botName, }) {
|
|
19
|
+
const sanitizedName = projectName
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
22
|
+
.replace(/-+/g, '-')
|
|
23
|
+
.replace(/^-|-$/g, '')
|
|
24
|
+
.slice(0, 100);
|
|
25
|
+
if (!sanitizedName) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const projectsDir = getProjectsDir();
|
|
29
|
+
const projectDirectory = path.join(projectsDir, sanitizedName);
|
|
30
|
+
if (!fs.existsSync(projectsDir)) {
|
|
31
|
+
fs.mkdirSync(projectsDir, { recursive: true });
|
|
32
|
+
logger.log(`Created projects directory: ${projectsDir}`);
|
|
33
|
+
}
|
|
34
|
+
if (fs.existsSync(projectDirectory)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
38
|
+
logger.log(`Created project directory: ${projectDirectory}`);
|
|
39
|
+
execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
|
|
40
|
+
logger.log(`Initialized git in: ${projectDirectory}`);
|
|
41
|
+
const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
|
|
42
|
+
guild,
|
|
43
|
+
projectDirectory,
|
|
44
|
+
appId,
|
|
45
|
+
botName,
|
|
46
|
+
});
|
|
47
|
+
return { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName };
|
|
48
|
+
}
|
|
49
|
+
export async function handleCreateNewProjectCommand({ command, appId, }) {
|
|
50
|
+
await command.deferReply({ ephemeral: false });
|
|
51
|
+
const projectName = command.options.getString('name', true);
|
|
52
|
+
const guild = command.guild;
|
|
53
|
+
const channel = command.channel;
|
|
54
|
+
if (!guild) {
|
|
55
|
+
await command.editReply('This command can only be used in a guild');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
59
|
+
await command.editReply('This command can only be used in a text channel');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const result = await createNewProject({
|
|
64
|
+
guild,
|
|
65
|
+
projectName,
|
|
66
|
+
appId,
|
|
67
|
+
botName: command.client.user?.username,
|
|
68
|
+
});
|
|
69
|
+
if (!result) {
|
|
70
|
+
const sanitizedName = projectName
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
73
|
+
.replace(/-+/g, '-')
|
|
74
|
+
.replace(/^-|-$/g, '')
|
|
75
|
+
.slice(0, 100);
|
|
76
|
+
if (!sanitizedName) {
|
|
77
|
+
await command.editReply('Invalid project name');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const projectDirectory = path.join(getProjectsDir(), sanitizedName);
|
|
81
|
+
await command.editReply(`Project directory already exists: ${projectDirectory}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const { textChannelId, voiceChannelId, channelName, projectDirectory, sanitizedName } = result;
|
|
85
|
+
const textChannel = (await guild.channels.fetch(textChannelId));
|
|
86
|
+
const voiceInfo = voiceChannelId ? `\nš Voice: <#${voiceChannelId}>` : '';
|
|
87
|
+
await command.editReply(`ā
Created new project **${sanitizedName}**\nš Directory: \`${projectDirectory}\`\nš Text: <#${textChannelId}>${voiceInfo}\n_Starting session..._`);
|
|
88
|
+
const starterMessage = await textChannel.send({
|
|
89
|
+
content: `š **New project initialized**\nš \`${projectDirectory}\``,
|
|
90
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
91
|
+
});
|
|
92
|
+
const thread = await starterMessage.startThread({
|
|
93
|
+
name: `Init: ${sanitizedName}`,
|
|
94
|
+
autoArchiveDuration: 1440,
|
|
95
|
+
reason: 'New project session',
|
|
96
|
+
});
|
|
97
|
+
// Add user to thread so it appears in their sidebar
|
|
98
|
+
await thread.members.add(command.user.id);
|
|
99
|
+
await handleOpencodeSession({
|
|
100
|
+
prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
|
|
101
|
+
thread,
|
|
102
|
+
projectDirectory,
|
|
103
|
+
channelId: textChannel.id,
|
|
104
|
+
});
|
|
105
|
+
logger.log(`Created new project ${channelName} at ${projectDirectory}`);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
logger.error('[CREATE-NEW-PROJECT] Error:', error);
|
|
109
|
+
await command.editReply(`Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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 handleDiffCommand({ 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: diffOutput } = await execAsync('git diff --stat HEAD~5..HEAD 2>/dev/null || git diff --stat', {
|
|
46
|
+
cwd: directory,
|
|
47
|
+
maxBuffer: 1024 * 1024,
|
|
48
|
+
});
|
|
49
|
+
const { stdout: statusOutput } = await execAsync('git status --short', {
|
|
50
|
+
cwd: directory,
|
|
51
|
+
maxBuffer: 1024 * 1024,
|
|
52
|
+
});
|
|
53
|
+
let message = 'š **Recent Changes**\n\n';
|
|
54
|
+
if (statusOutput.trim()) {
|
|
55
|
+
message +=
|
|
56
|
+
'**Uncommitted:**\n```\n' + statusOutput.slice(0, 500) + '\n```\n\n';
|
|
57
|
+
}
|
|
58
|
+
if (diffOutput.trim()) {
|
|
59
|
+
message +=
|
|
60
|
+
'**Last 5 commits:**\n```\n' + diffOutput.slice(0, 800) + '\n```';
|
|
61
|
+
}
|
|
62
|
+
if (!statusOutput.trim() && !diffOutput.trim()) {
|
|
63
|
+
message = 'š **No recent changes**\n\nWorking directory is clean.';
|
|
64
|
+
}
|
|
65
|
+
await command.reply({
|
|
66
|
+
content: message.slice(0, 2000),
|
|
67
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
await command.reply({
|
|
72
|
+
content: `Failed to get diff: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
73
|
+
ephemeral: true,
|
|
74
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|