kimaki 0.4.25 → 0.4.27

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 (52) hide show
  1. package/dist/acp-client.test.js +149 -0
  2. package/dist/channel-management.js +11 -9
  3. package/dist/cli.js +58 -18
  4. package/dist/commands/add-project.js +1 -0
  5. package/dist/commands/agent.js +152 -0
  6. package/dist/commands/ask-question.js +184 -0
  7. package/dist/commands/model.js +23 -4
  8. package/dist/commands/permissions.js +101 -105
  9. package/dist/commands/session.js +1 -3
  10. package/dist/commands/user-command.js +145 -0
  11. package/dist/database.js +51 -0
  12. package/dist/discord-bot.js +32 -32
  13. package/dist/discord-utils.js +71 -14
  14. package/dist/interaction-handler.js +25 -8
  15. package/dist/logger.js +43 -5
  16. package/dist/markdown.js +104 -0
  17. package/dist/markdown.test.js +31 -1
  18. package/dist/message-formatting.js +72 -22
  19. package/dist/message-formatting.test.js +73 -0
  20. package/dist/opencode.js +70 -16
  21. package/dist/session-handler.js +142 -66
  22. package/dist/system-message.js +4 -51
  23. package/dist/voice-handler.js +18 -8
  24. package/dist/voice.js +28 -12
  25. package/package.json +14 -13
  26. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  27. package/src/__snapshots__/compact-session-context.md +47 -0
  28. package/src/channel-management.ts +20 -8
  29. package/src/cli.ts +73 -19
  30. package/src/commands/add-project.ts +1 -0
  31. package/src/commands/agent.ts +201 -0
  32. package/src/commands/ask-question.ts +277 -0
  33. package/src/commands/fork.ts +1 -2
  34. package/src/commands/model.ts +24 -4
  35. package/src/commands/permissions.ts +139 -114
  36. package/src/commands/session.ts +1 -3
  37. package/src/commands/user-command.ts +178 -0
  38. package/src/database.ts +61 -0
  39. package/src/discord-bot.ts +36 -33
  40. package/src/discord-utils.ts +76 -14
  41. package/src/interaction-handler.ts +31 -10
  42. package/src/logger.ts +47 -10
  43. package/src/markdown.test.ts +45 -1
  44. package/src/markdown.ts +132 -0
  45. package/src/message-formatting.test.ts +81 -0
  46. package/src/message-formatting.ts +93 -25
  47. package/src/opencode.ts +80 -21
  48. package/src/session-handler.ts +190 -97
  49. package/src/system-message.ts +4 -51
  50. package/src/voice-handler.ts +20 -9
  51. package/src/voice.ts +32 -13
  52. package/LICENSE +0 -21
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
4
4
  import { getDatabase, setChannelModel, setSessionModel, runModelMigrations } from '../database.js';
5
5
  import { initializeOpencodeForDirectory } from '../opencode.js';
6
6
  import { resolveTextChannel, getKimakiMetadata } from '../discord-utils.js';
7
+ import { abortAndRetrySession } from '../session-handler.js';
7
8
  import { createLogger } from '../logger.js';
8
9
  const modelLogger = createLogger('MODEL');
9
10
  // Store context by hash to avoid customId length limits (Discord max: 100 chars)
@@ -102,6 +103,7 @@ export async function handleModelCommand({ interaction, appId, }) {
102
103
  channelId: targetChannelId,
103
104
  sessionId: sessionId,
104
105
  isThread: isThread,
106
+ thread: isThread ? channel : undefined,
105
107
  };
106
108
  const contextHash = crypto.randomBytes(8).toString('hex');
107
109
  pendingModelContexts.set(contextHash, context);
@@ -267,10 +269,27 @@ export async function handleModelSelectMenu(interaction) {
267
269
  // Store for session
268
270
  setSessionModel(context.sessionId, fullModelId);
269
271
  modelLogger.log(`Set model ${fullModelId} for session ${context.sessionId}`);
270
- await interaction.editReply({
271
- content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
272
- components: [],
273
- });
272
+ // Check if there's a running request and abort+retry with new model
273
+ let retried = false;
274
+ if (context.thread) {
275
+ retried = await abortAndRetrySession({
276
+ sessionId: context.sessionId,
277
+ thread: context.thread,
278
+ projectDirectory: context.dir,
279
+ });
280
+ }
281
+ if (retried) {
282
+ await interaction.editReply({
283
+ content: `Model changed for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\`\n\n_Retrying current request with new model..._`,
284
+ components: [],
285
+ });
286
+ }
287
+ else {
288
+ await interaction.editReply({
289
+ content: `Model preference set for this session:\n**${context.providerName}** / **${selectedModelId}**\n\n\`${fullModelId}\``,
290
+ components: [],
291
+ });
292
+ }
274
293
  }
275
294
  else {
276
295
  // Store for channel
@@ -1,126 +1,122 @@
1
- // Permission commands - /accept, /accept-always, /reject
2
- import { ChannelType } from 'discord.js';
1
+ // Permission dropdown handler - Shows dropdown for permission requests.
2
+ // When OpenCode asks for permission, this module renders a dropdown
3
+ // with Accept, Accept Always, and Deny options.
4
+ import { StringSelectMenuBuilder, StringSelectMenuInteraction, ActionRowBuilder, } from 'discord.js';
5
+ import crypto from 'node:crypto';
3
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
4
- import { pendingPermissions } from '../session-handler.js';
5
- import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
7
+ import { NOTIFY_MESSAGE_FLAGS } from '../discord-utils.js';
6
8
  import { createLogger } from '../logger.js';
7
9
  const logger = createLogger('PERMISSIONS');
8
- export async function handleAcceptCommand({ command, }) {
9
- const scope = command.commandName === 'accept-always' ? 'always' : 'once';
10
- const channel = command.channel;
11
- if (!channel) {
12
- await command.reply({
13
- content: 'This command can only be used in a channel',
14
- ephemeral: true,
15
- flags: SILENT_MESSAGE_FLAGS,
16
- });
17
- return;
18
- }
19
- const isThread = [
20
- ChannelType.PublicThread,
21
- ChannelType.PrivateThread,
22
- ChannelType.AnnouncementThread,
23
- ].includes(channel.type);
24
- if (!isThread) {
25
- await command.reply({
26
- content: 'This command can only be used in a thread with an active session',
27
- ephemeral: true,
28
- flags: SILENT_MESSAGE_FLAGS,
29
- });
30
- return;
31
- }
32
- const pending = pendingPermissions.get(channel.id);
33
- if (!pending) {
34
- await command.reply({
35
- content: 'No pending permission request in this thread',
36
- ephemeral: true,
37
- flags: SILENT_MESSAGE_FLAGS,
38
- });
39
- return;
40
- }
41
- try {
42
- const getClient = await initializeOpencodeForDirectory(pending.directory);
43
- await getClient().postSessionIdPermissionsPermissionId({
44
- path: {
45
- id: pending.permission.sessionID,
46
- permissionID: pending.permission.id,
47
- },
48
- body: {
49
- response: scope,
50
- },
51
- });
52
- pendingPermissions.delete(channel.id);
53
- const msg = scope === 'always'
54
- ? `✅ Permission **accepted** (auto-approve similar requests)`
55
- : `✅ Permission **accepted**`;
56
- await command.reply({ content: msg, flags: SILENT_MESSAGE_FLAGS });
57
- logger.log(`Permission ${pending.permission.id} accepted with scope: ${scope}`);
58
- }
59
- catch (error) {
60
- logger.error('[ACCEPT] Error:', error);
61
- await command.reply({
62
- content: `Failed to accept permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
63
- ephemeral: true,
64
- flags: SILENT_MESSAGE_FLAGS,
65
- });
66
- }
10
+ // Store pending permission contexts by hash
11
+ export const pendingPermissionContexts = new Map();
12
+ /**
13
+ * Show permission dropdown for a permission request.
14
+ * Returns the message ID and context hash for tracking.
15
+ */
16
+ export async function showPermissionDropdown({ thread, permission, directory, }) {
17
+ const contextHash = crypto.randomBytes(8).toString('hex');
18
+ const context = {
19
+ permission,
20
+ directory,
21
+ thread,
22
+ contextHash,
23
+ };
24
+ pendingPermissionContexts.set(contextHash, context);
25
+ const patternStr = permission.patterns.join(', ');
26
+ // Build dropdown options
27
+ const options = [
28
+ {
29
+ label: 'Accept',
30
+ value: 'once',
31
+ description: 'Allow this request only',
32
+ },
33
+ {
34
+ label: 'Accept Always',
35
+ value: 'always',
36
+ description: 'Auto-approve similar requests',
37
+ },
38
+ {
39
+ label: 'Deny',
40
+ value: 'reject',
41
+ description: 'Reject this permission request',
42
+ },
43
+ ];
44
+ const selectMenu = new StringSelectMenuBuilder()
45
+ .setCustomId(`permission:${contextHash}`)
46
+ .setPlaceholder('Choose an action')
47
+ .addOptions(options);
48
+ const actionRow = new ActionRowBuilder().addComponents(selectMenu);
49
+ const permissionMessage = await thread.send({
50
+ content: `⚠️ **Permission Required**\n\n` +
51
+ `**Type:** \`${permission.permission}\`\n` +
52
+ (patternStr ? `**Pattern:** \`${patternStr}\`` : ''),
53
+ components: [actionRow],
54
+ flags: NOTIFY_MESSAGE_FLAGS,
55
+ });
56
+ logger.log(`Showed permission dropdown for ${permission.id}`);
57
+ return { messageId: permissionMessage.id, contextHash };
67
58
  }
68
- export async function handleRejectCommand({ command, }) {
69
- const channel = command.channel;
70
- if (!channel) {
71
- await command.reply({
72
- content: 'This command can only be used in a channel',
73
- ephemeral: true,
74
- flags: SILENT_MESSAGE_FLAGS,
75
- });
76
- return;
77
- }
78
- const isThread = [
79
- ChannelType.PublicThread,
80
- ChannelType.PrivateThread,
81
- ChannelType.AnnouncementThread,
82
- ].includes(channel.type);
83
- if (!isThread) {
84
- await command.reply({
85
- content: 'This command can only be used in a thread with an active session',
86
- ephemeral: true,
87
- flags: SILENT_MESSAGE_FLAGS,
88
- });
59
+ /**
60
+ * Handle dropdown selection for permission.
61
+ */
62
+ export async function handlePermissionSelectMenu(interaction) {
63
+ const customId = interaction.customId;
64
+ if (!customId.startsWith('permission:')) {
89
65
  return;
90
66
  }
91
- const pending = pendingPermissions.get(channel.id);
92
- if (!pending) {
93
- await command.reply({
94
- content: 'No pending permission request in this thread',
67
+ const contextHash = customId.replace('permission:', '');
68
+ const context = pendingPermissionContexts.get(contextHash);
69
+ if (!context) {
70
+ await interaction.reply({
71
+ content: 'This permission request has expired or was already handled.',
95
72
  ephemeral: true,
96
- flags: SILENT_MESSAGE_FLAGS,
97
73
  });
98
74
  return;
99
75
  }
76
+ await interaction.deferUpdate();
77
+ const response = interaction.values[0];
100
78
  try {
101
- const getClient = await initializeOpencodeForDirectory(pending.directory);
79
+ const getClient = await initializeOpencodeForDirectory(context.directory);
102
80
  await getClient().postSessionIdPermissionsPermissionId({
103
81
  path: {
104
- id: pending.permission.sessionID,
105
- permissionID: pending.permission.id,
106
- },
107
- body: {
108
- response: 'reject',
82
+ id: context.permission.sessionID,
83
+ permissionID: context.permission.id,
109
84
  },
85
+ body: { response },
110
86
  });
111
- pendingPermissions.delete(channel.id);
112
- await command.reply({
113
- content: `❌ Permission **rejected**`,
114
- flags: SILENT_MESSAGE_FLAGS,
87
+ pendingPermissionContexts.delete(contextHash);
88
+ // Update message: show result and remove dropdown
89
+ const resultText = (() => {
90
+ switch (response) {
91
+ case 'once':
92
+ return '✅ Permission **accepted**';
93
+ case 'always':
94
+ return '✅ Permission **accepted** (auto-approve similar requests)';
95
+ case 'reject':
96
+ return '❌ Permission **rejected**';
97
+ }
98
+ })();
99
+ const patternStr = context.permission.patterns.join(', ');
100
+ await interaction.editReply({
101
+ content: `⚠️ **Permission Required**\n\n` +
102
+ `**Type:** \`${context.permission.permission}\`\n` +
103
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n\n` : '\n') +
104
+ resultText,
105
+ components: [], // Remove the dropdown
115
106
  });
116
- logger.log(`Permission ${pending.permission.id} rejected`);
107
+ logger.log(`Permission ${context.permission.id} ${response}`);
117
108
  }
118
109
  catch (error) {
119
- logger.error('[REJECT] Error:', error);
120
- await command.reply({
121
- content: `Failed to reject permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
122
- ephemeral: true,
123
- flags: SILENT_MESSAGE_FLAGS,
110
+ logger.error('Error handling permission:', error);
111
+ await interaction.editReply({
112
+ content: `Failed to process permission: ${error instanceof Error ? error.message : 'Unknown error'}`,
113
+ components: [],
124
114
  });
125
115
  }
126
116
  }
117
+ /**
118
+ * Clean up a pending permission context (e.g., on auto-reject).
119
+ */
120
+ export function cleanupPermissionContext(contextHash) {
121
+ pendingPermissionContexts.delete(contextHash);
122
+ }
@@ -6,7 +6,7 @@ import { getDatabase } from '../database.js';
6
6
  import { initializeOpencodeForDirectory } from '../opencode.js';
7
7
  import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
8
8
  import { extractTagsArrays } from '../xml.js';
9
- import { handleOpencodeSession, parseSlashCommand } from '../session-handler.js';
9
+ import { handleOpencodeSession } from '../session-handler.js';
10
10
  import { createLogger } from '../logger.js';
11
11
  const logger = createLogger('SESSION');
12
12
  export async function handleSessionCommand({ command, appId, }) {
@@ -61,12 +61,10 @@ export async function handleSessionCommand({ command, appId, }) {
61
61
  reason: 'OpenCode session',
62
62
  });
63
63
  await command.editReply(`Created new session in ${thread.toString()}`);
64
- const parsedCommand = parseSlashCommand(fullPrompt);
65
64
  await handleOpencodeSession({
66
65
  prompt: fullPrompt,
67
66
  thread,
68
67
  projectDirectory,
69
- parsedCommand,
70
68
  channelId: textChannel.id,
71
69
  });
72
70
  }
@@ -0,0 +1,145 @@
1
+ // User-defined OpenCode command handler.
2
+ // Handles slash commands that map to user-configured commands in opencode.json.
3
+ import { ChannelType } from 'discord.js';
4
+ import { extractTagsArrays } from '../xml.js';
5
+ import { handleOpencodeSession } from '../session-handler.js';
6
+ import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
7
+ import { createLogger } from '../logger.js';
8
+ import { getDatabase } from '../database.js';
9
+ import fs from 'node:fs';
10
+ const userCommandLogger = createLogger('USER_CMD');
11
+ export const handleUserCommand = async ({ command, appId, }) => {
12
+ const discordCommandName = command.commandName;
13
+ // Strip the -cmd suffix to get the actual OpenCode command name
14
+ const commandName = discordCommandName.replace(/-cmd$/, '');
15
+ const args = command.options.getString('arguments') || '';
16
+ userCommandLogger.log(`Executing /${commandName} (from /${discordCommandName}) with args: ${args}`);
17
+ const channel = command.channel;
18
+ userCommandLogger.log(`Channel info: type=${channel?.type}, id=${channel?.id}, isNull=${channel === null}`);
19
+ const isThread = channel && [
20
+ ChannelType.PublicThread,
21
+ ChannelType.PrivateThread,
22
+ ChannelType.AnnouncementThread,
23
+ ].includes(channel.type);
24
+ const isTextChannel = channel?.type === ChannelType.GuildText;
25
+ if (!channel || (!isTextChannel && !isThread)) {
26
+ await command.reply({
27
+ content: 'This command can only be used in text channels or threads',
28
+ ephemeral: true,
29
+ });
30
+ return;
31
+ }
32
+ let projectDirectory;
33
+ let channelAppId;
34
+ let textChannel = null;
35
+ let thread = null;
36
+ if (isThread) {
37
+ // Running in an existing thread - get project directory from parent channel
38
+ thread = channel;
39
+ textChannel = thread.parent;
40
+ // Verify this thread has an existing session
41
+ const row = getDatabase()
42
+ .prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
43
+ .get(thread.id);
44
+ if (!row) {
45
+ await command.reply({
46
+ content: 'This thread does not have an active session. Use this command in a project channel to create a new thread.',
47
+ ephemeral: true,
48
+ });
49
+ return;
50
+ }
51
+ if (textChannel?.topic) {
52
+ const extracted = extractTagsArrays({
53
+ xml: textChannel.topic,
54
+ tags: ['kimaki.directory', 'kimaki.app'],
55
+ });
56
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
57
+ channelAppId = extracted['kimaki.app']?.[0]?.trim();
58
+ }
59
+ }
60
+ else {
61
+ // Running in a text channel - will create a new thread
62
+ textChannel = channel;
63
+ if (textChannel.topic) {
64
+ const extracted = extractTagsArrays({
65
+ xml: textChannel.topic,
66
+ tags: ['kimaki.directory', 'kimaki.app'],
67
+ });
68
+ projectDirectory = extracted['kimaki.directory']?.[0]?.trim();
69
+ channelAppId = extracted['kimaki.app']?.[0]?.trim();
70
+ }
71
+ }
72
+ if (channelAppId && channelAppId !== appId) {
73
+ await command.reply({
74
+ content: 'This channel is not configured for this bot',
75
+ ephemeral: true,
76
+ });
77
+ return;
78
+ }
79
+ if (!projectDirectory) {
80
+ await command.reply({
81
+ content: 'This channel is not configured with a project directory',
82
+ ephemeral: true,
83
+ });
84
+ return;
85
+ }
86
+ if (!fs.existsSync(projectDirectory)) {
87
+ await command.reply({
88
+ content: `Directory does not exist: ${projectDirectory}`,
89
+ ephemeral: true,
90
+ });
91
+ return;
92
+ }
93
+ await command.deferReply({ ephemeral: false });
94
+ try {
95
+ // Use the dedicated session.command API instead of formatting as text prompt
96
+ const commandPayload = { name: commandName, arguments: args };
97
+ if (isThread && thread) {
98
+ // Running in existing thread - just send the command
99
+ await command.editReply(`Running /${commandName}...`);
100
+ await handleOpencodeSession({
101
+ prompt: '', // Not used when command is set
102
+ thread,
103
+ projectDirectory,
104
+ channelId: textChannel?.id,
105
+ command: commandPayload,
106
+ });
107
+ }
108
+ else if (textChannel) {
109
+ // Running in text channel - create a new thread
110
+ const starterMessage = await textChannel.send({
111
+ content: `**/${commandName}**${args ? ` ${args.slice(0, 200)}${args.length > 200 ? '…' : ''}` : ''}`,
112
+ flags: SILENT_MESSAGE_FLAGS,
113
+ });
114
+ const threadName = `/${commandName} ${args.slice(0, 80)}${args.length > 80 ? '…' : ''}`;
115
+ const newThread = await starterMessage.startThread({
116
+ name: threadName.slice(0, 100),
117
+ autoArchiveDuration: 1440,
118
+ reason: `OpenCode command: ${commandName}`,
119
+ });
120
+ await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
121
+ await handleOpencodeSession({
122
+ prompt: '', // Not used when command is set
123
+ thread: newThread,
124
+ projectDirectory,
125
+ channelId: textChannel.id,
126
+ command: commandPayload,
127
+ });
128
+ }
129
+ }
130
+ catch (error) {
131
+ userCommandLogger.error(`Error executing /${commandName}:`, error);
132
+ const errorMessage = error instanceof Error ? error.message : String(error);
133
+ if (command.deferred) {
134
+ await command.editReply({
135
+ content: `Failed to execute /${commandName}: ${errorMessage}`,
136
+ });
137
+ }
138
+ else {
139
+ await command.reply({
140
+ content: `Failed to execute /${commandName}: ${errorMessage}`,
141
+ ephemeral: true,
142
+ });
143
+ }
144
+ }
145
+ };
package/dist/database.js CHANGED
@@ -82,6 +82,21 @@ export function runModelMigrations(database) {
82
82
  model_id TEXT NOT NULL,
83
83
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
84
84
  )
85
+ `);
86
+ targetDb.exec(`
87
+ CREATE TABLE IF NOT EXISTS channel_agents (
88
+ channel_id TEXT PRIMARY KEY,
89
+ agent_name TEXT NOT NULL,
90
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
91
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
92
+ )
93
+ `);
94
+ targetDb.exec(`
95
+ CREATE TABLE IF NOT EXISTS session_agents (
96
+ session_id TEXT PRIMARY KEY,
97
+ agent_name TEXT NOT NULL,
98
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
99
+ )
85
100
  `);
86
101
  dbLogger.log('Model preferences migrations complete');
87
102
  }
@@ -125,6 +140,42 @@ export function setSessionModel(sessionId, modelId) {
125
140
  const db = getDatabase();
126
141
  db.prepare(`INSERT OR REPLACE INTO session_models (session_id, model_id) VALUES (?, ?)`).run(sessionId, modelId);
127
142
  }
143
+ /**
144
+ * Get the agent preference for a channel.
145
+ */
146
+ export function getChannelAgent(channelId) {
147
+ const db = getDatabase();
148
+ const row = db
149
+ .prepare('SELECT agent_name FROM channel_agents WHERE channel_id = ?')
150
+ .get(channelId);
151
+ return row?.agent_name;
152
+ }
153
+ /**
154
+ * Set the agent preference for a channel.
155
+ */
156
+ export function setChannelAgent(channelId, agentName) {
157
+ const db = getDatabase();
158
+ db.prepare(`INSERT INTO channel_agents (channel_id, agent_name, updated_at)
159
+ VALUES (?, ?, CURRENT_TIMESTAMP)
160
+ ON CONFLICT(channel_id) DO UPDATE SET agent_name = ?, updated_at = CURRENT_TIMESTAMP`).run(channelId, agentName, agentName);
161
+ }
162
+ /**
163
+ * Get the agent preference for a session.
164
+ */
165
+ export function getSessionAgent(sessionId) {
166
+ const db = getDatabase();
167
+ const row = db
168
+ .prepare('SELECT agent_name FROM session_agents WHERE session_id = ?')
169
+ .get(sessionId);
170
+ return row?.agent_name;
171
+ }
172
+ /**
173
+ * Set the agent preference for a session.
174
+ */
175
+ export function setSessionAgent(sessionId, agentName) {
176
+ const db = getDatabase();
177
+ db.prepare(`INSERT OR REPLACE INTO session_agents (session_id, agent_name) VALUES (?, ?)`).run(sessionId, agentName);
178
+ }
128
179
  export function closeDatabase() {
129
180
  if (db) {
130
181
  db.close();
@@ -8,7 +8,8 @@ import { getOpencodeSystemMessage } from './system-message.js';
8
8
  import { getFileAttachments, getTextAttachments } from './message-formatting.js';
9
9
  import { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, getChannelsWithDescriptions, } from './channel-management.js';
10
10
  import { voiceConnections, cleanupVoiceConnection, processVoiceAttachment, registerVoiceStateHandler, } from './voice-handler.js';
11
- import { handleOpencodeSession, parseSlashCommand, } from './session-handler.js';
11
+ import { getCompactSessionContext, getLastSessionId, } from './markdown.js';
12
+ import { handleOpencodeSession } from './session-handler.js';
12
13
  import { registerInteractionHandler } from './interaction-handler.js';
13
14
  export { getDatabase, closeDatabase } from './database.js';
14
15
  export { initializeOpencodeForDirectory } from './opencode.js';
@@ -153,35 +154,37 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
153
154
  return;
154
155
  }
155
156
  let messageContent = message.content || '';
156
- let sessionMessagesText;
157
- if (projectDirectory && row.session_id) {
157
+ let currentSessionContext;
158
+ let lastSessionContext;
159
+ if (projectDirectory) {
158
160
  try {
159
161
  const getClient = await initializeOpencodeForDirectory(projectDirectory);
160
- const messagesResponse = await getClient().session.messages({
161
- path: { id: row.session_id },
162
+ const client = getClient();
163
+ // get current session context (without system prompt, it would be duplicated)
164
+ if (row.session_id) {
165
+ currentSessionContext = await getCompactSessionContext({
166
+ client,
167
+ sessionId: row.session_id,
168
+ includeSystemPrompt: false,
169
+ maxMessages: 15,
170
+ });
171
+ }
172
+ // get last session context (with system prompt for project context)
173
+ const lastSessionId = await getLastSessionId({
174
+ client,
175
+ excludeSessionId: row.session_id,
162
176
  });
163
- const messages = messagesResponse.data || [];
164
- const recentMessages = messages.slice(-10);
165
- sessionMessagesText = recentMessages
166
- .map((m) => {
167
- const role = m.info.role === 'user' ? 'User' : 'Assistant';
168
- const text = (() => {
169
- if (m.info.role === 'user') {
170
- const textParts = (m.parts || []).filter((p) => p.type === 'text');
171
- return textParts
172
- .map((p) => ('text' in p ? p.text : ''))
173
- .filter(Boolean)
174
- .join('\n');
175
- }
176
- const assistantInfo = m.info;
177
- return assistantInfo.text?.slice(0, 500);
178
- })();
179
- return `[${role}]: ${text || '(no text)'}`;
180
- })
181
- .join('\n\n');
177
+ if (lastSessionId) {
178
+ lastSessionContext = await getCompactSessionContext({
179
+ client,
180
+ sessionId: lastSessionId,
181
+ includeSystemPrompt: true,
182
+ maxMessages: 10,
183
+ });
184
+ }
182
185
  }
183
186
  catch (e) {
184
- voiceLogger.log(`Could not get session messages:`, e);
187
+ voiceLogger.error(`Could not get session context:`, e);
185
188
  }
186
189
  }
187
190
  const transcription = await processVoiceAttachment({
@@ -189,24 +192,23 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
189
192
  thread,
190
193
  projectDirectory,
191
194
  appId: currentAppId,
192
- sessionMessages: sessionMessagesText,
195
+ currentSessionContext,
196
+ lastSessionContext,
193
197
  });
194
198
  if (transcription) {
195
199
  messageContent = transcription;
196
200
  }
197
- const fileAttachments = getFileAttachments(message);
201
+ const fileAttachments = await getFileAttachments(message);
198
202
  const textAttachmentsContent = await getTextAttachments(message);
199
203
  const promptWithAttachments = textAttachmentsContent
200
204
  ? `${messageContent}\n\n${textAttachmentsContent}`
201
205
  : messageContent;
202
- const parsedCommand = parseSlashCommand(messageContent);
203
206
  await handleOpencodeSession({
204
207
  prompt: promptWithAttachments,
205
208
  thread,
206
209
  projectDirectory,
207
210
  originalMessage: message,
208
211
  images: fileAttachments,
209
- parsedCommand,
210
212
  channelId: parent?.id,
211
213
  });
212
214
  return;
@@ -265,19 +267,17 @@ export async function startDiscordBot({ token, appId, discordClient, }) {
265
267
  if (transcription) {
266
268
  messageContent = transcription;
267
269
  }
268
- const fileAttachments = getFileAttachments(message);
270
+ const fileAttachments = await getFileAttachments(message);
269
271
  const textAttachmentsContent = await getTextAttachments(message);
270
272
  const promptWithAttachments = textAttachmentsContent
271
273
  ? `${messageContent}\n\n${textAttachmentsContent}`
272
274
  : messageContent;
273
- const parsedCommand = parseSlashCommand(messageContent);
274
275
  await handleOpencodeSession({
275
276
  prompt: promptWithAttachments,
276
277
  thread,
277
278
  projectDirectory,
278
279
  originalMessage: message,
279
280
  images: fileAttachments,
280
- parsedCommand,
281
281
  channelId: textChannel.id,
282
282
  });
283
283
  }