kimaki 0.4.24 → 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 (86) hide show
  1. package/bin.js +6 -1
  2. package/dist/acp-client.test.js +149 -0
  3. package/dist/ai-tool-to-genai.js +3 -0
  4. package/dist/channel-management.js +14 -9
  5. package/dist/cli.js +148 -17
  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 +54 -0
  22. package/dist/discord-bot.js +35 -32
  23. package/dist/discord-utils.js +81 -15
  24. package/dist/format-tables.js +3 -0
  25. package/dist/genai-worker-wrapper.js +3 -0
  26. package/dist/genai-worker.js +3 -0
  27. package/dist/genai.js +3 -0
  28. package/dist/interaction-handler.js +89 -695
  29. package/dist/logger.js +46 -5
  30. package/dist/markdown.js +107 -0
  31. package/dist/markdown.test.js +31 -1
  32. package/dist/message-formatting.js +113 -28
  33. package/dist/message-formatting.test.js +73 -0
  34. package/dist/opencode.js +73 -16
  35. package/dist/session-handler.js +176 -63
  36. package/dist/system-message.js +7 -38
  37. package/dist/tools.js +3 -0
  38. package/dist/utils.js +3 -0
  39. package/dist/voice-handler.js +21 -8
  40. package/dist/voice.js +31 -12
  41. package/dist/worker-types.js +3 -0
  42. package/dist/xml.js +3 -0
  43. package/package.json +3 -3
  44. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  45. package/src/__snapshots__/compact-session-context.md +47 -0
  46. package/src/ai-tool-to-genai.ts +4 -0
  47. package/src/channel-management.ts +24 -8
  48. package/src/cli.ts +163 -18
  49. package/src/commands/abort.ts +94 -0
  50. package/src/commands/add-project.ts +139 -0
  51. package/src/commands/agent.ts +201 -0
  52. package/src/commands/ask-question.ts +276 -0
  53. package/src/commands/create-new-project.ts +111 -0
  54. package/src/{fork.ts → commands/fork.ts} +40 -7
  55. package/src/{model-command.ts → commands/model.ts} +31 -9
  56. package/src/commands/permissions.ts +146 -0
  57. package/src/commands/queue.ts +181 -0
  58. package/src/commands/resume.ts +230 -0
  59. package/src/commands/session.ts +184 -0
  60. package/src/commands/share.ts +96 -0
  61. package/src/commands/types.ts +25 -0
  62. package/src/commands/undo-redo.ts +213 -0
  63. package/src/commands/user-command.ts +178 -0
  64. package/src/database.ts +65 -0
  65. package/src/discord-bot.ts +40 -33
  66. package/src/discord-utils.ts +88 -14
  67. package/src/format-tables.ts +4 -0
  68. package/src/genai-worker-wrapper.ts +4 -0
  69. package/src/genai-worker.ts +4 -0
  70. package/src/genai.ts +4 -0
  71. package/src/interaction-handler.ts +111 -924
  72. package/src/logger.ts +51 -10
  73. package/src/markdown.test.ts +45 -1
  74. package/src/markdown.ts +136 -0
  75. package/src/message-formatting.test.ts +81 -0
  76. package/src/message-formatting.ts +143 -30
  77. package/src/opencode.ts +84 -21
  78. package/src/session-handler.ts +248 -91
  79. package/src/system-message.ts +8 -38
  80. package/src/tools.ts +4 -0
  81. package/src/utils.ts +4 -0
  82. package/src/voice-handler.ts +24 -9
  83. package/src/voice.ts +36 -13
  84. package/src/worker-types.ts +4 -0
  85. package/src/xml.ts +4 -0
  86. package/README.md +0 -48
@@ -0,0 +1,152 @@
1
+ // /agent command - Set the preferred agent for this channel or session.
2
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, } from 'discord.js';
3
+ import crypto from 'node:crypto';
4
+ import { getDatabase, setChannelAgent, setSessionAgent, runModelMigrations } from '../database.js';
5
+ import { initializeOpencodeForDirectory } from '../opencode.js';
6
+ import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
+ import { createLogger } from '../logger.js';
8
+ const agentLogger = createLogger('AGENT');
9
+ const pendingAgentContexts = new Map();
10
+ export async function handleAgentCommand({ interaction, appId, }) {
11
+ await interaction.deferReply({ ephemeral: true });
12
+ runModelMigrations();
13
+ const channel = interaction.channel;
14
+ if (!channel) {
15
+ await interaction.editReply({ content: 'This command can only be used in a channel' });
16
+ return;
17
+ }
18
+ const isThread = [
19
+ ChannelType.PublicThread,
20
+ ChannelType.PrivateThread,
21
+ ChannelType.AnnouncementThread,
22
+ ].includes(channel.type);
23
+ let projectDirectory;
24
+ let channelAppId;
25
+ let targetChannelId;
26
+ let sessionId;
27
+ if (isThread) {
28
+ const thread = channel;
29
+ const textChannel = await resolveTextChannel(thread);
30
+ const metadata = getKimakiMetadata(textChannel);
31
+ projectDirectory = metadata.projectDirectory;
32
+ channelAppId = metadata.channelAppId;
33
+ targetChannelId = textChannel?.id || channel.id;
34
+ const row = getDatabase()
35
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
36
+ .get(thread.id);
37
+ sessionId = row?.session_id;
38
+ }
39
+ else if (channel.type === ChannelType.GuildText) {
40
+ const textChannel = channel;
41
+ const metadata = getKimakiMetadata(textChannel);
42
+ projectDirectory = metadata.projectDirectory;
43
+ channelAppId = metadata.channelAppId;
44
+ targetChannelId = channel.id;
45
+ }
46
+ else {
47
+ await interaction.editReply({ content: 'This command can only be used in text channels or threads' });
48
+ return;
49
+ }
50
+ if (channelAppId && channelAppId !== appId) {
51
+ await interaction.editReply({ content: 'This channel is not configured for this bot' });
52
+ return;
53
+ }
54
+ if (!projectDirectory) {
55
+ await interaction.editReply({ content: 'This channel is not configured with a project directory' });
56
+ return;
57
+ }
58
+ try {
59
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
60
+ const agentsResponse = await getClient().app.agents({
61
+ query: { directory: projectDirectory },
62
+ });
63
+ if (!agentsResponse.data || agentsResponse.data.length === 0) {
64
+ await interaction.editReply({ content: 'No agents available' });
65
+ return;
66
+ }
67
+ const agents = agentsResponse.data
68
+ .filter((a) => a.mode === 'primary' || a.mode === 'all')
69
+ .slice(0, 25);
70
+ if (agents.length === 0) {
71
+ await interaction.editReply({ content: 'No primary agents available' });
72
+ return;
73
+ }
74
+ const contextHash = crypto.randomBytes(8).toString('hex');
75
+ pendingAgentContexts.set(contextHash, {
76
+ dir: projectDirectory,
77
+ channelId: targetChannelId,
78
+ sessionId,
79
+ isThread,
80
+ });
81
+ const options = agents.map((agent) => ({
82
+ label: agent.name.slice(0, 100),
83
+ value: agent.name,
84
+ description: (agent.description || `${agent.mode} agent`).slice(0, 100),
85
+ }));
86
+ const selectMenu = new StringSelectMenuBuilder()
87
+ .setCustomId(`agent_select:${contextHash}`)
88
+ .setPlaceholder('Select an agent')
89
+ .addOptions(options);
90
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
91
+ await interaction.editReply({
92
+ content: '**Set Agent Preference**\nSelect an agent:',
93
+ components: [actionRow],
94
+ });
95
+ }
96
+ catch (error) {
97
+ agentLogger.error('Error loading agents:', error);
98
+ await interaction.editReply({
99
+ content: `Failed to load agents: ${error instanceof Error ? error.message : 'Unknown error'}`,
100
+ });
101
+ }
102
+ }
103
+ export async function handleAgentSelectMenu(interaction) {
104
+ const customId = interaction.customId;
105
+ if (!customId.startsWith('agent_select:')) {
106
+ return;
107
+ }
108
+ await interaction.deferUpdate();
109
+ const contextHash = customId.replace('agent_select:', '');
110
+ const context = pendingAgentContexts.get(contextHash);
111
+ if (!context) {
112
+ await interaction.editReply({
113
+ content: 'Selection expired. Please run /agent again.',
114
+ components: [],
115
+ });
116
+ return;
117
+ }
118
+ const selectedAgent = interaction.values[0];
119
+ if (!selectedAgent) {
120
+ await interaction.editReply({
121
+ content: 'No agent selected',
122
+ components: [],
123
+ });
124
+ return;
125
+ }
126
+ try {
127
+ if (context.isThread && context.sessionId) {
128
+ setSessionAgent(context.sessionId, selectedAgent);
129
+ agentLogger.log(`Set agent ${selectedAgent} for session ${context.sessionId}`);
130
+ await interaction.editReply({
131
+ content: `Agent preference set for this session: **${selectedAgent}**`,
132
+ components: [],
133
+ });
134
+ }
135
+ else {
136
+ setChannelAgent(context.channelId, selectedAgent);
137
+ agentLogger.log(`Set agent ${selectedAgent} for channel ${context.channelId}`);
138
+ await interaction.editReply({
139
+ content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
140
+ components: [],
141
+ });
142
+ }
143
+ pendingAgentContexts.delete(contextHash);
144
+ }
145
+ catch (error) {
146
+ agentLogger.error('Error saving agent preference:', error);
147
+ await interaction.editReply({
148
+ content: `Failed to save agent preference: ${error instanceof Error ? error.message : 'Unknown error'}`,
149
+ components: [],
150
+ });
151
+ }
152
+ }
@@ -0,0 +1,183 @@
1
+ // AskUserQuestion tool handler - Shows Discord dropdowns for AI questions.
2
+ // When the AI uses the AskUserQuestion tool, this module renders dropdowns
3
+ // for each question and collects user responses.
4
+ import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder, } from 'discord.js';
5
+ import crypto from 'node:crypto';
6
+ import { sendThreadMessage } from '../discord-utils.js';
7
+ import { getOpencodeServerPort } from '../opencode.js';
8
+ import { createLogger } from '../logger.js';
9
+ const logger = createLogger('ASK_QUESTION');
10
+ // Store pending question contexts by hash
11
+ export const pendingQuestionContexts = new Map();
12
+ /**
13
+ * Show dropdown menus for question tool input.
14
+ * Sends one message per question with the dropdown directly under the question text.
15
+ */
16
+ export async function showAskUserQuestionDropdowns({ thread, sessionId, directory, requestId, input, }) {
17
+ const contextHash = crypto.randomBytes(8).toString('hex');
18
+ const context = {
19
+ sessionId,
20
+ directory,
21
+ thread,
22
+ requestId,
23
+ questions: input.questions,
24
+ answers: {},
25
+ totalQuestions: input.questions.length,
26
+ answeredCount: 0,
27
+ contextHash,
28
+ };
29
+ pendingQuestionContexts.set(contextHash, context);
30
+ // Send one message per question with its dropdown directly underneath
31
+ for (let i = 0; i < input.questions.length; i++) {
32
+ const q = input.questions[i];
33
+ // Map options to Discord select menu options
34
+ // Discord max: 25 options per select menu
35
+ const options = [
36
+ ...q.options.slice(0, 24).map((opt, optIdx) => ({
37
+ label: opt.label.slice(0, 100),
38
+ value: `${optIdx}`,
39
+ description: opt.description.slice(0, 100),
40
+ })),
41
+ {
42
+ label: 'Other',
43
+ value: 'other',
44
+ description: 'Provide a custom answer in chat',
45
+ },
46
+ ];
47
+ const selectMenu = new StringSelectMenuBuilder()
48
+ .setCustomId(`ask_question:${contextHash}:${i}`)
49
+ .setPlaceholder(`Select an option`)
50
+ .addOptions(options);
51
+ // Enable multi-select if the question supports it
52
+ if (q.multiple) {
53
+ selectMenu.setMinValues(1);
54
+ selectMenu.setMaxValues(options.length);
55
+ }
56
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
57
+ await thread.send({
58
+ content: `**${q.header}**\n${q.question}`,
59
+ components: [actionRow],
60
+ });
61
+ }
62
+ logger.log(`Showed ${input.questions.length} question dropdown(s) for session ${sessionId}`);
63
+ }
64
+ /**
65
+ * Handle dropdown selection for AskUserQuestion.
66
+ */
67
+ export async function handleAskQuestionSelectMenu(interaction) {
68
+ const customId = interaction.customId;
69
+ if (!customId.startsWith('ask_question:')) {
70
+ return;
71
+ }
72
+ const parts = customId.split(':');
73
+ const contextHash = parts[1];
74
+ const questionIndex = parseInt(parts[2], 10);
75
+ if (!contextHash) {
76
+ await interaction.reply({
77
+ content: 'Invalid selection.',
78
+ ephemeral: true,
79
+ });
80
+ return;
81
+ }
82
+ const context = pendingQuestionContexts.get(contextHash);
83
+ if (!context) {
84
+ await interaction.reply({
85
+ content: 'This question has expired. Please ask the AI again.',
86
+ ephemeral: true,
87
+ });
88
+ return;
89
+ }
90
+ await interaction.deferUpdate();
91
+ const selectedValues = interaction.values;
92
+ const question = context.questions[questionIndex];
93
+ if (!question) {
94
+ logger.error(`Question index ${questionIndex} not found in context`);
95
+ return;
96
+ }
97
+ // Check if "other" was selected
98
+ if (selectedValues.includes('other')) {
99
+ // User wants to provide custom answer
100
+ // For now, mark as "Other" - they can type in chat
101
+ context.answers[questionIndex] = ['Other (please type your answer in chat)'];
102
+ }
103
+ else {
104
+ // Map value indices back to option labels
105
+ context.answers[questionIndex] = selectedValues.map((v) => {
106
+ const optIdx = parseInt(v, 10);
107
+ return question.options[optIdx]?.label || `Option ${optIdx + 1}`;
108
+ });
109
+ }
110
+ context.answeredCount++;
111
+ // Update this question's message: show answer and remove dropdown
112
+ const answeredText = context.answers[questionIndex].join(', ');
113
+ await interaction.editReply({
114
+ content: `**${question.header}**\n${question.question}\n✓ _${answeredText}_`,
115
+ components: [], // Remove the dropdown
116
+ });
117
+ // Check if all questions are answered
118
+ if (context.answeredCount >= context.totalQuestions) {
119
+ // All questions answered - send result back to session
120
+ await submitQuestionAnswers(context);
121
+ pendingQuestionContexts.delete(contextHash);
122
+ }
123
+ }
124
+ /**
125
+ * Submit all collected answers back to the OpenCode session.
126
+ * Uses the question.reply API to provide answers to the waiting tool.
127
+ */
128
+ async function submitQuestionAnswers(context) {
129
+ try {
130
+ // Build answers array: each element is an array of selected labels for that question
131
+ const answersPayload = context.questions.map((_, i) => {
132
+ return context.answers[i] || [];
133
+ });
134
+ // Reply to the question using direct HTTP call to OpenCode API
135
+ // (v1 SDK doesn't have question.reply, so we call it directly)
136
+ const port = getOpencodeServerPort(context.directory);
137
+ if (!port) {
138
+ throw new Error('OpenCode server not found for directory');
139
+ }
140
+ const response = await fetch(`http://127.0.0.1:${port}/question/${context.requestId}/reply`, {
141
+ method: 'POST',
142
+ headers: { 'Content-Type': 'application/json' },
143
+ body: JSON.stringify({ answers: answersPayload }),
144
+ });
145
+ if (!response.ok) {
146
+ const text = await response.text();
147
+ throw new Error(`Failed to reply to question: ${response.status} ${text}`);
148
+ }
149
+ logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
150
+ }
151
+ catch (error) {
152
+ logger.error('Failed to submit answers:', error);
153
+ await sendThreadMessage(context.thread, `✗ Failed to submit answers: ${error instanceof Error ? error.message : 'Unknown error'}`);
154
+ }
155
+ }
156
+ /**
157
+ * Check if a tool part is an AskUserQuestion tool.
158
+ * Returns the parsed input if valid, null otherwise.
159
+ */
160
+ export function parseAskUserQuestionTool(part) {
161
+ if (part.type !== 'tool') {
162
+ return null;
163
+ }
164
+ // Check for the tool name (case-insensitive)
165
+ const toolName = part.tool?.toLowerCase();
166
+ if (toolName !== 'question') {
167
+ return null;
168
+ }
169
+ const input = part.state?.input;
170
+ if (!input?.questions || !Array.isArray(input.questions) || input.questions.length === 0) {
171
+ return null;
172
+ }
173
+ // Validate structure
174
+ for (const q of input.questions) {
175
+ if (typeof q.question !== 'string' ||
176
+ typeof q.header !== 'string' ||
177
+ !Array.isArray(q.options) ||
178
+ q.options.length < 2) {
179
+ return null;
180
+ }
181
+ }
182
+ return input;
183
+ }
@@ -0,0 +1,78 @@
1
+ // /create-new-project command - Create a new project folder, initialize git, and start a session.
2
+ import { ChannelType } from 'discord.js';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { createProjectChannels } from '../channel-management.js';
7
+ import { handleOpencodeSession } from '../session-handler.js';
8
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
9
+ import { createLogger } from '../logger.js';
10
+ const logger = createLogger('CREATE-NEW-PROJECT');
11
+ export async function handleCreateNewProjectCommand({ command, appId, }) {
12
+ await command.deferReply({ ephemeral: false });
13
+ const projectName = command.options.getString('name', true);
14
+ const guild = command.guild;
15
+ const channel = command.channel;
16
+ if (!guild) {
17
+ await command.editReply('This command can only be used in a guild');
18
+ return;
19
+ }
20
+ if (!channel || channel.type !== ChannelType.GuildText) {
21
+ await command.editReply('This command can only be used in a text channel');
22
+ return;
23
+ }
24
+ const sanitizedName = projectName
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9-]/g, '-')
27
+ .replace(/-+/g, '-')
28
+ .replace(/^-|-$/g, '')
29
+ .slice(0, 100);
30
+ if (!sanitizedName) {
31
+ await command.editReply('Invalid project name');
32
+ return;
33
+ }
34
+ const kimakiDir = path.join(os.homedir(), 'kimaki');
35
+ const projectDirectory = path.join(kimakiDir, sanitizedName);
36
+ try {
37
+ if (!fs.existsSync(kimakiDir)) {
38
+ fs.mkdirSync(kimakiDir, { recursive: true });
39
+ logger.log(`Created kimaki directory: ${kimakiDir}`);
40
+ }
41
+ if (fs.existsSync(projectDirectory)) {
42
+ await command.editReply(`Project directory already exists: ${projectDirectory}`);
43
+ return;
44
+ }
45
+ fs.mkdirSync(projectDirectory, { recursive: true });
46
+ logger.log(`Created project directory: ${projectDirectory}`);
47
+ const { execSync } = await import('node:child_process');
48
+ execSync('git init', { cwd: projectDirectory, stdio: 'pipe' });
49
+ logger.log(`Initialized git in: ${projectDirectory}`);
50
+ const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
51
+ guild,
52
+ projectDirectory,
53
+ appId,
54
+ });
55
+ const textChannel = (await guild.channels.fetch(textChannelId));
56
+ await command.editReply(`✅ Created new project **${sanitizedName}**\n📁 Directory: \`${projectDirectory}\`\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n\n_Starting session..._`);
57
+ const starterMessage = await textChannel.send({
58
+ content: `🚀 **New project initialized**\n📁 \`${projectDirectory}\``,
59
+ flags: SILENT_MESSAGE_FLAGS,
60
+ });
61
+ const thread = await starterMessage.startThread({
62
+ name: `Init: ${sanitizedName}`,
63
+ autoArchiveDuration: 1440,
64
+ reason: 'New project session',
65
+ });
66
+ await handleOpencodeSession({
67
+ prompt: 'The project was just initialized. Say hi and ask what the user wants to build.',
68
+ thread,
69
+ projectDirectory,
70
+ channelId: textChannel.id,
71
+ });
72
+ logger.log(`Created new project ${channelName} at ${projectDirectory}`);
73
+ }
74
+ catch (error) {
75
+ logger.error('[CREATE-NEW-PROJECT] Error:', error);
76
+ await command.editReply(`Failed to create new project: ${error instanceof Error ? error.message : 'Unknown error'}`);
77
+ }
78
+ }
@@ -0,0 +1,186 @@
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, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js';
6
+ import { collectLastAssistantParts } from '../message-formatting.js';
7
+ import { createLogger } from '../logger.js';
8
+ const sessionLogger = createLogger('SESSION');
9
+ const forkLogger = createLogger('FORK');
10
+ export async function handleForkCommand(interaction) {
11
+ const channel = interaction.channel;
12
+ if (!channel) {
13
+ await interaction.reply({
14
+ content: 'This command can only be used in a channel',
15
+ ephemeral: true,
16
+ });
17
+ return;
18
+ }
19
+ const isThread = [
20
+ ChannelType.PublicThread,
21
+ ChannelType.PrivateThread,
22
+ ChannelType.AnnouncementThread,
23
+ ].includes(channel.type);
24
+ if (!isThread) {
25
+ await interaction.reply({
26
+ content: 'This command can only be used in a thread with an active session',
27
+ ephemeral: true,
28
+ });
29
+ return;
30
+ }
31
+ const textChannel = await resolveTextChannel(channel);
32
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel);
33
+ if (!directory) {
34
+ await interaction.reply({
35
+ content: 'Could not determine project directory for this channel',
36
+ ephemeral: true,
37
+ });
38
+ return;
39
+ }
40
+ const row = getDatabase()
41
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
42
+ .get(channel.id);
43
+ if (!row?.session_id) {
44
+ await interaction.reply({
45
+ content: 'No active session in this thread',
46
+ ephemeral: true,
47
+ });
48
+ return;
49
+ }
50
+ // Defer reply before API calls to avoid 3-second timeout
51
+ await interaction.deferReply({ ephemeral: true });
52
+ const sessionId = row.session_id;
53
+ try {
54
+ const getClient = await initializeOpencodeForDirectory(directory);
55
+ const messagesResponse = await getClient().session.messages({
56
+ path: { id: sessionId },
57
+ });
58
+ if (!messagesResponse.data) {
59
+ await interaction.editReply({
60
+ content: 'Failed to fetch session messages',
61
+ });
62
+ return;
63
+ }
64
+ const userMessages = messagesResponse.data.filter((m) => m.info.role === 'user');
65
+ if (userMessages.length === 0) {
66
+ await interaction.editReply({
67
+ content: 'No user messages found in this session',
68
+ });
69
+ return;
70
+ }
71
+ const recentMessages = userMessages.slice(-25);
72
+ const options = recentMessages.map((m, index) => {
73
+ const textPart = m.parts.find((p) => p.type === 'text');
74
+ const preview = textPart?.text?.slice(0, 80) || '(no text)';
75
+ const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`;
76
+ return {
77
+ label: label.slice(0, 100),
78
+ value: m.info.id,
79
+ description: new Date(m.info.time.created).toLocaleString().slice(0, 50),
80
+ };
81
+ });
82
+ const encodedDir = Buffer.from(directory).toString('base64');
83
+ const selectMenu = new StringSelectMenuBuilder()
84
+ .setCustomId(`fork_select:${sessionId}:${encodedDir}`)
85
+ .setPlaceholder('Select a message to fork from')
86
+ .addOptions(options);
87
+ const actionRow = new ActionRowBuilder()
88
+ .addComponents(selectMenu);
89
+ await interaction.editReply({
90
+ content: '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
91
+ components: [actionRow],
92
+ });
93
+ }
94
+ catch (error) {
95
+ forkLogger.error('Error loading messages:', error);
96
+ await interaction.editReply({
97
+ content: `Failed to load messages: ${error instanceof Error ? error.message : 'Unknown error'}`,
98
+ });
99
+ }
100
+ }
101
+ export async function handleForkSelectMenu(interaction) {
102
+ const customId = interaction.customId;
103
+ if (!customId.startsWith('fork_select:')) {
104
+ return;
105
+ }
106
+ const [, sessionId, encodedDir] = customId.split(':');
107
+ if (!sessionId || !encodedDir) {
108
+ await interaction.reply({
109
+ content: 'Invalid selection data',
110
+ ephemeral: true,
111
+ });
112
+ return;
113
+ }
114
+ const directory = Buffer.from(encodedDir, 'base64').toString('utf-8');
115
+ const selectedMessageId = interaction.values[0];
116
+ if (!selectedMessageId) {
117
+ await interaction.reply({
118
+ content: 'No message selected',
119
+ ephemeral: true,
120
+ });
121
+ return;
122
+ }
123
+ await interaction.deferReply({ ephemeral: false });
124
+ try {
125
+ const getClient = await initializeOpencodeForDirectory(directory);
126
+ const forkResponse = await getClient().session.fork({
127
+ path: { id: sessionId },
128
+ body: { messageID: selectedMessageId },
129
+ });
130
+ if (!forkResponse.data) {
131
+ await interaction.editReply('Failed to fork session');
132
+ return;
133
+ }
134
+ const forkedSession = forkResponse.data;
135
+ const parentChannel = interaction.channel;
136
+ if (!parentChannel || ![
137
+ ChannelType.PublicThread,
138
+ ChannelType.PrivateThread,
139
+ ChannelType.AnnouncementThread,
140
+ ].includes(parentChannel.type)) {
141
+ await interaction.editReply('Could not access parent channel');
142
+ return;
143
+ }
144
+ const textChannel = await resolveTextChannel(parentChannel);
145
+ if (!textChannel) {
146
+ await interaction.editReply('Could not resolve parent text channel');
147
+ return;
148
+ }
149
+ const thread = await textChannel.threads.create({
150
+ name: `Fork: ${forkedSession.title}`.slice(0, 100),
151
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
152
+ reason: `Forked from session ${sessionId}`,
153
+ });
154
+ getDatabase()
155
+ .prepare('INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)')
156
+ .run(thread.id, forkedSession.id);
157
+ sessionLogger.log(`Created forked session ${forkedSession.id} in thread ${thread.id}`);
158
+ await sendThreadMessage(thread, `**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}\``);
159
+ // Fetch and display the last assistant messages from the forked session
160
+ const messagesResponse = await getClient().session.messages({
161
+ path: { id: forkedSession.id },
162
+ });
163
+ if (messagesResponse.data) {
164
+ const { partIds, content } = collectLastAssistantParts({
165
+ messages: messagesResponse.data,
166
+ });
167
+ if (content.trim()) {
168
+ const discordMessage = await sendThreadMessage(thread, content);
169
+ // Store part-message mappings for future reference
170
+ const stmt = getDatabase().prepare('INSERT OR REPLACE INTO part_messages (part_id, message_id, thread_id) VALUES (?, ?, ?)');
171
+ const transaction = getDatabase().transaction((ids) => {
172
+ for (const partId of ids) {
173
+ stmt.run(partId, discordMessage.id, thread.id);
174
+ }
175
+ });
176
+ transaction(partIds);
177
+ }
178
+ }
179
+ await sendThreadMessage(thread, `You can now continue the conversation from this point.`);
180
+ await interaction.editReply(`Session forked! Continue in ${thread.toString()}`);
181
+ }
182
+ catch (error) {
183
+ forkLogger.error('Error forking session:', error);
184
+ await interaction.editReply(`Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`);
185
+ }
186
+ }