kimaki 0.4.23 → 0.4.25

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/bin.js +6 -1
  3. package/dist/ai-tool-to-genai.js +3 -0
  4. package/dist/channel-management.js +3 -0
  5. package/dist/cli.js +93 -14
  6. package/dist/commands/abort.js +78 -0
  7. package/dist/commands/add-project.js +97 -0
  8. package/dist/commands/create-new-project.js +78 -0
  9. package/dist/commands/fork.js +186 -0
  10. package/dist/commands/model.js +294 -0
  11. package/dist/commands/permissions.js +126 -0
  12. package/dist/commands/queue.js +129 -0
  13. package/dist/commands/resume.js +145 -0
  14. package/dist/commands/session.js +144 -0
  15. package/dist/commands/share.js +80 -0
  16. package/dist/commands/types.js +2 -0
  17. package/dist/commands/undo-redo.js +161 -0
  18. package/dist/database.js +3 -0
  19. package/dist/discord-bot.js +3 -0
  20. package/dist/discord-utils.js +10 -1
  21. package/dist/format-tables.js +3 -0
  22. package/dist/genai-worker-wrapper.js +3 -0
  23. package/dist/genai-worker.js +3 -0
  24. package/dist/genai.js +3 -0
  25. package/dist/interaction-handler.js +71 -697
  26. package/dist/logger.js +3 -0
  27. package/dist/markdown.js +3 -0
  28. package/dist/message-formatting.js +41 -6
  29. package/dist/opencode.js +3 -0
  30. package/dist/session-handler.js +47 -3
  31. package/dist/system-message.js +16 -0
  32. package/dist/tools.js +3 -0
  33. package/dist/utils.js +3 -0
  34. package/dist/voice-handler.js +3 -0
  35. package/dist/voice.js +3 -0
  36. package/dist/worker-types.js +3 -0
  37. package/dist/xml.js +3 -0
  38. package/package.json +11 -12
  39. package/src/ai-tool-to-genai.ts +4 -0
  40. package/src/channel-management.ts +4 -0
  41. package/src/cli.ts +93 -14
  42. package/src/commands/abort.ts +94 -0
  43. package/src/commands/add-project.ts +138 -0
  44. package/src/commands/create-new-project.ts +111 -0
  45. package/src/{fork.ts → commands/fork.ts} +39 -5
  46. package/src/{model-command.ts → commands/model.ts} +7 -5
  47. package/src/commands/permissions.ts +146 -0
  48. package/src/commands/queue.ts +181 -0
  49. package/src/commands/resume.ts +230 -0
  50. package/src/commands/session.ts +186 -0
  51. package/src/commands/share.ts +96 -0
  52. package/src/commands/types.ts +25 -0
  53. package/src/commands/undo-redo.ts +213 -0
  54. package/src/database.ts +4 -0
  55. package/src/discord-bot.ts +4 -0
  56. package/src/discord-utils.ts +12 -0
  57. package/src/format-tables.ts +4 -0
  58. package/src/genai-worker-wrapper.ts +4 -0
  59. package/src/genai-worker.ts +4 -0
  60. package/src/genai.ts +4 -0
  61. package/src/interaction-handler.ts +81 -919
  62. package/src/logger.ts +4 -0
  63. package/src/markdown.ts +4 -0
  64. package/src/message-formatting.ts +52 -7
  65. package/src/opencode.ts +4 -0
  66. package/src/session-handler.ts +70 -3
  67. package/src/system-message.ts +17 -0
  68. package/src/tools.ts +4 -0
  69. package/src/utils.ts +4 -0
  70. package/src/voice-handler.ts +4 -0
  71. package/src/voice.ts +4 -0
  72. package/src/worker-types.ts +4 -0
  73. package/src/xml.ts +4 -0
  74. package/README.md +0 -48
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kimaki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/bin.js CHANGED
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env node
2
+
3
+ // CLI entrypoint with automatic restart capability.
4
+ // Spawns the main CLI and restarts it on non-zero exit codes with throttling.
5
+ // Exit codes 0, 130 (SIGINT), 143 (SIGTERM), or 64 (EXIT_NO_RESTART) terminate cleanly.
6
+
2
7
  import { spawn } from 'node:child_process'
3
8
  import { fileURLToPath } from 'node:url'
4
9
  import { dirname, join } from 'node:path'
@@ -62,4 +67,4 @@ async function run() {
62
67
  run().catch(err => {
63
68
  console.error('Fatal error:', err)
64
69
  process.exit(1)
65
- })
70
+ })
@@ -1,3 +1,6 @@
1
+ // AI SDK to Google GenAI tool converter.
2
+ // Transforms Vercel AI SDK tool definitions into Google GenAI CallableTool format
3
+ // for use with Gemini's function calling in the voice assistant.
1
4
  import { Type } from '@google/genai';
2
5
  import { z, toJSONSchema } from 'zod';
3
6
  /**
@@ -1,3 +1,6 @@
1
+ // Discord channel and category management.
2
+ // Creates and manages Kimaki project channels (text + voice pairs),
3
+ // extracts channel metadata from topic tags, and ensures category structure.
1
4
  import { ChannelType, } from 'discord.js';
2
5
  import path from 'node:path';
3
6
  import { getDatabase } from './database.js';
package/dist/cli.js CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ // Main CLI entrypoint for the Kimaki Discord bot.
3
+ // Handles interactive setup, Discord OAuth, slash command registration,
4
+ // project channel creation, and launching the bot with opencode integration.
2
5
  import { cac } from 'cac';
3
6
  import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, spinner, } from '@clack/prompts';
4
7
  import { deduplicateByKey, generateBotInstallUrl } from './utils.js';
@@ -13,31 +16,84 @@ const cliLogger = createLogger('CLI');
13
16
  const cli = cac('kimaki');
14
17
  process.title = 'kimaki';
15
18
  const LOCK_PORT = 29988;
19
+ async function killProcessOnPort(port) {
20
+ const isWindows = process.platform === 'win32';
21
+ const myPid = process.pid;
22
+ try {
23
+ if (isWindows) {
24
+ // Windows: find PID using netstat, then kill
25
+ const result = spawnSync('cmd', ['/c', `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`], {
26
+ shell: false,
27
+ encoding: 'utf-8',
28
+ });
29
+ const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p));
30
+ // Filter out our own PID and take the first (oldest)
31
+ const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
32
+ if (targetPid) {
33
+ cliLogger.log(`Killing existing kimaki process (PID: ${targetPid})`);
34
+ spawnSync('taskkill', ['/F', '/PID', targetPid], { shell: false });
35
+ return true;
36
+ }
37
+ }
38
+ else {
39
+ // Unix: use lsof with -sTCP:LISTEN to only find the listening process
40
+ const result = spawnSync('lsof', ['-i', `:${port}`, '-sTCP:LISTEN', '-t'], {
41
+ shell: false,
42
+ encoding: 'utf-8',
43
+ });
44
+ const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p));
45
+ // Filter out our own PID and take the first (oldest)
46
+ const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid);
47
+ if (targetPid) {
48
+ cliLogger.log(`Killing existing kimaki process (PID: ${targetPid})`);
49
+ process.kill(parseInt(targetPid, 10), 'SIGKILL');
50
+ return true;
51
+ }
52
+ }
53
+ }
54
+ catch {
55
+ // Failed to kill, continue anyway
56
+ }
57
+ return false;
58
+ }
16
59
  async function checkSingleInstance() {
17
60
  try {
18
61
  const response = await fetch(`http://127.0.0.1:${LOCK_PORT}`, {
19
62
  signal: AbortSignal.timeout(1000),
20
63
  });
21
64
  if (response.ok) {
22
- cliLogger.error('Another kimaki instance is already running');
23
- process.exit(1);
65
+ cliLogger.log('Another kimaki instance detected');
66
+ await killProcessOnPort(LOCK_PORT);
67
+ // Wait a moment for port to be released
68
+ await new Promise((resolve) => { setTimeout(resolve, 500); });
24
69
  }
25
70
  }
26
71
  catch {
27
72
  // Connection refused means no instance running, continue
28
73
  }
29
74
  }
30
- function startLockServer() {
31
- const server = http.createServer((req, res) => {
32
- res.writeHead(200);
33
- res.end('kimaki');
34
- });
35
- server.listen(LOCK_PORT, '127.0.0.1');
36
- server.on('error', (err) => {
37
- if (err.code === 'EADDRINUSE') {
38
- cliLogger.error('Another kimaki instance is already running');
39
- process.exit(1);
40
- }
75
+ async function startLockServer() {
76
+ return new Promise((resolve, reject) => {
77
+ const server = http.createServer((req, res) => {
78
+ res.writeHead(200);
79
+ res.end('kimaki');
80
+ });
81
+ server.listen(LOCK_PORT, '127.0.0.1');
82
+ server.once('listening', () => {
83
+ resolve();
84
+ });
85
+ server.on('error', async (err) => {
86
+ if (err.code === 'EADDRINUSE') {
87
+ cliLogger.log('Port still in use, retrying...');
88
+ await killProcessOnPort(LOCK_PORT);
89
+ await new Promise((r) => { setTimeout(r, 500); });
90
+ // Retry once
91
+ server.listen(LOCK_PORT, '127.0.0.1');
92
+ }
93
+ else {
94
+ reject(err);
95
+ }
96
+ });
41
97
  });
42
98
  }
43
99
  const EXIT_NO_RESTART = 64;
@@ -125,6 +181,29 @@ async function registerCommands(token, appId) {
125
181
  .setName('model')
126
182
  .setDescription('Set the preferred model for this channel or session')
127
183
  .toJSON(),
184
+ new SlashCommandBuilder()
185
+ .setName('queue')
186
+ .setDescription('Queue a message to be sent after the current response finishes')
187
+ .addStringOption((option) => {
188
+ option
189
+ .setName('message')
190
+ .setDescription('The message to queue')
191
+ .setRequired(true);
192
+ return option;
193
+ })
194
+ .toJSON(),
195
+ new SlashCommandBuilder()
196
+ .setName('clear-queue')
197
+ .setDescription('Clear all queued messages in this thread')
198
+ .toJSON(),
199
+ new SlashCommandBuilder()
200
+ .setName('undo')
201
+ .setDescription('Undo the last assistant message (revert file changes)')
202
+ .toJSON(),
203
+ new SlashCommandBuilder()
204
+ .setName('redo')
205
+ .setDescription('Redo previously undone changes')
206
+ .toJSON(),
128
207
  ];
129
208
  const rest = new REST().setToken(token);
130
209
  try {
@@ -469,7 +548,7 @@ cli
469
548
  .action(async (options) => {
470
549
  try {
471
550
  await checkSingleInstance();
472
- startLockServer();
551
+ await startLockServer();
473
552
  await run({
474
553
  restart: options.restart,
475
554
  addChannels: options.addChannels,
@@ -0,0 +1,78 @@
1
+ // /abort command - Abort the current OpenCode request in this thread.
2
+ import { ChannelType } from 'discord.js';
3
+ import { getDatabase } from '../database.js';
4
+ import { initializeOpencodeForDirectory } from '../opencode.js';
5
+ import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
+ import { abortControllers } from '../session-handler.js';
7
+ import { createLogger } from '../logger.js';
8
+ const logger = createLogger('ABORT');
9
+ export async function handleAbortCommand({ command, }) {
10
+ const channel = command.channel;
11
+ if (!channel) {
12
+ await command.reply({
13
+ content: 'This command can only be used in a channel',
14
+ ephemeral: true,
15
+ flags: SILENT_MESSAGE_FLAGS,
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 command.reply({
26
+ content: 'This command can only be used in a thread with an active session',
27
+ ephemeral: true,
28
+ flags: SILENT_MESSAGE_FLAGS,
29
+ });
30
+ return;
31
+ }
32
+ const textChannel = await resolveTextChannel(channel);
33
+ const { projectDirectory: directory } = getKimakiMetadata(textChannel);
34
+ if (!directory) {
35
+ await command.reply({
36
+ content: 'Could not determine project directory for this channel',
37
+ ephemeral: true,
38
+ flags: SILENT_MESSAGE_FLAGS,
39
+ });
40
+ return;
41
+ }
42
+ const row = getDatabase()
43
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
44
+ .get(channel.id);
45
+ if (!row?.session_id) {
46
+ await command.reply({
47
+ content: 'No active session in this thread',
48
+ ephemeral: true,
49
+ flags: SILENT_MESSAGE_FLAGS,
50
+ });
51
+ return;
52
+ }
53
+ const sessionId = row.session_id;
54
+ try {
55
+ const existingController = abortControllers.get(sessionId);
56
+ if (existingController) {
57
+ existingController.abort(new Error('User requested abort'));
58
+ abortControllers.delete(sessionId);
59
+ }
60
+ const getClient = await initializeOpencodeForDirectory(directory);
61
+ await getClient().session.abort({
62
+ path: { id: sessionId },
63
+ });
64
+ await command.reply({
65
+ content: `🛑 Request **aborted**`,
66
+ flags: SILENT_MESSAGE_FLAGS,
67
+ });
68
+ logger.log(`Session ${sessionId} aborted by user`);
69
+ }
70
+ catch (error) {
71
+ logger.error('[ABORT] Error:', error);
72
+ await command.reply({
73
+ content: `Failed to abort: ${error instanceof Error ? error.message : 'Unknown error'}`,
74
+ ephemeral: true,
75
+ flags: SILENT_MESSAGE_FLAGS,
76
+ });
77
+ }
78
+ }
@@ -0,0 +1,97 @@
1
+ // /add-project command - Create Discord channels for an existing OpenCode project.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getDatabase } from '../database.js';
5
+ import { initializeOpencodeForDirectory } from '../opencode.js';
6
+ import { createProjectChannels } from '../channel-management.js';
7
+ import { createLogger } from '../logger.js';
8
+ const logger = createLogger('ADD-PROJECT');
9
+ export async function handleAddProjectCommand({ command, appId, }) {
10
+ await command.deferReply({ ephemeral: false });
11
+ const projectId = command.options.getString('project', true);
12
+ const guild = command.guild;
13
+ if (!guild) {
14
+ await command.editReply('This command can only be used in a guild');
15
+ return;
16
+ }
17
+ try {
18
+ const currentDir = process.cwd();
19
+ const getClient = await initializeOpencodeForDirectory(currentDir);
20
+ const projectsResponse = await getClient().project.list({});
21
+ if (!projectsResponse.data) {
22
+ await command.editReply('Failed to fetch projects');
23
+ return;
24
+ }
25
+ const project = projectsResponse.data.find((p) => p.id === projectId);
26
+ if (!project) {
27
+ await command.editReply('Project not found');
28
+ return;
29
+ }
30
+ const directory = project.worktree;
31
+ if (!fs.existsSync(directory)) {
32
+ await command.editReply(`Directory does not exist: ${directory}`);
33
+ return;
34
+ }
35
+ const db = getDatabase();
36
+ const existingChannel = db
37
+ .prepare('SELECT channel_id FROM channel_directories WHERE directory = ? AND channel_type = ?')
38
+ .get(directory, 'text');
39
+ if (existingChannel) {
40
+ await command.editReply(`A channel already exists for this directory: <#${existingChannel.channel_id}>`);
41
+ return;
42
+ }
43
+ const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
44
+ guild,
45
+ projectDirectory: directory,
46
+ appId,
47
+ });
48
+ await command.editReply(`✅ Created channels for project:\n📝 Text: <#${textChannelId}>\n🔊 Voice: <#${voiceChannelId}>\n📁 Directory: \`${directory}\``);
49
+ logger.log(`Created channels for project ${channelName} at ${directory}`);
50
+ }
51
+ catch (error) {
52
+ logger.error('[ADD-PROJECT] Error:', error);
53
+ await command.editReply(`Failed to create channels: ${error instanceof Error ? error.message : 'Unknown error'}`);
54
+ }
55
+ }
56
+ export async function handleAddProjectAutocomplete({ interaction, appId, }) {
57
+ const focusedValue = interaction.options.getFocused();
58
+ try {
59
+ const currentDir = process.cwd();
60
+ const getClient = await initializeOpencodeForDirectory(currentDir);
61
+ const projectsResponse = await getClient().project.list({});
62
+ if (!projectsResponse.data) {
63
+ await interaction.respond([]);
64
+ return;
65
+ }
66
+ const db = getDatabase();
67
+ const existingDirs = db
68
+ .prepare('SELECT DISTINCT directory FROM channel_directories WHERE channel_type = ?')
69
+ .all('text');
70
+ const existingDirSet = new Set(existingDirs.map((row) => row.directory));
71
+ const availableProjects = projectsResponse.data.filter((project) => !existingDirSet.has(project.worktree));
72
+ const projects = availableProjects
73
+ .filter((project) => {
74
+ const baseName = path.basename(project.worktree);
75
+ const searchText = `${baseName} ${project.worktree}`.toLowerCase();
76
+ return searchText.includes(focusedValue.toLowerCase());
77
+ })
78
+ .sort((a, b) => {
79
+ const aTime = a.time.initialized || a.time.created;
80
+ const bTime = b.time.initialized || b.time.created;
81
+ return bTime - aTime;
82
+ })
83
+ .slice(0, 25)
84
+ .map((project) => {
85
+ const name = `${path.basename(project.worktree)} (${project.worktree})`;
86
+ return {
87
+ name: name.length > 100 ? name.slice(0, 99) + '…' : name,
88
+ value: project.id,
89
+ };
90
+ });
91
+ await interaction.respond(projects);
92
+ }
93
+ catch (error) {
94
+ logger.error('[AUTOCOMPLETE] Error fetching projects:', error);
95
+ await interaction.respond([]);
96
+ }
97
+ }
@@ -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
+ }