kimaki 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { Events, ChannelType, REST, Routes, SlashCommandBuilder, } from 'discord
7
7
  import path from 'node:path';
8
8
  import fs from 'node:fs';
9
9
  import { createLogger } from './logger.js';
10
+ import { spawnSync, execSync } from 'node:child_process';
10
11
  const cliLogger = createLogger('CLI');
11
12
  const cli = cac('kimaki');
12
13
  process.title = 'kimaki';
@@ -75,6 +76,56 @@ async function ensureKimakiCategory(guild) {
75
76
  async function run({ restart, addChannels }) {
76
77
  const forceSetup = Boolean(restart);
77
78
  intro('šŸ¤– Discord Bot Setup');
79
+ // Step 0: Check if OpenCode CLI is available
80
+ const opencodeCheck = spawnSync('which', ['opencode'], { shell: true });
81
+ if (opencodeCheck.status !== 0) {
82
+ note('OpenCode CLI is required but not found in your PATH.', 'āš ļø OpenCode Not Found');
83
+ const shouldInstall = await confirm({
84
+ message: 'Would you like to install OpenCode right now?',
85
+ });
86
+ if (isCancel(shouldInstall) || !shouldInstall) {
87
+ cancel('OpenCode CLI is required to run this bot');
88
+ process.exit(0);
89
+ }
90
+ const s = spinner();
91
+ s.start('Installing OpenCode CLI...');
92
+ try {
93
+ execSync('curl -fsSL https://opencode.ai/install | bash', {
94
+ stdio: 'inherit',
95
+ shell: '/bin/bash',
96
+ });
97
+ s.stop('OpenCode CLI installed successfully!');
98
+ // The install script adds opencode to PATH via shell configuration
99
+ // For the current process, we need to check common installation paths
100
+ const possiblePaths = [
101
+ `${process.env.HOME}/.local/bin/opencode`,
102
+ `${process.env.HOME}/.opencode/bin/opencode`,
103
+ '/usr/local/bin/opencode',
104
+ '/opt/opencode/bin/opencode',
105
+ ];
106
+ const installedPath = possiblePaths.find((p) => {
107
+ try {
108
+ fs.accessSync(p, fs.constants.F_OK);
109
+ return true;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ });
115
+ if (!installedPath) {
116
+ note('OpenCode was installed but may not be available in this session.\n' +
117
+ 'Please restart your terminal and run this command again.', 'āš ļø Restart Required');
118
+ process.exit(0);
119
+ }
120
+ // For subsequent spawn calls in this session, we can use the full path
121
+ process.env.OPENCODE_PATH = installedPath;
122
+ }
123
+ catch (error) {
124
+ s.stop('Failed to install OpenCode CLI');
125
+ cliLogger.error('Installation error:', error instanceof Error ? error.message : String(error));
126
+ process.exit(EXIT_NO_RESTART);
127
+ }
128
+ }
78
129
  const db = getDatabase();
79
130
  let appId;
80
131
  let token;
@@ -231,7 +282,7 @@ async function run({ restart, addChannels }) {
231
282
  s.start('Fetching OpenCode projects...');
232
283
  let projects = [];
233
284
  try {
234
- const projectsResponse = await getClient().project.list();
285
+ const projectsResponse = await getClient().project.list({});
235
286
  if (!projectsResponse.data) {
236
287
  throw new Error('Failed to fetch projects');
237
288
  }
@@ -270,23 +321,23 @@ async function run({ restart, addChannels }) {
270
321
  }
271
322
  if (guilds.length === 1) {
272
323
  targetGuild = guilds[0];
324
+ note(`Using server: ${targetGuild.name}`, 'Server Selected');
273
325
  }
274
326
  else {
275
- const guildId = await text({
276
- message: 'Enter the Discord server ID to create channels in:',
277
- placeholder: guilds[0]?.id,
278
- validate(value) {
279
- if (!value)
280
- return 'Server ID is required';
281
- if (!guilds.find((g) => g.id === value))
282
- return 'Invalid server ID';
283
- },
327
+ const guildSelection = await multiselect({
328
+ message: 'Select a Discord server to create channels in:',
329
+ options: guilds.map((guild) => ({
330
+ value: guild.id,
331
+ label: `${guild.name} (${guild.memberCount} members)`,
332
+ })),
333
+ required: true,
334
+ maxItems: 1,
284
335
  });
285
- if (isCancel(guildId)) {
336
+ if (isCancel(guildSelection)) {
286
337
  cancel('Setup cancelled');
287
338
  process.exit(0);
288
339
  }
289
- targetGuild = guilds.find((g) => g.id === guildId);
340
+ targetGuild = guilds.find((g) => g.id === guildSelection[0]);
290
341
  }
291
342
  s.start('Creating Discord channels...');
292
343
  for (const projectId of selectedProjects) {
@@ -281,7 +281,7 @@ async function setupVoiceHandling({ connection, guildId, channelId, appId, }) {
281
281
  });
282
282
  });
283
283
  }
284
- export function frameMono16khz() {
284
+ function frameMono16khz() {
285
285
  // Hardcoded: 16 kHz, mono, 16-bit PCM, 20 ms -> 320 samples -> 640 bytes
286
286
  const FRAME_BYTES = (100 /*ms*/ * 16_000 /*Hz*/ * 1 /*channels*/ * 2) /*bytes per sample*/ /
287
287
  1000;
@@ -608,7 +608,8 @@ export async function initializeOpencodeForDirectory(directory) {
608
608
  // console.log(
609
609
  // `[OPENCODE] Starting new server on port ${port} for directory: ${directory}`,
610
610
  // )
611
- const serverProcess = spawn('opencode', ['serve', '--port', port.toString()], {
611
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode';
612
+ const serverProcess = spawn(opencodeCommand, ['serve', '--port', port.toString()], {
612
613
  stdio: 'pipe',
613
614
  detached: false,
614
615
  cwd: directory,
@@ -648,7 +649,13 @@ export async function initializeOpencodeForDirectory(directory) {
648
649
  }
649
650
  });
650
651
  await waitForServer(port);
651
- const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` });
652
+ const client = createOpencodeClient({
653
+ baseUrl: `http://localhost:${port}`,
654
+ fetch: (request) => fetch(request, {
655
+ // @ts-ignore
656
+ timeout: false,
657
+ }),
658
+ });
652
659
  opencodeServers.set(directory, {
653
660
  process: serverProcess,
654
661
  client,
@@ -730,7 +737,7 @@ function formatPart(part) {
730
737
  const icon = part.state.status === 'completed'
731
738
  ? 'ā—¼ļøŽ'
732
739
  : part.state.status === 'error'
733
- ? 'āœ–ļø'
740
+ ? '⨯'
734
741
  : '';
735
742
  const title = `${icon} ${part.tool} ${toolTitle}`;
736
743
  let text = title;
@@ -1400,11 +1407,147 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1400
1407
  await interaction.respond([]);
1401
1408
  }
1402
1409
  }
1410
+ else if (interaction.commandName === 'session') {
1411
+ const focusedOption = interaction.options.getFocused(true);
1412
+ if (focusedOption.name === 'files') {
1413
+ const focusedValue = focusedOption.value;
1414
+ // Split by comma to handle multiple files
1415
+ const parts = focusedValue.split(',');
1416
+ const previousFiles = parts
1417
+ .slice(0, -1)
1418
+ .map((f) => f.trim())
1419
+ .filter((f) => f);
1420
+ const currentQuery = (parts[parts.length - 1] || '').trim();
1421
+ // Get the channel's project directory from its topic
1422
+ let projectDirectory;
1423
+ if (interaction.channel &&
1424
+ interaction.channel.type === ChannelType.GuildText) {
1425
+ const textChannel = resolveTextChannel(interaction.channel);
1426
+ if (textChannel) {
1427
+ const { projectDirectory: directory, channelAppId } = getKimakiMetadata(textChannel);
1428
+ if (channelAppId && channelAppId !== currentAppId) {
1429
+ await interaction.respond([]);
1430
+ return;
1431
+ }
1432
+ projectDirectory = directory;
1433
+ }
1434
+ }
1435
+ if (!projectDirectory) {
1436
+ await interaction.respond([]);
1437
+ return;
1438
+ }
1439
+ try {
1440
+ // Get OpenCode client for this directory
1441
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
1442
+ // Use find.files to search for files based on current query
1443
+ const response = await getClient().find.files({
1444
+ query: {
1445
+ query: currentQuery || '',
1446
+ },
1447
+ });
1448
+ // Get file paths from the response
1449
+ const files = response.data || [];
1450
+ // Build the prefix with previous files
1451
+ const prefix = previousFiles.length > 0
1452
+ ? previousFiles.join(', ') + ', '
1453
+ : '';
1454
+ // Map to Discord autocomplete format
1455
+ const choices = files
1456
+ .slice(0, 25) // Discord limit
1457
+ .map((file) => {
1458
+ const fullValue = prefix + file;
1459
+ // Get all basenames for display
1460
+ const allFiles = [...previousFiles, file];
1461
+ const allBasenames = allFiles.map((f) => f.split('/').pop() || f);
1462
+ let displayName = allBasenames.join(', ');
1463
+ // Truncate if too long
1464
+ if (displayName.length > 100) {
1465
+ displayName = '…' + displayName.slice(-97);
1466
+ }
1467
+ return {
1468
+ name: displayName,
1469
+ value: fullValue,
1470
+ };
1471
+ });
1472
+ await interaction.respond(choices);
1473
+ }
1474
+ catch (error) {
1475
+ voiceLogger.error('[AUTOCOMPLETE] Error fetching files:', error);
1476
+ await interaction.respond([]);
1477
+ }
1478
+ }
1479
+ }
1403
1480
  }
1404
1481
  // Handle slash commands
1405
1482
  if (interaction.isChatInputCommand()) {
1406
1483
  const command = interaction;
1407
- if (command.commandName === 'resume') {
1484
+ if (command.commandName === 'session') {
1485
+ await command.deferReply({ ephemeral: false });
1486
+ const prompt = command.options.getString('prompt', true);
1487
+ const filesString = command.options.getString('files') || '';
1488
+ const channel = command.channel;
1489
+ if (!channel || channel.type !== ChannelType.GuildText) {
1490
+ await command.editReply('This command can only be used in text channels');
1491
+ return;
1492
+ }
1493
+ const textChannel = channel;
1494
+ // Get project directory from channel topic
1495
+ let projectDirectory;
1496
+ let channelAppId;
1497
+ if (textChannel.topic) {
1498
+ const extracted = extractTagsArrays({
1499
+ xml: textChannel.topic,
1500
+ tags: ['kimaki.directory', 'kimaki.app'],
1501
+ });
1502
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
1503
+ channelAppId = extracted['kimaki.app']?.[0]?.trim();
1504
+ }
1505
+ // Check if this channel belongs to current bot instance
1506
+ if (channelAppId && channelAppId !== currentAppId) {
1507
+ await command.editReply('This channel is not configured for this bot');
1508
+ return;
1509
+ }
1510
+ if (!projectDirectory) {
1511
+ await command.editReply('This channel is not configured with a project directory');
1512
+ return;
1513
+ }
1514
+ if (!fs.existsSync(projectDirectory)) {
1515
+ await command.editReply(`Directory does not exist: ${projectDirectory}`);
1516
+ return;
1517
+ }
1518
+ try {
1519
+ // Initialize OpenCode client for the directory
1520
+ const getClient = await initializeOpencodeForDirectory(projectDirectory);
1521
+ // Process file mentions - split by comma only
1522
+ const files = filesString
1523
+ .split(',')
1524
+ .map((f) => f.trim())
1525
+ .filter((f) => f);
1526
+ // Build the full prompt with file mentions
1527
+ let fullPrompt = prompt;
1528
+ if (files.length > 0) {
1529
+ fullPrompt = `${prompt}\n\n@${files.join(' @')}`;
1530
+ }
1531
+ // Send a message first, then create thread from it
1532
+ const starterMessage = await textChannel.send({
1533
+ content: `šŸš€ **Starting OpenCode session**\nšŸ“ ${prompt.slice(0, 200)}${prompt.length > 200 ? '…' : ''}${files.length > 0 ? `\nšŸ“Ž Files: ${files.join(', ')}` : ''}`,
1534
+ });
1535
+ // Create thread from the message
1536
+ const thread = await starterMessage.startThread({
1537
+ name: prompt.slice(0, 100),
1538
+ autoArchiveDuration: 1440, // 24 hours
1539
+ reason: 'OpenCode session',
1540
+ });
1541
+ await command.editReply(`Created new session in ${thread.toString()}`);
1542
+ // Start the OpenCode session
1543
+ await handleOpencodeSession(fullPrompt, thread, projectDirectory);
1544
+ }
1545
+ catch (error) {
1546
+ voiceLogger.error('[SESSION] Error:', error);
1547
+ await command.editReply(`Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`);
1548
+ }
1549
+ }
1550
+ else if (command.commandName === 'resume') {
1408
1551
  await command.deferReply({ ephemeral: false });
1409
1552
  const sessionId = command.options.getString('session', true);
1410
1553
  const channel = command.channel;
@@ -1476,11 +1619,11 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
1476
1619
  for (const message of messages) {
1477
1620
  if (message.info.role === 'user') {
1478
1621
  // Render user messages
1479
- const userParts = message.parts.filter((p) => p.type === 'text');
1622
+ const userParts = message.parts.filter((p) => p.type === 'text' && !p.synthetic);
1480
1623
  const userTexts = userParts
1481
1624
  .map((p) => {
1482
- if (typeof p.text === 'string') {
1483
- return extractNonXmlContent(p.text);
1625
+ if (p.type === 'text') {
1626
+ return p.text;
1484
1627
  }
1485
1628
  return '';
1486
1629
  })
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.1.5",
5
+ "version": "0.2.1",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
package/src/cli.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  import path from 'node:path'
36
36
  import fs from 'node:fs'
37
37
  import { createLogger } from './logger.js'
38
+ import { spawnSync, execSync, type ExecSyncOptions } from 'node:child_process'
38
39
 
39
40
  const cliLogger = createLogger('CLI')
40
41
  const cli = cac('kimaki')
@@ -142,6 +143,73 @@ async function run({ restart, addChannels }: CliOptions) {
142
143
 
143
144
  intro('šŸ¤– Discord Bot Setup')
144
145
 
146
+ // Step 0: Check if OpenCode CLI is available
147
+ const opencodeCheck = spawnSync('which', ['opencode'], { shell: true })
148
+
149
+ if (opencodeCheck.status !== 0) {
150
+ note(
151
+ 'OpenCode CLI is required but not found in your PATH.',
152
+ 'āš ļø OpenCode Not Found',
153
+ )
154
+
155
+ const shouldInstall = await confirm({
156
+ message: 'Would you like to install OpenCode right now?',
157
+ })
158
+
159
+ if (isCancel(shouldInstall) || !shouldInstall) {
160
+ cancel('OpenCode CLI is required to run this bot')
161
+ process.exit(0)
162
+ }
163
+
164
+ const s = spinner()
165
+ s.start('Installing OpenCode CLI...')
166
+
167
+ try {
168
+ execSync('curl -fsSL https://opencode.ai/install | bash', {
169
+ stdio: 'inherit',
170
+ shell: '/bin/bash',
171
+ })
172
+ s.stop('OpenCode CLI installed successfully!')
173
+
174
+ // The install script adds opencode to PATH via shell configuration
175
+ // For the current process, we need to check common installation paths
176
+ const possiblePaths = [
177
+ `${process.env.HOME}/.local/bin/opencode`,
178
+ `${process.env.HOME}/.opencode/bin/opencode`,
179
+ '/usr/local/bin/opencode',
180
+ '/opt/opencode/bin/opencode',
181
+ ]
182
+
183
+ const installedPath = possiblePaths.find((p) => {
184
+ try {
185
+ fs.accessSync(p, fs.constants.F_OK)
186
+ return true
187
+ } catch {
188
+ return false
189
+ }
190
+ })
191
+
192
+ if (!installedPath) {
193
+ note(
194
+ 'OpenCode was installed but may not be available in this session.\n' +
195
+ 'Please restart your terminal and run this command again.',
196
+ 'āš ļø Restart Required',
197
+ )
198
+ process.exit(0)
199
+ }
200
+
201
+ // For subsequent spawn calls in this session, we can use the full path
202
+ process.env.OPENCODE_PATH = installedPath
203
+ } catch (error) {
204
+ s.stop('Failed to install OpenCode CLI')
205
+ cliLogger.error(
206
+ 'Installation error:',
207
+ error instanceof Error ? error.message : String(error),
208
+ )
209
+ process.exit(EXIT_NO_RESTART)
210
+ }
211
+ }
212
+
145
213
  const db = getDatabase()
146
214
  let appId: string
147
215
  let token: string
@@ -377,7 +445,7 @@ async function run({ restart, addChannels }: CliOptions) {
377
445
  let projects: Project[] = []
378
446
 
379
447
  try {
380
- const projectsResponse = await getClient().project.list()
448
+ const projectsResponse = await getClient().project.list({})
381
449
  if (!projectsResponse.data) {
382
450
  throw new Error('Failed to fetch projects')
383
451
  }
@@ -436,22 +504,24 @@ async function run({ restart, addChannels }: CliOptions) {
436
504
 
437
505
  if (guilds.length === 1) {
438
506
  targetGuild = guilds[0]!
507
+ note(`Using server: ${targetGuild.name}`, 'Server Selected')
439
508
  } else {
440
- const guildId = await text({
441
- message: 'Enter the Discord server ID to create channels in:',
442
- placeholder: guilds[0]?.id,
443
- validate(value) {
444
- if (!value) return 'Server ID is required'
445
- if (!guilds.find((g) => g.id === value)) return 'Invalid server ID'
446
- },
509
+ const guildSelection = await multiselect({
510
+ message: 'Select a Discord server to create channels in:',
511
+ options: guilds.map((guild) => ({
512
+ value: guild.id,
513
+ label: `${guild.name} (${guild.memberCount} members)`,
514
+ })),
515
+ required: true,
516
+ maxItems: 1,
447
517
  })
448
518
 
449
- if (isCancel(guildId)) {
519
+ if (isCancel(guildSelection)) {
450
520
  cancel('Setup cancelled')
451
521
  process.exit(0)
452
522
  }
453
523
 
454
- targetGuild = guilds.find((g) => g.id === guildId)!
524
+ targetGuild = guilds.find((g) => g.id === guildSelection[0])!
455
525
  }
456
526
 
457
527
  s.start('Creating Discord channels...')
package/src/discordBot.ts CHANGED
@@ -403,7 +403,7 @@ async function setupVoiceHandling({
403
403
  })
404
404
  }
405
405
 
406
- export function frameMono16khz(): Transform {
406
+ function frameMono16khz(): Transform {
407
407
  // Hardcoded: 16 kHz, mono, 16-bit PCM, 20 ms -> 320 samples -> 640 bytes
408
408
  const FRAME_BYTES =
409
409
  (100 /*ms*/ * 16_000 /*Hz*/ * 1 /*channels*/ * 2) /*bytes per sample*/ /
@@ -822,8 +822,10 @@ export async function initializeOpencodeForDirectory(directory: string) {
822
822
  // `[OPENCODE] Starting new server on port ${port} for directory: ${directory}`,
823
823
  // )
824
824
 
825
+ const opencodeCommand = process.env.OPENCODE_PATH || 'opencode'
826
+
825
827
  const serverProcess = spawn(
826
- 'opencode',
828
+ opencodeCommand,
827
829
  ['serve', '--port', port.toString()],
828
830
  {
829
831
  stdio: 'pipe',
@@ -876,7 +878,15 @@ export async function initializeOpencodeForDirectory(directory: string) {
876
878
  })
877
879
 
878
880
  await waitForServer(port)
879
- const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` })
881
+
882
+ const client = createOpencodeClient({
883
+ baseUrl: `http://localhost:${port}`,
884
+ fetch: (request: Request) =>
885
+ fetch(request, {
886
+ // @ts-ignore
887
+ timeout: false,
888
+ }),
889
+ })
880
890
 
881
891
  opencodeServers.set(directory, {
882
892
  process: serverProcess,
@@ -970,7 +980,7 @@ function formatPart(part: Part): string {
970
980
  part.state.status === 'completed'
971
981
  ? 'ā—¼ļøŽ'
972
982
  : part.state.status === 'error'
973
- ? 'āœ–ļø'
983
+ ? '⨯'
974
984
  : ''
975
985
  const title = `${icon} ${part.tool} ${toolTitle}`
976
986
 
@@ -1547,7 +1557,6 @@ export async function startDiscordBot({
1547
1557
  discordLogger.log(`Bot Application ID (provided): ${currentAppId}`)
1548
1558
  }
1549
1559
 
1550
-
1551
1560
  // List all guilds and channels that belong to this bot
1552
1561
  for (const guild of c.guilds.cache.values()) {
1553
1562
  discordLogger.log(`${guild.name} (${guild.id})`)
@@ -1868,6 +1877,93 @@ export async function startDiscordBot({
1868
1877
  )
1869
1878
  await interaction.respond([])
1870
1879
  }
1880
+ } else if (interaction.commandName === 'session') {
1881
+ const focusedOption = interaction.options.getFocused(true)
1882
+
1883
+ if (focusedOption.name === 'files') {
1884
+ const focusedValue = focusedOption.value
1885
+
1886
+ // Split by comma to handle multiple files
1887
+ const parts = focusedValue.split(',')
1888
+ const previousFiles = parts
1889
+ .slice(0, -1)
1890
+ .map((f) => f.trim())
1891
+ .filter((f) => f)
1892
+ const currentQuery = (parts[parts.length - 1] || '').trim()
1893
+
1894
+ // Get the channel's project directory from its topic
1895
+ let projectDirectory: string | undefined
1896
+ if (
1897
+ interaction.channel &&
1898
+ interaction.channel.type === ChannelType.GuildText
1899
+ ) {
1900
+ const textChannel = resolveTextChannel(
1901
+ interaction.channel as TextChannel | ThreadChannel | null,
1902
+ )
1903
+ if (textChannel) {
1904
+ const { projectDirectory: directory, channelAppId } =
1905
+ getKimakiMetadata(textChannel)
1906
+ if (channelAppId && channelAppId !== currentAppId) {
1907
+ await interaction.respond([])
1908
+ return
1909
+ }
1910
+ projectDirectory = directory
1911
+ }
1912
+ }
1913
+
1914
+ if (!projectDirectory) {
1915
+ await interaction.respond([])
1916
+ return
1917
+ }
1918
+
1919
+ try {
1920
+ // Get OpenCode client for this directory
1921
+ const getClient =
1922
+ await initializeOpencodeForDirectory(projectDirectory)
1923
+
1924
+ // Use find.files to search for files based on current query
1925
+ const response = await getClient().find.files({
1926
+ query: {
1927
+ query: currentQuery || '',
1928
+ },
1929
+ })
1930
+
1931
+ // Get file paths from the response
1932
+ const files = response.data || []
1933
+
1934
+ // Build the prefix with previous files
1935
+ const prefix =
1936
+ previousFiles.length > 0
1937
+ ? previousFiles.join(', ') + ', '
1938
+ : ''
1939
+
1940
+ // Map to Discord autocomplete format
1941
+ const choices = files
1942
+ .slice(0, 25) // Discord limit
1943
+ .map((file: string) => {
1944
+ const fullValue = prefix + file
1945
+ // Get all basenames for display
1946
+ const allFiles = [...previousFiles, file]
1947
+ const allBasenames = allFiles.map(
1948
+ (f) => f.split('/').pop() || f,
1949
+ )
1950
+ let displayName = allBasenames.join(', ')
1951
+ // Truncate if too long
1952
+ if (displayName.length > 100) {
1953
+ displayName = '…' + displayName.slice(-97)
1954
+ }
1955
+ return {
1956
+ name: displayName,
1957
+ value: fullValue,
1958
+ }
1959
+ })
1960
+
1961
+ await interaction.respond(choices)
1962
+ } catch (error) {
1963
+ voiceLogger.error('[AUTOCOMPLETE] Error fetching files:', error)
1964
+ await interaction.respond([])
1965
+ }
1966
+ }
1871
1967
  }
1872
1968
  }
1873
1969
 
@@ -1875,7 +1971,100 @@ export async function startDiscordBot({
1875
1971
  if (interaction.isChatInputCommand()) {
1876
1972
  const command = interaction
1877
1973
 
1878
- if (command.commandName === 'resume') {
1974
+ if (command.commandName === 'session') {
1975
+ await command.deferReply({ ephemeral: false })
1976
+
1977
+ const prompt = command.options.getString('prompt', true)
1978
+ const filesString = command.options.getString('files') || ''
1979
+ const channel = command.channel
1980
+
1981
+ if (!channel || channel.type !== ChannelType.GuildText) {
1982
+ await command.editReply(
1983
+ 'This command can only be used in text channels',
1984
+ )
1985
+ return
1986
+ }
1987
+
1988
+ const textChannel = channel as TextChannel
1989
+
1990
+ // Get project directory from channel topic
1991
+ let projectDirectory: string | undefined
1992
+ let channelAppId: string | undefined
1993
+
1994
+ if (textChannel.topic) {
1995
+ const extracted = extractTagsArrays({
1996
+ xml: textChannel.topic,
1997
+ tags: ['kimaki.directory', 'kimaki.app'],
1998
+ })
1999
+
2000
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
2001
+ channelAppId = extracted['kimaki.app']?.[0]?.trim()
2002
+ }
2003
+
2004
+ // Check if this channel belongs to current bot instance
2005
+ if (channelAppId && channelAppId !== currentAppId) {
2006
+ await command.editReply(
2007
+ 'This channel is not configured for this bot',
2008
+ )
2009
+ return
2010
+ }
2011
+
2012
+ if (!projectDirectory) {
2013
+ await command.editReply(
2014
+ 'This channel is not configured with a project directory',
2015
+ )
2016
+ return
2017
+ }
2018
+
2019
+ if (!fs.existsSync(projectDirectory)) {
2020
+ await command.editReply(
2021
+ `Directory does not exist: ${projectDirectory}`,
2022
+ )
2023
+ return
2024
+ }
2025
+
2026
+ try {
2027
+ // Initialize OpenCode client for the directory
2028
+ const getClient =
2029
+ await initializeOpencodeForDirectory(projectDirectory)
2030
+
2031
+ // Process file mentions - split by comma only
2032
+ const files = filesString
2033
+ .split(',')
2034
+ .map((f) => f.trim())
2035
+ .filter((f) => f)
2036
+
2037
+ // Build the full prompt with file mentions
2038
+ let fullPrompt = prompt
2039
+ if (files.length > 0) {
2040
+ fullPrompt = `${prompt}\n\n@${files.join(' @')}`
2041
+ }
2042
+
2043
+ // Send a message first, then create thread from it
2044
+ const starterMessage = await textChannel.send({
2045
+ content: `šŸš€ **Starting OpenCode session**\nšŸ“ ${prompt.slice(0, 200)}${prompt.length > 200 ? '…' : ''}${files.length > 0 ? `\nšŸ“Ž Files: ${files.join(', ')}` : ''}`,
2046
+ })
2047
+
2048
+ // Create thread from the message
2049
+ const thread = await starterMessage.startThread({
2050
+ name: prompt.slice(0, 100),
2051
+ autoArchiveDuration: 1440, // 24 hours
2052
+ reason: 'OpenCode session',
2053
+ })
2054
+
2055
+ await command.editReply(
2056
+ `Created new session in ${thread.toString()}`,
2057
+ )
2058
+
2059
+ // Start the OpenCode session
2060
+ await handleOpencodeSession(fullPrompt, thread, projectDirectory)
2061
+ } catch (error) {
2062
+ voiceLogger.error('[SESSION] Error:', error)
2063
+ await command.editReply(
2064
+ `Failed to create session: ${error instanceof Error ? error.message : 'Unknown error'}`,
2065
+ )
2066
+ }
2067
+ } else if (command.commandName === 'resume') {
1879
2068
  await command.deferReply({ ephemeral: false })
1880
2069
 
1881
2070
  const sessionId = command.options.getString('session', true)
@@ -1988,12 +2177,12 @@ export async function startDiscordBot({
1988
2177
  if (message.info.role === 'user') {
1989
2178
  // Render user messages
1990
2179
  const userParts = message.parts.filter(
1991
- (p) => p.type === 'text',
2180
+ (p) => p.type === 'text' && !p.synthetic,
1992
2181
  )
1993
2182
  const userTexts = userParts
1994
2183
  .map((p) => {
1995
- if (typeof p.text === 'string') {
1996
- return extractNonXmlContent(p.text)
2184
+ if (p.type === 'text') {
2185
+ return p.text
1997
2186
  }
1998
2187
  return ''
1999
2188
  })