kimaki 0.4.35 → 0.4.37

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 (76) hide show
  1. package/dist/ai-tool-to-genai.js +1 -3
  2. package/dist/channel-management.js +5 -5
  3. package/dist/cli.js +182 -46
  4. package/dist/commands/abort.js +1 -1
  5. package/dist/commands/add-project.js +1 -1
  6. package/dist/commands/agent.js +6 -2
  7. package/dist/commands/ask-question.js +2 -1
  8. package/dist/commands/fork.js +7 -7
  9. package/dist/commands/queue.js +2 -2
  10. package/dist/commands/remove-project.js +109 -0
  11. package/dist/commands/resume.js +3 -5
  12. package/dist/commands/session.js +2 -2
  13. package/dist/commands/share.js +1 -1
  14. package/dist/commands/undo-redo.js +2 -2
  15. package/dist/commands/user-command.js +3 -6
  16. package/dist/config.js +1 -1
  17. package/dist/database.js +7 -0
  18. package/dist/discord-bot.js +37 -20
  19. package/dist/discord-utils.js +33 -9
  20. package/dist/genai.js +4 -6
  21. package/dist/interaction-handler.js +8 -1
  22. package/dist/markdown.js +1 -3
  23. package/dist/message-formatting.js +7 -3
  24. package/dist/openai-realtime.js +3 -5
  25. package/dist/opencode.js +1 -1
  26. package/dist/session-handler.js +25 -15
  27. package/dist/system-message.js +10 -4
  28. package/dist/tools.js +9 -22
  29. package/dist/voice-handler.js +9 -12
  30. package/dist/voice.js +5 -3
  31. package/dist/xml.js +2 -4
  32. package/package.json +3 -2
  33. package/src/__snapshots__/compact-session-context-no-system.md +24 -24
  34. package/src/__snapshots__/compact-session-context.md +31 -31
  35. package/src/ai-tool-to-genai.ts +3 -11
  36. package/src/channel-management.ts +18 -29
  37. package/src/cli.ts +334 -205
  38. package/src/commands/abort.ts +1 -3
  39. package/src/commands/add-project.ts +8 -14
  40. package/src/commands/agent.ts +16 -9
  41. package/src/commands/ask-question.ts +8 -7
  42. package/src/commands/create-new-project.ts +8 -14
  43. package/src/commands/fork.ts +23 -27
  44. package/src/commands/model.ts +14 -11
  45. package/src/commands/permissions.ts +1 -1
  46. package/src/commands/queue.ts +6 -19
  47. package/src/commands/remove-project.ts +136 -0
  48. package/src/commands/resume.ts +11 -30
  49. package/src/commands/session.ts +4 -13
  50. package/src/commands/share.ts +1 -3
  51. package/src/commands/types.ts +1 -3
  52. package/src/commands/undo-redo.ts +6 -18
  53. package/src/commands/user-command.ts +8 -10
  54. package/src/config.ts +5 -5
  55. package/src/database.ts +17 -8
  56. package/src/discord-bot.ts +60 -58
  57. package/src/discord-utils.ts +35 -18
  58. package/src/escape-backticks.test.ts +0 -2
  59. package/src/format-tables.ts +1 -4
  60. package/src/genai-worker-wrapper.ts +3 -9
  61. package/src/genai-worker.ts +4 -19
  62. package/src/genai.ts +10 -42
  63. package/src/interaction-handler.ts +133 -121
  64. package/src/markdown.test.ts +10 -32
  65. package/src/markdown.ts +6 -14
  66. package/src/message-formatting.ts +13 -14
  67. package/src/openai-realtime.ts +25 -47
  68. package/src/opencode.ts +24 -34
  69. package/src/session-handler.ts +91 -61
  70. package/src/system-message.ts +18 -4
  71. package/src/tools.ts +13 -39
  72. package/src/utils.ts +1 -4
  73. package/src/voice-handler.ts +34 -78
  74. package/src/voice.ts +11 -19
  75. package/src/xml.test.ts +1 -1
  76. package/src/xml.ts +3 -12
package/src/cli.ts CHANGED
@@ -41,7 +41,6 @@ import {
41
41
  import path from 'node:path'
42
42
  import fs from 'node:fs'
43
43
 
44
-
45
44
  import { createLogger } from './logger.js'
46
45
  import { spawn, spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
47
46
  import http from 'node:http'
@@ -60,11 +59,22 @@ async function killProcessOnPort(port: number): Promise<boolean> {
60
59
  try {
61
60
  if (isWindows) {
62
61
  // Windows: find PID using netstat, then kill
63
- const result = spawnSync('cmd', ['/c', `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`], {
64
- shell: false,
65
- encoding: 'utf-8',
66
- })
67
- const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p))
62
+ const result = spawnSync(
63
+ 'cmd',
64
+ [
65
+ '/c',
66
+ `for /f "tokens=5" %a in ('netstat -ano ^| findstr :${port} ^| findstr LISTENING') do @echo %a`,
67
+ ],
68
+ {
69
+ shell: false,
70
+ encoding: 'utf-8',
71
+ },
72
+ )
73
+ const pids = result.stdout
74
+ ?.trim()
75
+ .split('\n')
76
+ .map((p) => p.trim())
77
+ .filter((p) => /^\d+$/.test(p))
68
78
  // Filter out our own PID and take the first (oldest)
69
79
  const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid)
70
80
  if (targetPid) {
@@ -78,7 +88,11 @@ async function killProcessOnPort(port: number): Promise<boolean> {
78
88
  shell: false,
79
89
  encoding: 'utf-8',
80
90
  })
81
- const pids = result.stdout?.trim().split('\n').map((p) => p.trim()).filter((p) => /^\d+$/.test(p))
91
+ const pids = result.stdout
92
+ ?.trim()
93
+ .split('\n')
94
+ .map((p) => p.trim())
95
+ .filter((p) => /^\d+$/.test(p))
82
96
  // Filter out our own PID and take the first (oldest)
83
97
  const targetPid = pids?.find((p) => parseInt(p, 10) !== myPid)
84
98
  if (targetPid) {
@@ -104,7 +118,9 @@ async function checkSingleInstance(): Promise<void> {
104
118
  cliLogger.log(`Another kimaki instance detected for data dir: ${getDataDir()}`)
105
119
  await killProcessOnPort(lockPort)
106
120
  // Wait a moment for port to be released
107
- await new Promise((resolve) => { setTimeout(resolve, 500) })
121
+ await new Promise((resolve) => {
122
+ setTimeout(resolve, 500)
123
+ })
108
124
  }
109
125
  } catch {
110
126
  cliLogger.debug('No other kimaki instance detected on lock port')
@@ -127,7 +143,9 @@ async function startLockServer(): Promise<void> {
127
143
  if (err.code === 'EADDRINUSE') {
128
144
  cliLogger.log('Port still in use, retrying...')
129
145
  await killProcessOnPort(lockPort)
130
- await new Promise((r) => { setTimeout(r, 500) })
146
+ await new Promise((r) => {
147
+ setTimeout(r, 500)
148
+ })
131
149
  // Retry once
132
150
  server.listen(lockPort, '127.0.0.1')
133
151
  } else {
@@ -137,8 +155,6 @@ async function startLockServer(): Promise<void> {
137
155
  })
138
156
  }
139
157
 
140
-
141
-
142
158
  const EXIT_NO_RESTART = 64
143
159
 
144
160
  type Project = {
@@ -160,7 +176,11 @@ type CliOptions = {
160
176
  // Commands to skip when registering user commands (reserved names)
161
177
  const SKIP_USER_COMMANDS = ['init']
162
178
 
163
- async function registerCommands(token: string, appId: string, userCommands: OpencodeCommand[] = []) {
179
+ async function registerCommands(
180
+ token: string,
181
+ appId: string,
182
+ userCommands: OpencodeCommand[] = [],
183
+ ) {
164
184
  const commands = [
165
185
  new SlashCommandBuilder()
166
186
  .setName('resume')
@@ -179,19 +199,14 @@ async function registerCommands(token: string, appId: string, userCommands: Open
179
199
  .setName('session')
180
200
  .setDescription('Start a new OpenCode session')
181
201
  .addStringOption((option) => {
182
- option
183
- .setName('prompt')
184
- .setDescription('Prompt content for the session')
185
- .setRequired(true)
202
+ option.setName('prompt').setDescription('Prompt content for the session').setRequired(true)
186
203
 
187
204
  return option
188
205
  })
189
206
  .addStringOption((option) => {
190
207
  option
191
208
  .setName('files')
192
- .setDescription(
193
- 'Files to mention (comma or space separated; autocomplete)',
194
- )
209
+ .setDescription('Files to mention (comma or space separated; autocomplete)')
195
210
  .setAutocomplete(true)
196
211
  .setMaxLength(6000)
197
212
 
@@ -220,13 +235,23 @@ async function registerCommands(token: string, appId: string, userCommands: Open
220
235
  })
221
236
  .toJSON(),
222
237
  new SlashCommandBuilder()
223
- .setName('create-new-project')
224
- .setDescription('Create a new project folder, initialize git, and start a session')
238
+ .setName('remove-project')
239
+ .setDescription('Remove Discord channels for a project')
225
240
  .addStringOption((option) => {
226
241
  option
227
- .setName('name')
228
- .setDescription('Name for the new project folder')
242
+ .setName('project')
243
+ .setDescription('Select a project to remove')
229
244
  .setRequired(true)
245
+ .setAutocomplete(true)
246
+
247
+ return option
248
+ })
249
+ .toJSON(),
250
+ new SlashCommandBuilder()
251
+ .setName('create-new-project')
252
+ .setDescription('Create a new project folder, initialize git, and start a session')
253
+ .addStringOption((option) => {
254
+ option.setName('name').setDescription('Name for the new project folder').setRequired(true)
230
255
 
231
256
  return option
232
257
  })
@@ -259,10 +284,7 @@ async function registerCommands(token: string, appId: string, userCommands: Open
259
284
  .setName('queue')
260
285
  .setDescription('Queue a message to be sent after the current response finishes')
261
286
  .addStringOption((option) => {
262
- option
263
- .setName('message')
264
- .setDescription('The message to queue')
265
- .setRequired(true)
287
+ option.setName('message').setDescription('The message to queue').setRequired(true)
266
288
 
267
289
  return option
268
290
  })
@@ -294,7 +316,7 @@ async function registerCommands(token: string, appId: string, userCommands: Open
294
316
 
295
317
  commands.push(
296
318
  new SlashCommandBuilder()
297
- .setName(commandName)
319
+ .setName(commandName.slice(0, 32)) // Discord limits to 32 chars
298
320
  .setDescription(description.slice(0, 100)) // Discord limits to 100 chars
299
321
  .addStringOption((option) => {
300
322
  option
@@ -314,19 +336,13 @@ async function registerCommands(token: string, appId: string, userCommands: Open
314
336
  body: commands,
315
337
  })) as any[]
316
338
 
317
- cliLogger.info(
318
- `COMMANDS: Successfully registered ${data.length} slash commands`,
319
- )
339
+ cliLogger.info(`COMMANDS: Successfully registered ${data.length} slash commands`)
320
340
  } catch (error) {
321
- cliLogger.error(
322
- 'COMMANDS: Failed to register slash commands: ' + String(error),
323
- )
341
+ cliLogger.error('COMMANDS: Failed to register slash commands: ' + String(error))
324
342
  throw error
325
343
  }
326
344
  }
327
345
 
328
-
329
-
330
346
  async function run({ restart, addChannels }: CliOptions) {
331
347
  const forceSetup = Boolean(restart)
332
348
 
@@ -336,10 +352,7 @@ async function run({ restart, addChannels }: CliOptions) {
336
352
  const opencodeCheck = spawnSync('which', ['opencode'], { shell: true })
337
353
 
338
354
  if (opencodeCheck.status !== 0) {
339
- note(
340
- 'OpenCode CLI is required but not found in your PATH.',
341
- '⚠️ OpenCode Not Found',
342
- )
355
+ note('OpenCode CLI is required but not found in your PATH.', '⚠️ OpenCode Not Found')
343
356
 
344
357
  const shouldInstall = await confirm({
345
358
  message: 'Would you like to install OpenCode right now?',
@@ -391,10 +404,7 @@ async function run({ restart, addChannels }: CliOptions) {
391
404
  process.env.OPENCODE_PATH = installedPath
392
405
  } catch (error) {
393
406
  s.stop('Failed to install OpenCode CLI')
394
- cliLogger.error(
395
- 'Installation error:',
396
- error instanceof Error ? error.message : String(error),
397
- )
407
+ cliLogger.error('Installation error:', error instanceof Error ? error.message : String(error))
398
408
  process.exit(EXIT_NO_RESTART)
399
409
  }
400
410
  }
@@ -404,13 +414,10 @@ async function run({ restart, addChannels }: CliOptions) {
404
414
  let token: string
405
415
 
406
416
  const existingBot = db
407
- .prepare(
408
- 'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
409
- )
417
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
410
418
  .get() as { app_id: string; token: string } | undefined
411
419
 
412
- const shouldAddChannels =
413
- !existingBot?.token || forceSetup || Boolean(addChannels)
420
+ const shouldAddChannels = !existingBot?.token || forceSetup || Boolean(addChannels)
414
421
 
415
422
  if (existingBot && !forceSetup) {
416
423
  appId = existingBot.app_id
@@ -481,8 +488,7 @@ async function run({ restart, addChannels }: CliOptions) {
481
488
  'Step 3: Get Bot Token',
482
489
  )
483
490
  const tokenInput = await password({
484
- message:
485
- 'Enter your Discord Bot Token (from "Bot" section - click "Reset Token" if needed):',
491
+ message: 'Enter your Discord Bot Token (from "Bot" section - click "Reset Token" if needed):',
486
492
  validate(value) {
487
493
  if (!value) return 'Bot token is required'
488
494
  if (value.length < 50) return 'Invalid token format (too short)'
@@ -495,10 +501,7 @@ async function run({ restart, addChannels }: CliOptions) {
495
501
  }
496
502
  token = tokenInput
497
503
 
498
- note(
499
- `You can get a Gemini api Key at https://aistudio.google.com/apikey`,
500
- `Gemini API Key`,
501
- )
504
+ note(`You can get a Gemini api Key at https://aistudio.google.com/apikey`, `Gemini API Key`)
502
505
 
503
506
  const geminiApiKey = await password({
504
507
  message:
@@ -516,9 +519,10 @@ async function run({ restart, addChannels }: CliOptions) {
516
519
 
517
520
  // Store API key in database
518
521
  if (geminiApiKey) {
519
- db.prepare(
520
- 'INSERT OR REPLACE INTO bot_api_keys (app_id, gemini_api_key) VALUES (?, ?)',
521
- ).run(appId, geminiApiKey || null)
522
+ db.prepare('INSERT OR REPLACE INTO bot_api_keys (app_id, gemini_api_key) VALUES (?, ?)').run(
523
+ appId,
524
+ geminiApiKey || null,
525
+ )
522
526
  note('API key saved successfully', 'API Key Stored')
523
527
  }
524
528
 
@@ -565,9 +569,7 @@ async function run({ restart, addChannels }: CliOptions) {
565
569
  guild.roles
566
570
  .fetch()
567
571
  .then(async (roles) => {
568
- const existingRole = roles.find(
569
- (role) => role.name.toLowerCase() === 'kimaki',
570
- )
572
+ const existingRole = roles.find((role) => role.name.toLowerCase() === 'kimaki')
571
573
  if (existingRole) {
572
574
  // Move to bottom if not already there
573
575
  if (existingRole.position > 1) {
@@ -596,8 +598,7 @@ async function run({ restart, addChannels }: CliOptions) {
596
598
 
597
599
  const channels = await getChannelsWithDescriptions(guild)
598
600
  const kimakiChans = channels.filter(
599
- (ch) =>
600
- ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
601
+ (ch) => ch.kimakiDirectory && (!ch.kimakiApp || ch.kimakiApp === appId),
601
602
  )
602
603
 
603
604
  return { guild, channels: kimakiChans }
@@ -622,31 +623,26 @@ async function run({ restart, addChannels }: CliOptions) {
622
623
  s.stop('Connected to Discord!')
623
624
  } catch (error) {
624
625
  s.stop('Failed to connect to Discord')
625
- cliLogger.error(
626
- 'Error: ' + (error instanceof Error ? error.message : String(error)),
627
- )
626
+ cliLogger.error('Error: ' + (error instanceof Error ? error.message : String(error)))
628
627
  process.exit(EXIT_NO_RESTART)
629
628
  }
630
- db.prepare(
631
- 'INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)',
632
- ).run(appId, token)
629
+ db.prepare('INSERT OR REPLACE INTO bot_tokens (app_id, token) VALUES (?, ?)').run(appId, token)
633
630
 
634
631
  for (const { guild, channels } of kimakiChannels) {
635
632
  for (const channel of channels) {
636
633
  if (channel.kimakiDirectory) {
637
634
  db.prepare(
638
- 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
639
- ).run(channel.id, channel.kimakiDirectory, 'text')
635
+ 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
636
+ ).run(channel.id, channel.kimakiDirectory, 'text', channel.kimakiApp || null)
640
637
 
641
638
  const voiceChannel = guild.channels.cache.find(
642
- (ch) =>
643
- ch.type === ChannelType.GuildVoice && ch.name === channel.name,
639
+ (ch) => ch.type === ChannelType.GuildVoice && ch.name === channel.name,
644
640
  )
645
641
 
646
642
  if (voiceChannel) {
647
643
  db.prepare(
648
- 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type) VALUES (?, ?, ?)',
649
- ).run(voiceChannel.id, channel.kimakiDirectory, 'voice')
644
+ 'INSERT OR IGNORE INTO channel_directories (channel_id, directory, channel_type, app_id) VALUES (?, ?, ?, ?)',
645
+ ).run(voiceChannel.id, channel.kimakiDirectory, 'voice', channel.kimakiApp || null)
650
646
  }
651
647
  }
652
648
  }
@@ -657,11 +653,7 @@ async function run({ restart, addChannels }: CliOptions) {
657
653
  .flatMap(({ guild, channels }) =>
658
654
  channels.map((ch) => {
659
655
  const appInfo =
660
- ch.kimakiApp === appId
661
- ? ' (this bot)'
662
- : ch.kimakiApp
663
- ? ` (app: ${ch.kimakiApp})`
664
- : ''
656
+ ch.kimakiApp === appId ? ' (this bot)' : ch.kimakiApp ? ` (app: ${ch.kimakiApp})` : ''
665
657
  return `#${ch.name} in ${guild.name}: ${ch.kimakiDirectory}${appInfo}`
666
658
  }),
667
659
  )
@@ -679,13 +671,19 @@ async function run({ restart, addChannels }: CliOptions) {
679
671
 
680
672
  // Fetch projects and commands in parallel
681
673
  const [projects, allUserCommands] = await Promise.all([
682
- getClient().project.list({}).then((r) => r.data || []).catch((error) => {
683
- s.stop('Failed to fetch projects')
684
- cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
685
- discordClient.destroy()
686
- process.exit(EXIT_NO_RESTART)
687
- }),
688
- getClient().command.list({ query: { directory: currentDir } }).then((r) => r.data || []).catch(() => []),
674
+ getClient()
675
+ .project.list({})
676
+ .then((r) => r.data || [])
677
+ .catch((error) => {
678
+ s.stop('Failed to fetch projects')
679
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
680
+ discordClient.destroy()
681
+ process.exit(EXIT_NO_RESTART)
682
+ }),
683
+ getClient()
684
+ .command.list({ query: { directory: currentDir } })
685
+ .then((r) => r.data || [])
686
+ .catch(() => []),
689
687
  ])
690
688
 
691
689
  s.stop(`Found ${projects.length} OpenCode project(s)`)
@@ -711,16 +709,10 @@ async function run({ restart, addChannels }: CliOptions) {
711
709
  )
712
710
 
713
711
  if (availableProjects.length === 0) {
714
- note(
715
- 'All OpenCode projects already have Discord channels',
716
- 'No New Projects',
717
- )
712
+ note('All OpenCode projects already have Discord channels', 'No New Projects')
718
713
  }
719
714
 
720
- if (
721
- (!existingDirs?.length && availableProjects.length > 0) ||
722
- shouldAddChannels
723
- ) {
715
+ if ((!existingDirs?.length && availableProjects.length > 0) || shouldAddChannels) {
724
716
  const selectedProjects = await multiselect({
725
717
  message: 'Select projects to create Discord channels for:',
726
718
  options: availableProjects.map((project) => ({
@@ -781,17 +773,17 @@ async function run({ restart, addChannels }: CliOptions) {
781
773
  guildId: targetGuild.id,
782
774
  })
783
775
  } catch (error) {
784
- cliLogger.error(`Failed to create channels for ${path.basename(project.worktree)}:`, error)
776
+ cliLogger.error(
777
+ `Failed to create channels for ${path.basename(project.worktree)}:`,
778
+ error,
779
+ )
785
780
  }
786
781
  }
787
782
 
788
783
  s.stop(`Created ${createdChannels.length} channel(s)`)
789
784
 
790
785
  if (createdChannels.length > 0) {
791
- note(
792
- createdChannels.map((ch) => `#${ch.name}`).join('\n'),
793
- 'Created Channels',
794
- )
786
+ note(createdChannels.map((ch) => `#${ch.name}`).join('\n'), 'Created Channels')
795
787
  }
796
788
  }
797
789
  }
@@ -850,10 +842,7 @@ async function run({ restart, addChannels }: CliOptions) {
850
842
 
851
843
  if (allChannels.length > 0) {
852
844
  const channelLinks = allChannels
853
- .map(
854
- (ch) =>
855
- `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`,
856
- )
845
+ .map((ch) => `• #${ch.name}: https://discord.com/channels/${ch.guildId}/${ch.id}`)
857
846
  .join('\n')
858
847
 
859
848
  note(
@@ -862,63 +851,62 @@ async function run({ restart, addChannels }: CliOptions) {
862
851
  )
863
852
  }
864
853
 
865
- outro('✨ Setup complete!')
854
+ note(
855
+ 'Leave this process running to keep the bot active.\n\nIf you close this process or restart your machine, run `npx kimaki` again to start the bot.',
856
+ '⚠️ Keep Running',
857
+ )
858
+
859
+ outro('✨ Setup complete! Listening for new messages... do not close this process.')
866
860
  }
867
861
 
868
862
  cli
869
863
  .command('', 'Set up and run the Kimaki Discord bot')
870
864
  .option('--restart', 'Prompt for new credentials even if saved')
871
- .option(
872
- '--add-channels',
873
- 'Select OpenCode projects to create Discord channels before starting',
874
- )
875
- .option(
876
- '--data-dir <path>',
877
- 'Data directory for config and database (default: ~/.kimaki)',
878
- )
865
+ .option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
866
+ .option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
879
867
  .option('--install-url', 'Print the bot install URL and exit')
880
- .action(async (options: { restart?: boolean; addChannels?: boolean; dataDir?: string; installUrl?: boolean }) => {
881
- try {
882
- // Set data directory early, before any database access
883
- if (options.dataDir) {
884
- setDataDir(options.dataDir)
885
- cliLogger.log(`Using data directory: ${getDataDir()}`)
886
- }
868
+ .action(
869
+ async (options: {
870
+ restart?: boolean
871
+ addChannels?: boolean
872
+ dataDir?: string
873
+ installUrl?: boolean
874
+ }) => {
875
+ try {
876
+ // Set data directory early, before any database access
877
+ if (options.dataDir) {
878
+ setDataDir(options.dataDir)
879
+ cliLogger.log(`Using data directory: ${getDataDir()}`)
880
+ }
887
881
 
888
- if (options.installUrl) {
889
- const db = getDatabase()
890
- const existingBot = db
891
- .prepare(
892
- 'SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
893
- )
894
- .get() as { app_id: string } | undefined
882
+ if (options.installUrl) {
883
+ const db = getDatabase()
884
+ const existingBot = db
885
+ .prepare('SELECT app_id FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
886
+ .get() as { app_id: string } | undefined
895
887
 
896
- if (!existingBot) {
897
- cliLogger.error('No bot configured yet. Run `kimaki` first to set up.')
898
- process.exit(EXIT_NO_RESTART)
888
+ if (!existingBot) {
889
+ cliLogger.error('No bot configured yet. Run `kimaki` first to set up.')
890
+ process.exit(EXIT_NO_RESTART)
891
+ }
892
+
893
+ console.log(generateBotInstallUrl({ clientId: existingBot.app_id }))
894
+ process.exit(0)
899
895
  }
900
896
 
901
- console.log(generateBotInstallUrl({ clientId: existingBot.app_id }))
902
- process.exit(0)
897
+ await checkSingleInstance()
898
+ await startLockServer()
899
+ await run({
900
+ restart: options.restart,
901
+ addChannels: options.addChannels,
902
+ dataDir: options.dataDir,
903
+ })
904
+ } catch (error) {
905
+ cliLogger.error('Unhandled error:', error instanceof Error ? error.message : String(error))
906
+ process.exit(EXIT_NO_RESTART)
903
907
  }
904
-
905
- await checkSingleInstance()
906
- await startLockServer()
907
- await run({
908
- restart: options.restart,
909
- addChannels: options.addChannels,
910
- dataDir: options.dataDir,
911
- })
912
- } catch (error) {
913
- cliLogger.error(
914
- 'Unhandled error:',
915
- error instanceof Error ? error.message : String(error),
916
- )
917
- process.exit(EXIT_NO_RESTART)
918
- }
919
- })
920
-
921
-
908
+ },
909
+ )
922
910
 
923
911
  cli
924
912
  .command('upload-to-discord [...files]', 'Upload files to a Discord thread for a session')
@@ -957,9 +945,7 @@ cli
957
945
  }
958
946
 
959
947
  const botRow = db
960
- .prepare(
961
- 'SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1',
962
- )
948
+ .prepare('SELECT app_id, token FROM bot_tokens ORDER BY created_at DESC LIMIT 1')
963
949
  .get() as { app_id: string; token: string } | undefined
964
950
 
965
951
  if (!botRow) {
@@ -974,9 +960,12 @@ cli
974
960
  const buffer = fs.readFileSync(file)
975
961
 
976
962
  const formData = new FormData()
977
- formData.append('payload_json', JSON.stringify({
978
- attachments: [{ id: 0, filename: path.basename(file) }]
979
- }))
963
+ formData.append(
964
+ 'payload_json',
965
+ JSON.stringify({
966
+ attachments: [{ id: 0, filename: path.basename(file) }],
967
+ }),
968
+ )
980
969
  formData.append('files[0]', new Blob([buffer]), path.basename(file))
981
970
 
982
971
  const response = await fetch(
@@ -984,10 +973,10 @@ cli
984
973
  {
985
974
  method: 'POST',
986
975
  headers: {
987
- 'Authorization': `Bot ${botRow.token}`,
976
+ Authorization: `Bot ${botRow.token}`,
988
977
  },
989
978
  body: formData,
990
- }
979
+ },
991
980
  )
992
981
 
993
982
  if (!response.ok) {
@@ -1005,38 +994,61 @@ cli
1005
994
 
1006
995
  process.exit(0)
1007
996
  } catch (error) {
1008
- cliLogger.error(
1009
- 'Error:',
1010
- error instanceof Error ? error.message : String(error),
1011
- )
997
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
1012
998
  process.exit(EXIT_NO_RESTART)
1013
999
  }
1014
1000
  })
1015
1001
 
1016
-
1017
1002
  // Magic prefix used to identify bot-initiated sessions.
1018
1003
  // The running bot will recognize this prefix and start a session.
1019
1004
  const BOT_SESSION_PREFIX = '🤖 **Bot-initiated session**'
1005
+ // Notify-only prefix - bot won't start a session, just creates thread for notifications.
1006
+ // Reply to the thread to start a session with the notification as context.
1007
+ const BOT_NOTIFY_PREFIX = '📢 **Notification**'
1020
1008
 
1021
1009
  cli
1022
- .command('start-session', 'Start a new session in a Discord channel (creates thread, bot handles the rest)')
1010
+ .command(
1011
+ 'send',
1012
+ 'Send a message to Discord channel, creating a thread. Use --notify-only to skip AI session.',
1013
+ )
1014
+ .alias('start-session') // backwards compatibility
1023
1015
  .option('-c, --channel <channelId>', 'Discord channel ID')
1024
- .option('-p, --prompt <prompt>', 'Initial prompt for the session')
1016
+ .option('-d, --project <path>', 'Project directory (alternative to --channel)')
1017
+ .option('-p, --prompt <prompt>', 'Message content')
1025
1018
  .option('-n, --name [name]', 'Thread name (optional, defaults to prompt preview)')
1026
1019
  .option('-a, --app-id [appId]', 'Bot application ID (required if no local database)')
1027
- .action(async (options: { channel?: string; prompt?: string; name?: string; appId?: string }) => {
1028
- try {
1029
- const { channel: channelId, prompt, name, appId: optionAppId } = options
1020
+ .option('--notify-only', 'Create notification thread without starting AI session')
1021
+ .action(
1022
+ async (options: {
1023
+ channel?: string
1024
+ project?: string
1025
+ prompt?: string
1026
+ name?: string
1027
+ appId?: string
1028
+ notifyOnly?: boolean
1029
+ }) => {
1030
+ try {
1031
+ let { channel: channelId, prompt, name, appId: optionAppId, notifyOnly } = options
1032
+ const { project: projectPath } = options
1033
+
1034
+ // Get raw channel ID from argv to prevent JS number precision loss on large Discord IDs
1035
+ // cac parses large numbers and loses precision, so we extract the original string value
1036
+ if (channelId) {
1037
+ const channelArgIndex = process.argv.findIndex((arg) => arg === '--channel' || arg === '-c')
1038
+ if (channelArgIndex !== -1 && process.argv[channelArgIndex + 1]) {
1039
+ channelId = process.argv[channelArgIndex + 1]
1040
+ }
1041
+ }
1030
1042
 
1031
- if (!channelId) {
1032
- cliLogger.error('Channel ID is required. Use --channel <channelId>')
1033
- process.exit(EXIT_NO_RESTART)
1034
- }
1043
+ if (!channelId && !projectPath) {
1044
+ cliLogger.error('Either --channel or --project is required')
1045
+ process.exit(EXIT_NO_RESTART)
1046
+ }
1035
1047
 
1036
- if (!prompt) {
1037
- cliLogger.error('Prompt is required. Use --prompt <prompt>')
1038
- process.exit(EXIT_NO_RESTART)
1039
- }
1048
+ if (!prompt) {
1049
+ cliLogger.error('Prompt is required. Use --prompt <prompt>')
1050
+ process.exit(EXIT_NO_RESTART)
1051
+ }
1040
1052
 
1041
1053
  // Get bot token from env var or database
1042
1054
  const envToken = process.env.KIMAKI_BOT_TOKEN
@@ -1076,22 +1088,137 @@ cli
1076
1088
  }
1077
1089
 
1078
1090
  if (!botToken) {
1079
- cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.')
1091
+ cliLogger.error(
1092
+ 'No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.',
1093
+ )
1080
1094
  process.exit(EXIT_NO_RESTART)
1081
1095
  }
1082
1096
 
1083
1097
  const s = spinner()
1098
+
1099
+ // If --project provided, resolve to channel ID
1100
+ if (projectPath) {
1101
+ const absolutePath = path.resolve(projectPath)
1102
+
1103
+ if (!fs.existsSync(absolutePath)) {
1104
+ cliLogger.error(`Directory does not exist: ${absolutePath}`)
1105
+ process.exit(EXIT_NO_RESTART)
1106
+ }
1107
+
1108
+ s.start('Looking up channel for project...')
1109
+
1110
+ // Check if channel already exists for this directory or a parent directory
1111
+ // This allows running from subfolders of a registered project
1112
+ try {
1113
+ const db = getDatabase()
1114
+
1115
+ // Helper to find channel for a path (prefers current bot's channel)
1116
+ const findChannelForPath = (dirPath: string): { channel_id: string; directory: string } | undefined => {
1117
+ const withAppId = db
1118
+ .prepare(
1119
+ 'SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ? AND app_id = ?',
1120
+ )
1121
+ .get(dirPath, 'text', appId) as { channel_id: string; directory: string } | undefined
1122
+ if (withAppId) return withAppId
1123
+
1124
+ return db
1125
+ .prepare(
1126
+ 'SELECT channel_id, directory FROM channel_directories WHERE directory = ? AND channel_type = ?',
1127
+ )
1128
+ .get(dirPath, 'text') as { channel_id: string; directory: string } | undefined
1129
+ }
1130
+
1131
+ // Try exact match first, then walk up parent directories
1132
+ let existingChannel: { channel_id: string; directory: string } | undefined
1133
+ let searchPath = absolutePath
1134
+ while (searchPath !== path.dirname(searchPath)) {
1135
+ existingChannel = findChannelForPath(searchPath)
1136
+ if (existingChannel) break
1137
+ searchPath = path.dirname(searchPath)
1138
+ }
1139
+
1140
+ if (existingChannel) {
1141
+ channelId = existingChannel.channel_id
1142
+ if (existingChannel.directory !== absolutePath) {
1143
+ s.message(`Found parent project channel: ${existingChannel.directory}`)
1144
+ } else {
1145
+ s.message(`Found existing channel: ${channelId}`)
1146
+ }
1147
+ } else {
1148
+ // Need to create a new channel
1149
+ s.message('Creating new channel...')
1150
+
1151
+ if (!appId) {
1152
+ s.stop('Missing app ID')
1153
+ cliLogger.error(
1154
+ 'App ID is required to create channels. Use --app-id or run `kimaki` first.',
1155
+ )
1156
+ process.exit(EXIT_NO_RESTART)
1157
+ }
1158
+
1159
+ const client = await createDiscordClient()
1160
+
1161
+ await new Promise<void>((resolve, reject) => {
1162
+ client.once(Events.ClientReady, () => {
1163
+ resolve()
1164
+ })
1165
+ client.once(Events.Error, reject)
1166
+ client.login(botToken)
1167
+ })
1168
+
1169
+ // Get guild from existing channels or first available
1170
+ const guild = await (async () => {
1171
+ // Try to find a guild from existing channels belonging to this bot
1172
+ const existingChannelRow = db
1173
+ .prepare(
1174
+ 'SELECT channel_id FROM channel_directories WHERE app_id = ? ORDER BY created_at DESC LIMIT 1',
1175
+ )
1176
+ .get(appId) as { channel_id: string } | undefined
1177
+
1178
+ if (existingChannelRow) {
1179
+ try {
1180
+ const ch = await client.channels.fetch(existingChannelRow.channel_id)
1181
+ if (ch && 'guild' in ch && ch.guild) {
1182
+ return ch.guild
1183
+ }
1184
+ } catch {
1185
+ // Channel might be deleted, continue
1186
+ }
1187
+ }
1188
+ // Fall back to first guild the bot is in
1189
+ const firstGuild = client.guilds.cache.first()
1190
+ if (!firstGuild) {
1191
+ throw new Error('No guild found. Add the bot to a server first.')
1192
+ }
1193
+ return firstGuild
1194
+ })()
1195
+
1196
+ const { textChannelId } = await createProjectChannels({
1197
+ guild,
1198
+ projectDirectory: absolutePath,
1199
+ appId,
1200
+ botName: client.user?.username,
1201
+ })
1202
+
1203
+ channelId = textChannelId
1204
+ s.message(`Created channel: ${channelId}`)
1205
+
1206
+ client.destroy()
1207
+ }
1208
+ } catch (e) {
1209
+ s.stop('Failed to resolve project')
1210
+ throw e
1211
+ }
1212
+ }
1213
+
1084
1214
  s.start('Fetching channel info...')
1085
1215
 
1086
1216
  // Get channel info to extract directory from topic
1087
- const channelResponse = await fetch(
1088
- `https://discord.com/api/v10/channels/${channelId}`,
1089
- {
1090
- headers: {
1091
- 'Authorization': `Bot ${botToken}`,
1092
- },
1093
- }
1094
- )
1217
+ const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
1218
+ headers: {
1219
+ Authorization: `Bot ${botToken}`,
1220
+ },
1221
+ })
1095
1222
 
1096
1223
  if (!channelResponse.ok) {
1097
1224
  const error = await channelResponse.text()
@@ -1099,7 +1226,7 @@ cli
1099
1226
  throw new Error(`Discord API error: ${channelResponse.status} - ${error}`)
1100
1227
  }
1101
1228
 
1102
- const channelData = await channelResponse.json() as {
1229
+ const channelData = (await channelResponse.json()) as {
1103
1230
  id: string
1104
1231
  name: string
1105
1232
  topic?: string
@@ -1108,7 +1235,9 @@ cli
1108
1235
 
1109
1236
  if (!channelData.topic) {
1110
1237
  s.stop('Channel has no topic')
1111
- throw new Error(`Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`)
1238
+ throw new Error(
1239
+ `Channel #${channelData.name} has no topic. It must have a <kimaki.directory> tag.`,
1240
+ )
1112
1241
  }
1113
1242
 
1114
1243
  const extracted = extractTagsArrays({
@@ -1127,25 +1256,28 @@ cli
1127
1256
  // Verify app ID matches if both are present
1128
1257
  if (channelAppId && appId && channelAppId !== appId) {
1129
1258
  s.stop('Channel belongs to different bot')
1130
- throw new Error(`Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`)
1259
+ throw new Error(
1260
+ `Channel belongs to a different bot (expected: ${appId}, got: ${channelAppId})`,
1261
+ )
1131
1262
  }
1132
1263
 
1133
1264
  s.message('Creating starter message...')
1134
1265
 
1135
1266
  // Create starter message with magic prefix
1136
- // The full prompt goes in the message so the bot can read it
1267
+ // BOT_SESSION_PREFIX triggers AI session, BOT_NOTIFY_PREFIX is notification-only
1268
+ const messagePrefix = notifyOnly ? BOT_NOTIFY_PREFIX : BOT_SESSION_PREFIX
1137
1269
  const starterMessageResponse = await fetch(
1138
1270
  `https://discord.com/api/v10/channels/${channelId}/messages`,
1139
1271
  {
1140
1272
  method: 'POST',
1141
1273
  headers: {
1142
- 'Authorization': `Bot ${botToken}`,
1274
+ Authorization: `Bot ${botToken}`,
1143
1275
  'Content-Type': 'application/json',
1144
1276
  },
1145
1277
  body: JSON.stringify({
1146
- content: `${BOT_SESSION_PREFIX}\n${prompt}`,
1278
+ content: `${messagePrefix}\n${prompt}`,
1147
1279
  }),
1148
- }
1280
+ },
1149
1281
  )
1150
1282
 
1151
1283
  if (!starterMessageResponse.ok) {
@@ -1154,7 +1286,7 @@ cli
1154
1286
  throw new Error(`Discord API error: ${starterMessageResponse.status} - ${error}`)
1155
1287
  }
1156
1288
 
1157
- const starterMessage = await starterMessageResponse.json() as { id: string }
1289
+ const starterMessage = (await starterMessageResponse.json()) as { id: string }
1158
1290
 
1159
1291
  s.message('Creating thread...')
1160
1292
 
@@ -1165,14 +1297,14 @@ cli
1165
1297
  {
1166
1298
  method: 'POST',
1167
1299
  headers: {
1168
- 'Authorization': `Bot ${botToken}`,
1300
+ Authorization: `Bot ${botToken}`,
1169
1301
  'Content-Type': 'application/json',
1170
1302
  },
1171
1303
  body: JSON.stringify({
1172
1304
  name: threadName.slice(0, 100),
1173
1305
  auto_archive_duration: 1440, // 1 day
1174
1306
  }),
1175
- }
1307
+ },
1176
1308
  )
1177
1309
 
1178
1310
  if (!threadResponse.ok) {
@@ -1181,29 +1313,26 @@ cli
1181
1313
  throw new Error(`Discord API error: ${threadResponse.status} - ${error}`)
1182
1314
  }
1183
1315
 
1184
- const threadData = await threadResponse.json() as { id: string; name: string }
1316
+ const threadData = (await threadResponse.json()) as { id: string; name: string }
1185
1317
 
1186
1318
  s.stop('Thread created!')
1187
1319
 
1188
1320
  const threadUrl = `https://discord.com/channels/${channelData.guild_id}/${threadData.id}`
1189
1321
 
1190
- note(
1191
- `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`,
1192
- '✅ Thread Created',
1193
- )
1322
+ const successMessage = notifyOnly
1323
+ ? `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nNotification created. Reply to start a session.\n\nURL: ${threadUrl}`
1324
+ : `Thread: ${threadData.name}\nDirectory: ${projectDirectory}\n\nThe running bot will pick this up and start the session.\n\nURL: ${threadUrl}`
1325
+
1326
+ note(successMessage, '✅ Thread Created')
1194
1327
 
1195
1328
  console.log(threadUrl)
1196
1329
 
1197
1330
  process.exit(0)
1198
1331
  } catch (error) {
1199
- cliLogger.error(
1200
- 'Error:',
1201
- error instanceof Error ? error.message : String(error),
1202
- )
1332
+ cliLogger.error('Error:', error instanceof Error ? error.message : String(error))
1203
1333
  process.exit(EXIT_NO_RESTART)
1204
1334
  }
1205
1335
  })
1206
1336
 
1207
-
1208
1337
  cli.help()
1209
1338
  cli.parse()