kimaki 0.4.46 → 0.4.48

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 (85) hide show
  1. package/dist/cli.js +69 -21
  2. package/dist/commands/abort.js +4 -2
  3. package/dist/commands/add-project.js +2 -2
  4. package/dist/commands/agent.js +4 -4
  5. package/dist/commands/ask-question.js +9 -8
  6. package/dist/commands/compact.js +126 -0
  7. package/dist/commands/create-new-project.js +60 -30
  8. package/dist/commands/fork.js +3 -3
  9. package/dist/commands/merge-worktree.js +23 -10
  10. package/dist/commands/model.js +5 -5
  11. package/dist/commands/permissions.js +5 -3
  12. package/dist/commands/queue.js +2 -2
  13. package/dist/commands/remove-project.js +2 -2
  14. package/dist/commands/resume.js +2 -2
  15. package/dist/commands/session.js +6 -3
  16. package/dist/commands/share.js +2 -2
  17. package/dist/commands/undo-redo.js +2 -2
  18. package/dist/commands/user-command.js +2 -2
  19. package/dist/commands/verbosity.js +5 -5
  20. package/dist/commands/worktree-settings.js +2 -2
  21. package/dist/commands/worktree.js +18 -8
  22. package/dist/config.js +7 -0
  23. package/dist/database.js +10 -7
  24. package/dist/discord-bot.js +30 -12
  25. package/dist/discord-utils.js +2 -2
  26. package/dist/genai-worker-wrapper.js +3 -3
  27. package/dist/genai-worker.js +2 -2
  28. package/dist/genai.js +2 -2
  29. package/dist/interaction-handler.js +6 -2
  30. package/dist/logger.js +57 -9
  31. package/dist/markdown.js +2 -2
  32. package/dist/message-formatting.js +91 -6
  33. package/dist/openai-realtime.js +2 -2
  34. package/dist/opencode.js +19 -25
  35. package/dist/session-handler.js +89 -29
  36. package/dist/system-message.js +11 -9
  37. package/dist/tools.js +3 -2
  38. package/dist/utils.js +1 -0
  39. package/dist/voice-handler.js +2 -2
  40. package/dist/voice.js +2 -2
  41. package/dist/worktree-utils.js +91 -7
  42. package/dist/xml.js +2 -2
  43. package/package.json +3 -3
  44. package/src/cli.ts +108 -21
  45. package/src/commands/abort.ts +4 -2
  46. package/src/commands/add-project.ts +2 -2
  47. package/src/commands/agent.ts +4 -4
  48. package/src/commands/ask-question.ts +9 -8
  49. package/src/commands/compact.ts +148 -0
  50. package/src/commands/create-new-project.ts +87 -36
  51. package/src/commands/fork.ts +3 -3
  52. package/src/commands/merge-worktree.ts +47 -10
  53. package/src/commands/model.ts +5 -5
  54. package/src/commands/permissions.ts +6 -2
  55. package/src/commands/queue.ts +2 -2
  56. package/src/commands/remove-project.ts +2 -2
  57. package/src/commands/resume.ts +2 -2
  58. package/src/commands/session.ts +6 -3
  59. package/src/commands/share.ts +2 -2
  60. package/src/commands/undo-redo.ts +2 -2
  61. package/src/commands/user-command.ts +2 -2
  62. package/src/commands/verbosity.ts +5 -5
  63. package/src/commands/worktree-settings.ts +2 -2
  64. package/src/commands/worktree.ts +20 -7
  65. package/src/config.ts +14 -0
  66. package/src/database.ts +13 -7
  67. package/src/discord-bot.ts +45 -12
  68. package/src/discord-utils.ts +2 -2
  69. package/src/genai-worker-wrapper.ts +3 -3
  70. package/src/genai-worker.ts +2 -2
  71. package/src/genai.ts +2 -2
  72. package/src/interaction-handler.ts +7 -2
  73. package/src/logger.ts +64 -10
  74. package/src/markdown.ts +2 -2
  75. package/src/message-formatting.ts +100 -6
  76. package/src/openai-realtime.ts +2 -2
  77. package/src/opencode.ts +19 -26
  78. package/src/session-handler.ts +102 -29
  79. package/src/system-message.ts +11 -9
  80. package/src/tools.ts +3 -2
  81. package/src/utils.ts +1 -0
  82. package/src/voice-handler.ts +2 -2
  83. package/src/voice.ts +2 -2
  84. package/src/worktree-utils.ts +111 -7
  85. package/src/xml.ts +2 -2
package/dist/cli.js CHANGED
@@ -10,13 +10,33 @@ import { Events, ChannelType, REST, Routes, SlashCommandBuilder, AttachmentBuild
10
10
  import path from 'node:path';
11
11
  import fs from 'node:fs';
12
12
  import * as errore from 'errore';
13
- import { createLogger } from './logger.js';
13
+ import { createLogger, LogPrefix } from './logger.js';
14
14
  import { uploadFilesToDiscord } from './discord-utils.js';
15
15
  import { spawn, spawnSync, execSync } from 'node:child_process';
16
16
  import http from 'node:http';
17
- import { setDataDir, getDataDir, getLockPort } from './config.js';
17
+ import { setDataDir, getDataDir, getLockPort, setDefaultVerbosity } from './config.js';
18
18
  import { sanitizeAgentName } from './commands/agent.js';
19
- const cliLogger = createLogger('CLI');
19
+ const cliLogger = createLogger(LogPrefix.CLI);
20
+ // Spawn caffeinate on macOS to prevent system sleep while bot is running.
21
+ // Not detached, so it dies automatically with the parent process.
22
+ function startCaffeinate() {
23
+ if (process.platform !== 'darwin') {
24
+ return;
25
+ }
26
+ try {
27
+ const proc = spawn('caffeinate', ['-i'], {
28
+ stdio: 'ignore',
29
+ detached: false,
30
+ });
31
+ proc.on('error', (err) => {
32
+ cliLogger.warn('Failed to start caffeinate:', err.message);
33
+ });
34
+ cliLogger.log('Started caffeinate to prevent system sleep');
35
+ }
36
+ catch (err) {
37
+ cliLogger.warn('Failed to spawn caffeinate:', err instanceof Error ? err.message : String(err));
38
+ }
39
+ }
20
40
  const cli = cac('kimaki');
21
41
  process.title = 'kimaki';
22
42
  async function killProcessOnPort(port) {
@@ -86,7 +106,8 @@ async function checkSingleInstance() {
86
106
  });
87
107
  }
88
108
  }
89
- catch {
109
+ catch (error) {
110
+ cliLogger.debug('Lock port check failed:', error instanceof Error ? error.message : String(error));
90
111
  cliLogger.debug('No other kimaki instance detected on lock port');
91
112
  }
92
113
  }
@@ -217,6 +238,10 @@ async function registerCommands({ token, appId, userCommands = [], agents = [],
217
238
  .setName('abort')
218
239
  .setDescription('Abort the current OpenCode request in this thread')
219
240
  .toJSON(),
241
+ new SlashCommandBuilder()
242
+ .setName('compact')
243
+ .setDescription('Compact the session context by summarizing conversation history')
244
+ .toJSON(),
220
245
  new SlashCommandBuilder()
221
246
  .setName('stop')
222
247
  .setDescription('Abort the current OpenCode request in this thread')
@@ -375,11 +400,17 @@ async function backgroundInit({ currentDir, token, appId, }) {
375
400
  getClient()
376
401
  .command.list({ query: { directory: currentDir } })
377
402
  .then((r) => r.data || [])
378
- .catch(() => []),
403
+ .catch((error) => {
404
+ cliLogger.warn('Failed to load user commands during background init:', error instanceof Error ? error.message : String(error));
405
+ return [];
406
+ }),
379
407
  getClient()
380
408
  .app.agents({ query: { directory: currentDir } })
381
409
  .then((r) => r.data || [])
382
- .catch(() => []),
410
+ .catch((error) => {
411
+ cliLogger.warn('Failed to load agents during background init:', error instanceof Error ? error.message : String(error));
412
+ return [];
413
+ }),
383
414
  ]);
384
415
  await registerCommands({ token, appId, userCommands, agents });
385
416
  cliLogger.log('Slash commands registered!');
@@ -389,6 +420,7 @@ async function backgroundInit({ currentDir, token, appId, }) {
389
420
  }
390
421
  }
391
422
  async function run({ restart, addChannels, useWorktrees }) {
423
+ startCaffeinate();
392
424
  const forceSetup = Boolean(restart);
393
425
  intro('šŸ¤– Discord Bot Setup');
394
426
  // Step 0: Check if OpenCode CLI is available
@@ -423,7 +455,8 @@ async function run({ restart, addChannels, useWorktrees }) {
423
455
  fs.accessSync(p, fs.constants.F_OK);
424
456
  return true;
425
457
  }
426
- catch {
458
+ catch (error) {
459
+ cliLogger.debug(`OpenCode path not found at ${p}:`, error instanceof Error ? error.message : String(error));
427
460
  return false;
428
461
  }
429
462
  });
@@ -651,11 +684,17 @@ async function run({ restart, addChannels, useWorktrees }) {
651
684
  getClient()
652
685
  .command.list({ query: { directory: currentDir } })
653
686
  .then((r) => r.data || [])
654
- .catch(() => []),
687
+ .catch((error) => {
688
+ cliLogger.warn('Failed to load user commands during setup:', error instanceof Error ? error.message : String(error));
689
+ return [];
690
+ }),
655
691
  getClient()
656
692
  .app.agents({ query: { directory: currentDir } })
657
693
  .then((r) => r.data || [])
658
- .catch(() => []),
694
+ .catch((error) => {
695
+ cliLogger.warn('Failed to load agents during setup:', error instanceof Error ? error.message : String(error));
696
+ return [];
697
+ }),
659
698
  ]);
660
699
  s.stop(`Found ${projects.length} OpenCode project(s)`);
661
700
  const existingDirs = kimakiChannels.flatMap(({ channels }) => channels
@@ -766,6 +805,7 @@ cli
766
805
  .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
767
806
  .option('--install-url', 'Print the bot install URL and exit')
768
807
  .option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
808
+ .option('--verbosity <level>', 'Default verbosity for all channels (tools-and-text or text-only)')
769
809
  .action(async (options) => {
770
810
  try {
771
811
  // Set data directory early, before any database access
@@ -773,6 +813,14 @@ cli
773
813
  setDataDir(options.dataDir);
774
814
  cliLogger.log(`Using data directory: ${getDataDir()}`);
775
815
  }
816
+ if (options.verbosity) {
817
+ if (options.verbosity !== 'tools-and-text' && options.verbosity !== 'text-only') {
818
+ cliLogger.error(`Invalid verbosity level: ${options.verbosity}. Use "tools-and-text" or "text-only".`);
819
+ process.exit(EXIT_NO_RESTART);
820
+ }
821
+ setDefaultVerbosity(options.verbosity);
822
+ cliLogger.log(`Default verbosity: ${options.verbosity}`);
823
+ }
776
824
  if (options.installUrl) {
777
825
  const db = getDatabase();
778
826
  const existingBot = db
@@ -895,8 +943,8 @@ cli
895
943
  .get();
896
944
  appId = botRow?.app_id;
897
945
  }
898
- catch {
899
- // Database might not exist in CI, that's ok
946
+ catch (error) {
947
+ cliLogger.debug('Database lookup failed while resolving app ID:', error instanceof Error ? error.message : String(error));
900
948
  }
901
949
  }
902
950
  }
@@ -992,8 +1040,8 @@ cli
992
1040
  return ch.guild;
993
1041
  }
994
1042
  }
995
- catch {
996
- // Channel might be deleted, continue
1043
+ catch (error) {
1044
+ cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
997
1045
  }
998
1046
  }
999
1047
  // Fall back to first guild the bot is in
@@ -1174,8 +1222,8 @@ cli
1174
1222
  .get();
1175
1223
  appId = botRow?.app_id;
1176
1224
  }
1177
- catch {
1178
- // Database might not exist in CI
1225
+ catch (error) {
1226
+ cliLogger.debug('Database lookup failed while resolving app ID:', error instanceof Error ? error.message : String(error));
1179
1227
  }
1180
1228
  }
1181
1229
  }
@@ -1245,8 +1293,8 @@ cli
1245
1293
  throw new Error('Channel has no guild');
1246
1294
  }
1247
1295
  }
1248
- catch {
1249
- // Channel might be deleted, fall back to first guild
1296
+ catch (error) {
1297
+ cliLogger.debug('Failed to fetch existing channel while selecting guild:', error instanceof Error ? error.message : String(error));
1250
1298
  const firstGuild = client.guilds.cache.first();
1251
1299
  if (!firstGuild) {
1252
1300
  s.stop('No guild found');
@@ -1285,13 +1333,13 @@ cli
1285
1333
  process.exit(0);
1286
1334
  }
1287
1335
  }
1288
- catch {
1289
- // Channel might be deleted, continue checking
1336
+ catch (error) {
1337
+ cliLogger.debug(`Failed to fetch channel ${existingChannel.channel_id} while checking existing channels:`, error instanceof Error ? error.message : String(error));
1290
1338
  }
1291
1339
  }
1292
1340
  }
1293
- catch {
1294
- // Database might not exist, continue to create
1341
+ catch (error) {
1342
+ cliLogger.debug('Database lookup failed while checking existing channels:', error instanceof Error ? error.message : String(error));
1295
1343
  }
1296
1344
  s.message(`Creating channels in ${guild.name}...`);
1297
1345
  const { textChannelId, voiceChannelId, channelName } = await createProjectChannels({
@@ -4,9 +4,9 @@ import { getDatabase } from '../database.js';
4
4
  import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
6
6
  import { abortControllers } from '../session-handler.js';
7
- import { createLogger } from '../logger.js';
7
+ import { createLogger, LogPrefix } from '../logger.js';
8
8
  import * as errore from 'errore';
9
- const logger = createLogger('ABORT');
9
+ const logger = createLogger(LogPrefix.ABORT);
10
10
  export async function handleAbortCommand({ command }) {
11
11
  const channel = command.channel;
12
12
  if (!channel) {
@@ -54,6 +54,7 @@ export async function handleAbortCommand({ command }) {
54
54
  const sessionId = row.session_id;
55
55
  const existingController = abortControllers.get(sessionId);
56
56
  if (existingController) {
57
+ logger.log(`[ABORT] reason=user-requested sessionId=${sessionId} channelId=${channel.id} - user ran /abort command`);
57
58
  existingController.abort(new Error('User requested abort'));
58
59
  abortControllers.delete(sessionId);
59
60
  }
@@ -67,6 +68,7 @@ export async function handleAbortCommand({ command }) {
67
68
  return;
68
69
  }
69
70
  try {
71
+ logger.log(`[ABORT-API] reason=user-requested sessionId=${sessionId} channelId=${channel.id} - sending API abort from /abort command`);
70
72
  await getClient().session.abort({
71
73
  path: { id: sessionId },
72
74
  });
@@ -4,10 +4,10 @@ import path from 'node:path';
4
4
  import { getDatabase } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
6
  import { createProjectChannels } from '../channel-management.js';
7
- import { createLogger } from '../logger.js';
7
+ import { createLogger, LogPrefix } from '../logger.js';
8
8
  import { abbreviatePath } from '../utils.js';
9
9
  import * as errore from 'errore';
10
- const logger = createLogger('ADD-PROJECT');
10
+ const logger = createLogger(LogPrefix.ADD_PROJECT);
11
11
  export async function handleAddProjectCommand({ command, appId }) {
12
12
  await command.deferReply({ ephemeral: false });
13
13
  const projectId = command.options.getString('project', true);
@@ -5,9 +5,9 @@ import crypto from 'node:crypto';
5
5
  import { getDatabase, setChannelAgent, setSessionAgent, clearSessionModel, runModelMigrations } from '../database.js';
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
7
7
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
8
- import { createLogger } from '../logger.js';
8
+ import { createLogger, LogPrefix } from '../logger.js';
9
9
  import * as errore from 'errore';
10
- const agentLogger = createLogger('AGENT');
10
+ const agentLogger = createLogger(LogPrefix.AGENT);
11
11
  const pendingAgentContexts = new Map();
12
12
  /**
13
13
  * Sanitize an agent name to be a valid Discord command name component.
@@ -181,7 +181,7 @@ export async function handleAgentSelectMenu(interaction) {
181
181
  }
182
182
  else {
183
183
  await interaction.editReply({
184
- content: `Agent preference set for this channel: **${selectedAgent}**\n\nAll new sessions in this channel will use this agent.`,
184
+ content: `Agent preference set for this channel: **${selectedAgent}**\nAll new sessions in this channel will use this agent.`,
185
185
  components: [],
186
186
  });
187
187
  }
@@ -237,7 +237,7 @@ export async function handleQuickAgentCommand({ command, appId, }) {
237
237
  }
238
238
  else {
239
239
  await command.editReply({
240
- content: `Switched to **${matchingAgent.name}** agent for this channel\n\nAll new sessions will use this agent.`,
240
+ content: `Switched to **${matchingAgent.name}** agent for this channel\nAll new sessions will use this agent.`,
241
241
  });
242
242
  }
243
243
  }
@@ -5,8 +5,8 @@ import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder,
5
5
  import crypto from 'node:crypto';
6
6
  import { sendThreadMessage, NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
7
7
  import { getOpencodeClientV2 } from '../opencode.js';
8
- import { createLogger } from '../logger.js';
9
- const logger = createLogger('ASK_QUESTION');
8
+ import { createLogger, LogPrefix } from '../logger.js';
9
+ const logger = createLogger(LogPrefix.ASK_QUESTION);
10
10
  // Store pending question contexts by hash
11
11
  export const pendingQuestionContexts = new Map();
12
12
  /**
@@ -178,9 +178,9 @@ export function parseAskUserQuestionTool(part) {
178
178
  }
179
179
  /**
180
180
  * Cancel a pending question for a thread (e.g., when user sends a new message).
181
- * Sends cancellation response to OpenCode so the session can continue.
181
+ * Sends the user's message as the answer to OpenCode so the model sees their actual response.
182
182
  */
183
- export async function cancelPendingQuestion(threadId) {
183
+ export async function cancelPendingQuestion(threadId, userMessage) {
184
184
  // Find pending question for this thread
185
185
  let contextHash;
186
186
  let context;
@@ -199,18 +199,19 @@ export async function cancelPendingQuestion(threadId) {
199
199
  if (!clientV2) {
200
200
  throw new Error('OpenCode server not found for directory');
201
201
  }
202
- // Preserve already-answered questions, mark unanswered as cancelled
202
+ // Use user's message as answer if provided, otherwise mark as "Other"
203
+ const customAnswer = userMessage || 'Other';
203
204
  const answers = context.questions.map((_, i) => {
204
- return context.answers[i] || ['(cancelled - user sent new message)'];
205
+ return context.answers[i] || [customAnswer];
205
206
  });
206
207
  await clientV2.question.reply({
207
208
  requestID: context.requestId,
208
209
  answers,
209
210
  });
210
- logger.log(`Cancelled question ${context.requestId} due to new user message`);
211
+ logger.log(`Answered question ${context.requestId} with user message`);
211
212
  }
212
213
  catch (error) {
213
- logger.error('Failed to cancel question:', error);
214
+ logger.error('Failed to answer question:', error);
214
215
  }
215
216
  // Clean up regardless of whether the API call succeeded
216
217
  pendingQuestionContexts.delete(contextHash);
@@ -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, getKimakiMetadata, 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 } = getKimakiMetadata(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
+ }
@@ -1,13 +1,51 @@
1
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).
2
3
  import { ChannelType } from 'discord.js';
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
6
+ import { execSync } from 'node:child_process';
5
7
  import { getProjectsDir } from '../config.js';
6
8
  import { createProjectChannels } from '../channel-management.js';
7
9
  import { handleOpencodeSession } from '../session-handler.js';
8
10
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
9
- import { createLogger } from '../logger.js';
10
- const logger = createLogger('CREATE-NEW-PROJECT');
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
+ }
11
49
  export async function handleCreateNewProjectCommand({ command, appId, }) {
12
50
  await command.deferReply({ ephemeral: false });
13
51
  const projectName = command.options.getString('name', true);
@@ -21,39 +59,31 @@ export async function handleCreateNewProjectCommand({ command, appId, }) {
21
59
  await command.editReply('This command can only be used in a text channel');
22
60
  return;
23
61
  }
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 projectsDir = getProjectsDir();
35
- const projectDirectory = path.join(projectsDir, sanitizedName);
36
62
  try {
37
- if (!fs.existsSync(projectsDir)) {
38
- fs.mkdirSync(projectsDir, { recursive: true });
39
- logger.log(`Created projects directory: ${projectsDir}`);
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({
63
+ const result = await createNewProject({
51
64
  guild,
52
- projectDirectory,
65
+ projectName,
53
66
  appId,
67
+ botName: command.client.user?.username,
54
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;
55
85
  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..._`);
86
+ await command.editReply(`āœ… Created new project **${sanitizedName}**\nšŸ“ Directory: \`${projectDirectory}\`\nšŸ“ Text: <#${textChannelId}>\nšŸ”Š Voice: <#${voiceChannelId}>\n_Starting session..._`);
57
87
  const starterMessage = await textChannel.send({
58
88
  content: `šŸš€ **New project initialized**\nšŸ“ \`${projectDirectory}\``,
59
89
  flags: SILENT_MESSAGE_FLAGS,
@@ -4,10 +4,10 @@ import { getDatabase } from '../database.js';
4
4
  import { initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from '../discord-utils.js';
6
6
  import { collectLastAssistantParts } from '../message-formatting.js';
7
- import { createLogger } from '../logger.js';
7
+ import { createLogger, LogPrefix } from '../logger.js';
8
8
  import * as errore from 'errore';
9
- const sessionLogger = createLogger('SESSION');
10
- const forkLogger = createLogger('FORK');
9
+ const sessionLogger = createLogger(LogPrefix.SESSION);
10
+ const forkLogger = createLogger(LogPrefix.FORK);
11
11
  export async function handleForkCommand(interaction) {
12
12
  const channel = interaction.channel;
13
13
  if (!channel) {
@@ -3,9 +3,9 @@
3
3
  // After merge, switches to detached HEAD at main so user can keep working.
4
4
  import {} from 'discord.js';
5
5
  import { getThreadWorktree } from '../database.js';
6
- import { createLogger } from '../logger.js';
6
+ import { createLogger, LogPrefix } from '../logger.js';
7
7
  import { execAsync } from '../worktree-utils.js';
8
- const logger = createLogger('MERGE-WORKTREE');
8
+ const logger = createLogger(LogPrefix.WORKTREE);
9
9
  /** Worktree thread title prefix - indicates unmerged worktree */
10
10
  export const WORKTREE_PREFIX = '⬦ ';
11
11
  /**
@@ -38,7 +38,8 @@ async function isDetachedHead(worktreeDir) {
38
38
  await execAsync(`git -C "${worktreeDir}" symbolic-ref HEAD`);
39
39
  return false;
40
40
  }
41
- catch {
41
+ catch (error) {
42
+ logger.debug(`Failed to resolve HEAD for ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
42
43
  return true;
43
44
  }
44
45
  }
@@ -50,7 +51,8 @@ async function getCurrentBranch(worktreeDir) {
50
51
  const { stdout } = await execAsync(`git -C "${worktreeDir}" symbolic-ref --short HEAD`);
51
52
  return stdout.trim() || null;
52
53
  }
53
- catch {
54
+ catch (error) {
55
+ logger.debug(`Failed to get current branch for ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
54
56
  return null;
55
57
  }
56
58
  }
@@ -89,7 +91,8 @@ export async function handleMergeWorktreeCommand({ command, appId }) {
89
91
  const { stdout } = await execAsync(`git -C "${mainRepoDir}" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@'`);
90
92
  defaultBranch = stdout.trim() || 'main';
91
93
  }
92
- catch {
94
+ catch (error) {
95
+ logger.warn(`Failed to detect default branch for ${mainRepoDir}, falling back to main:`, error instanceof Error ? error.message : String(error));
93
96
  defaultBranch = 'main';
94
97
  }
95
98
  // 3. Determine if we're on a branch or detached HEAD
@@ -115,11 +118,17 @@ export async function handleMergeWorktreeCommand({ command, appId }) {
115
118
  }
116
119
  catch (e) {
117
120
  // If merge fails (conflicts), abort and report
118
- await execAsync(`git -C "${worktreeDir}" merge --abort`).catch(() => { });
121
+ await execAsync(`git -C "${worktreeDir}" merge --abort`).catch((error) => {
122
+ logger.warn(`Failed to abort merge in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
123
+ });
119
124
  // Clean up temp branch if we created one
120
125
  if (tempBranch) {
121
- await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch(() => { });
122
- await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch(() => { });
126
+ await execAsync(`git -C "${worktreeDir}" checkout --detach`).catch((error) => {
127
+ logger.warn(`Failed to detach HEAD after merge conflict in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
128
+ });
129
+ await execAsync(`git -C "${worktreeDir}" branch -D ${tempBranch}`).catch((error) => {
130
+ logger.warn(`Failed to delete temp branch ${tempBranch} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
131
+ });
123
132
  }
124
133
  throw new Error(`Merge conflict - resolve manually in worktree then retry`);
125
134
  }
@@ -133,10 +142,14 @@ export async function handleMergeWorktreeCommand({ command, appId }) {
133
142
  await execAsync(`git -C "${worktreeDir}" checkout --detach ${defaultBranch}`);
134
143
  // 7. Delete the merged branch (temp or original)
135
144
  logger.log(`Deleting merged branch ${branchToMerge}`);
136
- await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch(() => { });
145
+ await execAsync(`git -C "${worktreeDir}" branch -D ${branchToMerge}`).catch((error) => {
146
+ logger.warn(`Failed to delete merged branch ${branchToMerge} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
147
+ });
137
148
  // Also delete the original worktree branch if different from what we merged
138
149
  if (!isDetached && branchToMerge !== worktreeInfo.worktree_name) {
139
- await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch(() => { });
150
+ await execAsync(`git -C "${worktreeDir}" branch -D ${worktreeInfo.worktree_name}`).catch((error) => {
151
+ logger.warn(`Failed to delete worktree branch ${worktreeInfo.worktree_name} in ${worktreeDir}:`, error instanceof Error ? error.message : String(error));
152
+ });
140
153
  }
141
154
  // 8. Remove worktree prefix from thread title (fire and forget with timeout)
142
155
  void removeWorktreePrefixFromTitle(thread);