@otto-assistant/otto 0.7.16 → 0.7.18
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/commands/model-variant.js +55 -14
- package/dist/commands/model.js +55 -15
- package/dist/commands/permissions.js +153 -2
- package/dist/commands/unset-model.js +48 -9
- package/dist/interaction-handler.js +15 -3
- package/package.json +1 -1
- package/src/commands/model-variant.ts +68 -13
- package/src/commands/model.ts +68 -14
- package/src/commands/permissions.ts +152 -2
- package/src/commands/unset-model.ts +60 -8
- package/src/interaction-handler.ts +21 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Cross-menu state: Discord doesn't expose already-selected values on sibling
|
|
6
6
|
// select menus in the same message. We track partial selections in the context
|
|
7
7
|
// Map. Whichever menu fires second sees the first selection stored and applies.
|
|
8
|
-
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
8
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, ButtonInteraction, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
10
|
import { setChannelModel, setSessionModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
|
|
11
11
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
@@ -313,21 +313,20 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
313
313
|
variant,
|
|
314
314
|
});
|
|
315
315
|
logger.log(`Set variant ${variant ?? 'none'} for session ${context.sessionId} (model ${modelId})`);
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
? '\n_Restarting current request with new variant..._'
|
|
325
|
-
: '';
|
|
316
|
+
// Show a button so the user can choose to resend the last message.
|
|
317
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
318
|
+
// Context is kept alive (TTL handles cleanup) so the button can access it.
|
|
319
|
+
const retryButton = new ButtonBuilder()
|
|
320
|
+
.setCustomId(`variant_retry:${contextHash}`)
|
|
321
|
+
.setLabel('Resend last message')
|
|
322
|
+
.setStyle(ButtonStyle.Secondary);
|
|
323
|
+
const actionRow = new ActionRowBuilder().addComponents(retryButton);
|
|
326
324
|
await interaction.editReply({
|
|
327
|
-
content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${
|
|
325
|
+
content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
|
|
328
326
|
flags: MessageFlags.SuppressEmbeds,
|
|
329
|
-
components: [],
|
|
327
|
+
components: [actionRow],
|
|
330
328
|
});
|
|
329
|
+
return;
|
|
331
330
|
}
|
|
332
331
|
else if (scope === 'global') {
|
|
333
332
|
await setGlobalModel({ appId: context.appId, modelId, variant });
|
|
@@ -357,7 +356,11 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
357
356
|
components: [],
|
|
358
357
|
});
|
|
359
358
|
}
|
|
360
|
-
|
|
359
|
+
// Clean up the context from memory, except for session scope where the
|
|
360
|
+
// retry button still needs it. TTL handles cleanup in that case.
|
|
361
|
+
if (scope !== 'session') {
|
|
362
|
+
pendingVariantContexts.delete(contextHash);
|
|
363
|
+
}
|
|
361
364
|
}
|
|
362
365
|
catch (error) {
|
|
363
366
|
logger.error('Error applying variant:', error);
|
|
@@ -367,3 +370,41 @@ async function applyVariant({ interaction, context, variant, scope, contextHash,
|
|
|
367
370
|
});
|
|
368
371
|
}
|
|
369
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* Handle the "Resend last message" button shown after a session-scope variant change.
|
|
375
|
+
* Aborts any active run and resends the last user prompt with the new variant.
|
|
376
|
+
*/
|
|
377
|
+
export async function handleVariantRetryButton(interaction) {
|
|
378
|
+
const customId = interaction.customId;
|
|
379
|
+
if (!customId.startsWith('variant_retry:')) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
await interaction.deferUpdate();
|
|
383
|
+
const contextHash = customId.replace('variant_retry:', '');
|
|
384
|
+
const context = pendingVariantContexts.get(contextHash);
|
|
385
|
+
// Context may have expired (TTL) — still allow the action via runtime alone.
|
|
386
|
+
const threadId = context?.thread?.id;
|
|
387
|
+
if (!threadId) {
|
|
388
|
+
await interaction.editReply({
|
|
389
|
+
content: 'Could not find the session thread. Please resend your message manually.',
|
|
390
|
+
components: [],
|
|
391
|
+
});
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const runtime = getRuntime(threadId);
|
|
395
|
+
if (!runtime) {
|
|
396
|
+
await interaction.editReply({
|
|
397
|
+
content: 'No active session found. Please resend your message manually.',
|
|
398
|
+
components: [],
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const retried = await runtime.retryLastUserPrompt();
|
|
403
|
+
pendingVariantContexts.delete(contextHash);
|
|
404
|
+
await interaction.editReply({
|
|
405
|
+
content: retried
|
|
406
|
+
? `Restarting with new variant...`
|
|
407
|
+
: `Variant changed. Send a new message to continue.`,
|
|
408
|
+
components: [],
|
|
409
|
+
});
|
|
410
|
+
}
|
package/dist/commands/model.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// /model command - Set the preferred model for this channel or session.
|
|
2
|
-
import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
2
|
+
import { ChatInputCommandInteraction, StringSelectMenuInteraction, ButtonInteraction, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { setChannelModel, setSessionModel, setSessionAgent, getChannelModel, getSessionModel, getSessionAgent, getChannelAgent, getThreadSession, getGlobalModel, setGlobalModel, getVariantCascade, } from '../database.js';
|
|
5
5
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
@@ -741,21 +741,20 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
741
741
|
}
|
|
742
742
|
await setSessionModel({ sessionId: context.sessionId, modelId, variant });
|
|
743
743
|
modelLogger.log(`Set model ${modelId}${variantSuffix} for session ${context.sessionId}`);
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
const
|
|
752
|
-
? '\n_Restarting current request with new model..._'
|
|
753
|
-
: '';
|
|
744
|
+
// Show a button so the user can choose to resend the last message.
|
|
745
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
746
|
+
// Context is kept alive (TTL handles cleanup) so the button can access it.
|
|
747
|
+
const retryButton = new ButtonBuilder()
|
|
748
|
+
.setCustomId(`model_retry:${contextHash}`)
|
|
749
|
+
.setLabel('Resend last message')
|
|
750
|
+
.setStyle(ButtonStyle.Secondary);
|
|
751
|
+
const actionRow = new ActionRowBuilder().addComponents(retryButton);
|
|
754
752
|
await interaction.editReply({
|
|
755
|
-
content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${
|
|
753
|
+
content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
|
|
756
754
|
flags: MessageFlags.SuppressEmbeds,
|
|
757
|
-
components: [],
|
|
755
|
+
components: [actionRow],
|
|
758
756
|
});
|
|
757
|
+
return;
|
|
759
758
|
}
|
|
760
759
|
else if (selectedScope === 'global') {
|
|
761
760
|
if (!context.appId) {
|
|
@@ -785,8 +784,11 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
785
784
|
components: [],
|
|
786
785
|
});
|
|
787
786
|
}
|
|
788
|
-
// Clean up the context from memory
|
|
789
|
-
|
|
787
|
+
// Clean up the context from memory, except for session scope where the
|
|
788
|
+
// retry button still needs it. TTL handles cleanup in that case.
|
|
789
|
+
if (selectedScope !== 'session') {
|
|
790
|
+
deleteModelContext(contextHash);
|
|
791
|
+
}
|
|
790
792
|
}
|
|
791
793
|
catch (error) {
|
|
792
794
|
modelLogger.error('Error saving model preference:', error);
|
|
@@ -796,3 +798,41 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
796
798
|
});
|
|
797
799
|
}
|
|
798
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* Handle the "Resend last message" button shown after a session-scope model change.
|
|
803
|
+
* Aborts any active run and resends the last user prompt with the new model.
|
|
804
|
+
*/
|
|
805
|
+
export async function handleModelRetryButton(interaction) {
|
|
806
|
+
const customId = interaction.customId;
|
|
807
|
+
if (!customId.startsWith('model_retry:')) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
await interaction.deferUpdate();
|
|
811
|
+
const contextHash = customId.replace('model_retry:', '');
|
|
812
|
+
const context = pendingModelContexts.get(contextHash);
|
|
813
|
+
// Context may have expired (10 min TTL) — still allow the action via runtime alone.
|
|
814
|
+
const threadId = context?.thread?.id;
|
|
815
|
+
if (!threadId) {
|
|
816
|
+
await interaction.editReply({
|
|
817
|
+
content: 'Could not find the session thread. Please resend your message manually.',
|
|
818
|
+
components: [],
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const runtime = getRuntime(threadId);
|
|
823
|
+
if (!runtime) {
|
|
824
|
+
await interaction.editReply({
|
|
825
|
+
content: 'No active session found. Please resend your message manually.',
|
|
826
|
+
components: [],
|
|
827
|
+
});
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const retried = await runtime.retryLastUserPrompt();
|
|
831
|
+
deleteModelContext(contextHash);
|
|
832
|
+
await interaction.editReply({
|
|
833
|
+
content: retried
|
|
834
|
+
? `Restarting with new model...`
|
|
835
|
+
: `Model changed. Send a new message to continue.`,
|
|
836
|
+
components: [],
|
|
837
|
+
});
|
|
838
|
+
}
|
|
@@ -49,6 +49,153 @@ function takePendingPermissionContext(contextHash) {
|
|
|
49
49
|
pendingPermissionContexts.delete(contextHash);
|
|
50
50
|
return ctx;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Render rich tool context from permission metadata for Discord/Telegram.
|
|
54
|
+
* Shows what the agent is actually trying to do — the command, file path,
|
|
55
|
+
* URL, content preview, etc. — so the user can make an informed decision.
|
|
56
|
+
*/
|
|
57
|
+
function renderPermissionContext(permission) {
|
|
58
|
+
const tool = permission.permission.toLowerCase();
|
|
59
|
+
const meta = permission.metadata ?? {};
|
|
60
|
+
// Read a string value from metadata, trying multiple key names
|
|
61
|
+
const getStr = (keys, fallback = '') => {
|
|
62
|
+
for (const key of keys) {
|
|
63
|
+
const val = meta[key];
|
|
64
|
+
if (typeof val === 'string' && val.length > 0)
|
|
65
|
+
return val;
|
|
66
|
+
}
|
|
67
|
+
return fallback;
|
|
68
|
+
};
|
|
69
|
+
const truncate = (s, maxLen = 500) => s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s;
|
|
70
|
+
const clipBlock = (s, limit) => s.length > limit ? s.slice(0, limit - 1) + '…' : s;
|
|
71
|
+
switch (tool) {
|
|
72
|
+
case 'bash':
|
|
73
|
+
case 'shell':
|
|
74
|
+
case 'shell_command':
|
|
75
|
+
case 'cmd':
|
|
76
|
+
case 'terminal': {
|
|
77
|
+
const command = getStr(['command', 'cmd', 'script']);
|
|
78
|
+
const description = getStr(['description']);
|
|
79
|
+
if (!command && !description)
|
|
80
|
+
return '';
|
|
81
|
+
const parts = [];
|
|
82
|
+
if (description)
|
|
83
|
+
parts.push(`> *${clipBlock(description, 200)}*`);
|
|
84
|
+
if (command)
|
|
85
|
+
parts.push(`\`\`\`bash\n${clipBlock(command, 800)}\n\`\`\``);
|
|
86
|
+
return parts.join('\n');
|
|
87
|
+
}
|
|
88
|
+
case 'edit':
|
|
89
|
+
case 'multiedit':
|
|
90
|
+
case 'str_replace':
|
|
91
|
+
case 'str_replace_based_edit_tool':
|
|
92
|
+
case 'apply_patch': {
|
|
93
|
+
const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file']);
|
|
94
|
+
const oldString = getStr(['old_string', 'oldString', 'changes', 'diff']);
|
|
95
|
+
const newString = getStr(['new_string', 'newString']);
|
|
96
|
+
if (!filePath && !oldString && !newString)
|
|
97
|
+
return '';
|
|
98
|
+
const parts = [];
|
|
99
|
+
if (filePath)
|
|
100
|
+
parts.push(`**File:** \`${filePath}\``);
|
|
101
|
+
if (oldString) {
|
|
102
|
+
parts.push(`**Replace:**\n\`\`\`diff\n- ${clipBlock(oldString, 400)}\n+ ${clipBlock(newString || '', 400)}\n\`\`\``);
|
|
103
|
+
}
|
|
104
|
+
else if (newString) {
|
|
105
|
+
parts.push(`**New content:**\n\`\`\`\n${clipBlock(newString, 600)}\n\`\`\``);
|
|
106
|
+
}
|
|
107
|
+
return parts.join('\n');
|
|
108
|
+
}
|
|
109
|
+
case 'write':
|
|
110
|
+
case 'create':
|
|
111
|
+
case 'file_write': {
|
|
112
|
+
const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file']);
|
|
113
|
+
const content = getStr(['content', 'text', 'data']);
|
|
114
|
+
if (!filePath && !content)
|
|
115
|
+
return '';
|
|
116
|
+
const parts = [];
|
|
117
|
+
if (filePath)
|
|
118
|
+
parts.push(`**File:** \`${filePath}\``);
|
|
119
|
+
if (content) {
|
|
120
|
+
parts.push(`\`\`\`\n${clipBlock(content, 600)}\n\`\`\``);
|
|
121
|
+
}
|
|
122
|
+
return parts.join('\n');
|
|
123
|
+
}
|
|
124
|
+
case 'webfetch':
|
|
125
|
+
case 'fetch':
|
|
126
|
+
case 'curl':
|
|
127
|
+
case 'wget': {
|
|
128
|
+
const url = getStr(['url', 'uri', 'endpoint']);
|
|
129
|
+
const method = getStr(['method']) || 'GET';
|
|
130
|
+
if (!url)
|
|
131
|
+
return '';
|
|
132
|
+
return `**URL:** \`${method.toUpperCase()}\` ${url}`;
|
|
133
|
+
}
|
|
134
|
+
case 'read': {
|
|
135
|
+
const filePath = getStr(['filePath', 'file_path', 'path', 'file', 'filename']);
|
|
136
|
+
const parentDir = getStr(['parentDir', 'parent_dir', 'directory']);
|
|
137
|
+
if (!filePath && !parentDir)
|
|
138
|
+
return '';
|
|
139
|
+
const parts = [];
|
|
140
|
+
if (filePath)
|
|
141
|
+
parts.push(`**Reading:** \`${filePath}\``);
|
|
142
|
+
if (parentDir)
|
|
143
|
+
parts.push(`**Directory:** \`${parentDir}\``);
|
|
144
|
+
return parts.join('\n');
|
|
145
|
+
}
|
|
146
|
+
case 'list':
|
|
147
|
+
case 'ls': {
|
|
148
|
+
const listPath = getStr(['path', 'directory', 'filePath']);
|
|
149
|
+
if (!listPath)
|
|
150
|
+
return '';
|
|
151
|
+
return `**Listing:** \`${listPath}\``;
|
|
152
|
+
}
|
|
153
|
+
case 'glob': {
|
|
154
|
+
const pattern = getStr(['pattern', 'glob']);
|
|
155
|
+
if (!pattern)
|
|
156
|
+
return '';
|
|
157
|
+
return `**Pattern:** \`${pattern}\``;
|
|
158
|
+
}
|
|
159
|
+
case 'grep': {
|
|
160
|
+
const pattern = getStr(['pattern', 'query']);
|
|
161
|
+
if (!pattern)
|
|
162
|
+
return '';
|
|
163
|
+
return `**Search:** \`${pattern}\``;
|
|
164
|
+
}
|
|
165
|
+
case 'external_directory': {
|
|
166
|
+
const filepath = getStr(['filepath', 'path', 'directory']);
|
|
167
|
+
const parentDir = getStr(['parentDir', 'parent_dir']);
|
|
168
|
+
const parts = [];
|
|
169
|
+
if (filepath)
|
|
170
|
+
parts.push(`**Path:** \`${filepath}\``);
|
|
171
|
+
if (parentDir)
|
|
172
|
+
parts.push(`**Parent:** \`${parentDir}\``);
|
|
173
|
+
return parts.join('\n');
|
|
174
|
+
}
|
|
175
|
+
case 'task':
|
|
176
|
+
case 'subagent': {
|
|
177
|
+
const description = getStr(['description', 'prompt']);
|
|
178
|
+
if (!description)
|
|
179
|
+
return '';
|
|
180
|
+
return `> ${clipBlock(description, 300)}`;
|
|
181
|
+
}
|
|
182
|
+
default: {
|
|
183
|
+
// Generic: show description or first meaningful metadata field
|
|
184
|
+
const description = getStr(['description', 'action', 'operation', 'command']);
|
|
185
|
+
if (description)
|
|
186
|
+
return `> *${clipBlock(description, 300)}*`;
|
|
187
|
+
const relevantKeys = Object.keys(meta).filter((k) => !['sessionID', 'id', 'type'].includes(k));
|
|
188
|
+
if (relevantKeys.length > 0) {
|
|
189
|
+
const preview = relevantKeys
|
|
190
|
+
.slice(0, 3)
|
|
191
|
+
.map((k) => `${k}: ${String(meta[k]).slice(0, 60)}`)
|
|
192
|
+
.join('\n');
|
|
193
|
+
return `\`\`\`\n${clipBlock(preview, 400)}\n\`\`\``;
|
|
194
|
+
}
|
|
195
|
+
return '';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
52
199
|
/**
|
|
53
200
|
* Show permission buttons for a permission request.
|
|
54
201
|
* Displays 3 buttons in a row: Accept, Accept Always, Deny.
|
|
@@ -108,11 +255,13 @@ export async function showPermissionButtons({ thread, permission, directory, per
|
|
|
108
255
|
const externalDirLine = permission.permission === 'external_directory'
|
|
109
256
|
? `Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n`
|
|
110
257
|
: '';
|
|
258
|
+
const contextStr = renderPermissionContext(permission);
|
|
111
259
|
const fullContent = `⚠️ **Permission Required**\n` +
|
|
112
260
|
subtaskLine +
|
|
113
261
|
`**Type:** \`${permission.permission}\`\n` +
|
|
114
262
|
externalDirLine +
|
|
115
|
-
(patternStr ? `**Pattern:** \`${patternStr}
|
|
263
|
+
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
264
|
+
(contextStr ? `\n${contextStr}` : '');
|
|
116
265
|
const permissionMessage = await thread.send({
|
|
117
266
|
content: fullContent.slice(0, 1900),
|
|
118
267
|
components: [actionRow],
|
|
@@ -133,12 +282,14 @@ function updatePermissionMessage({ context, status, }) {
|
|
|
133
282
|
const externalDirLine = context.permission.permission === 'external_directory'
|
|
134
283
|
? 'Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n'
|
|
135
284
|
: '';
|
|
285
|
+
const contextStr = renderPermissionContext(context.permission);
|
|
136
286
|
return message.edit({
|
|
137
287
|
content: `⚠️ **Permission Required**\n` +
|
|
138
288
|
`**Type:** \`${context.permission.permission}\`\n` +
|
|
139
289
|
externalDirLine +
|
|
140
290
|
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
141
|
-
|
|
291
|
+
(contextStr ? `\n${contextStr}\n` : '') +
|
|
292
|
+
`\n${status}`,
|
|
142
293
|
components: [],
|
|
143
294
|
});
|
|
144
295
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// /unset-model-override command - Remove model overrides and use default instead.
|
|
2
|
-
import { ChatInputCommandInteraction, ChannelType, MessageFlags, } from 'discord.js';
|
|
2
|
+
import { ChatInputCommandInteraction, ButtonInteraction, ButtonBuilder, ButtonStyle, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
|
|
3
3
|
import { getChannelModel, getSessionModel, getThreadSession, clearSessionModel, } from '../database.js';
|
|
4
4
|
import { getPrisma } from '../db.js';
|
|
5
5
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
@@ -121,19 +121,58 @@ export async function handleUnsetModelCommand({ interaction, appId, }) {
|
|
|
121
121
|
? 'none'
|
|
122
122
|
: `\`${newModelInfo.model}\` (${formatModelSource(newModelInfo.type, 'agentName' in newModelInfo ? newModelInfo.agentName : undefined)})`;
|
|
123
123
|
}
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel';
|
|
125
|
+
// Show a button so the user can choose to resend the last message (session scope only).
|
|
126
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
126
127
|
if (isThread && clearedType === 'session' && sessionId) {
|
|
127
128
|
const runtime = getRuntime(channel.id);
|
|
128
129
|
if (runtime) {
|
|
129
|
-
|
|
130
|
+
const retryButton = new ButtonBuilder()
|
|
131
|
+
.setCustomId(`unset_model_retry:${channel.id}`)
|
|
132
|
+
.setLabel('Resend last message')
|
|
133
|
+
.setStyle(ButtonStyle.Secondary);
|
|
134
|
+
const actionRow = new ActionRowBuilder().addComponents(retryButton);
|
|
135
|
+
await interaction.editReply({
|
|
136
|
+
content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
|
|
137
|
+
components: [actionRow],
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
130
140
|
}
|
|
131
141
|
}
|
|
132
|
-
const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel';
|
|
133
|
-
const retriedText = retried
|
|
134
|
-
? '\n_Restarting current request with new model..._'
|
|
135
|
-
: '';
|
|
136
142
|
await interaction.editReply({
|
|
137
|
-
content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}
|
|
143
|
+
content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Handle the "Resend last message" button after clearing a session model override.
|
|
148
|
+
*/
|
|
149
|
+
export async function handleUnsetModelRetryButton(interaction) {
|
|
150
|
+
const customId = interaction.customId;
|
|
151
|
+
if (!customId.startsWith('unset_model_retry:')) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
await interaction.deferUpdate();
|
|
155
|
+
const threadId = customId.replace('unset_model_retry:', '');
|
|
156
|
+
if (!threadId) {
|
|
157
|
+
await interaction.editReply({
|
|
158
|
+
content: 'Could not find the session. Please resend your message manually.',
|
|
159
|
+
components: [],
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const runtime = getRuntime(threadId);
|
|
164
|
+
if (!runtime) {
|
|
165
|
+
await interaction.editReply({
|
|
166
|
+
content: 'No active session found. Please resend your message manually.',
|
|
167
|
+
components: [],
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const retried = await runtime.retryLastUserPrompt();
|
|
172
|
+
await interaction.editReply({
|
|
173
|
+
content: retried
|
|
174
|
+
? 'Restarting with new model...'
|
|
175
|
+
: 'Model changed. Send a new message to continue.',
|
|
176
|
+
components: [],
|
|
138
177
|
});
|
|
139
178
|
}
|
|
@@ -21,8 +21,8 @@ import { handleDiffCommand } from './commands/diff.js';
|
|
|
21
21
|
import { handleForkCommand, handleForkSelectMenu, } from './commands/fork.js';
|
|
22
22
|
import { handleForkSubagentCommand, handleForkSubagentSelectMenu, } from './commands/fork-subagent.js';
|
|
23
23
|
import { handleBtwCommand } from './commands/btw.js';
|
|
24
|
-
import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, } from './commands/model.js';
|
|
25
|
-
import { handleUnsetModelCommand } from './commands/unset-model.js';
|
|
24
|
+
import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, handleModelRetryButton, } from './commands/model.js';
|
|
25
|
+
import { handleUnsetModelCommand, handleUnsetModelRetryButton, } from './commands/unset-model.js';
|
|
26
26
|
import { handleLoginCommand, handleLoginSelect, handleLoginTextButton, handleLoginTextModalSubmit, handleLoginApiKeyButton, handleOAuthCodeButton, handleOAuthCodeModalSubmit, handleApiKeyModalSubmit, } from './commands/login.js';
|
|
27
27
|
import { handleTranscriptionApiKeyButton, handleTranscriptionApiKeyCommand, handleTranscriptionApiKeyModalSubmit, } from './commands/gemini-apikey.js';
|
|
28
28
|
import { handleAgentCommand, handleAgentSelectMenu, handleQuickAgentCommand, } from './commands/agent.js';
|
|
@@ -44,7 +44,7 @@ import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
|
|
|
44
44
|
import { handleScreenshareCommand, handleScreenshareStopCommand, } from './commands/screenshare.js';
|
|
45
45
|
import { handleVscodeCommand } from './commands/vscode.js';
|
|
46
46
|
import { handleModelVariantSelectMenu } from './commands/model.js';
|
|
47
|
-
import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, } from './commands/model-variant.js';
|
|
47
|
+
import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, handleVariantRetryButton, } from './commands/model-variant.js';
|
|
48
48
|
import { hasOttoBotPermission } from './discord-utils.js';
|
|
49
49
|
import { createLogger, LogPrefix } from './logger.js';
|
|
50
50
|
import { notifyError } from './sentry.js';
|
|
@@ -302,6 +302,18 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
302
302
|
await handleHtmlActionButton(interaction);
|
|
303
303
|
return;
|
|
304
304
|
}
|
|
305
|
+
if (customId.startsWith('model_retry:')) {
|
|
306
|
+
await handleModelRetryButton(interaction);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (customId.startsWith('variant_retry:')) {
|
|
310
|
+
await handleVariantRetryButton(interaction);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (customId.startsWith('unset_model_retry:')) {
|
|
314
|
+
await handleUnsetModelRetryButton(interaction);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
305
317
|
return;
|
|
306
318
|
}
|
|
307
319
|
if (interaction.isStringSelectMenu()) {
|
package/package.json
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
import {
|
|
10
10
|
ChatInputCommandInteraction,
|
|
11
11
|
StringSelectMenuInteraction,
|
|
12
|
+
ButtonInteraction,
|
|
12
13
|
StringSelectMenuBuilder,
|
|
14
|
+
ButtonBuilder,
|
|
15
|
+
ButtonStyle,
|
|
13
16
|
ActionRowBuilder,
|
|
14
17
|
ChannelType,
|
|
15
18
|
type ThreadChannel,
|
|
@@ -427,22 +430,22 @@ async function applyVariant({
|
|
|
427
430
|
`Set variant ${variant ?? 'none'} for session ${context.sessionId} (model ${modelId})`,
|
|
428
431
|
)
|
|
429
432
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
433
|
+
// Show a button so the user can choose to resend the last message.
|
|
434
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
435
|
+
// Context is kept alive (TTL handles cleanup) so the button can access it.
|
|
436
|
+
const retryButton = new ButtonBuilder()
|
|
437
|
+
.setCustomId(`variant_retry:${contextHash}`)
|
|
438
|
+
.setLabel('Resend last message')
|
|
439
|
+
.setStyle(ButtonStyle.Secondary)
|
|
440
|
+
|
|
441
|
+
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
|
|
437
442
|
|
|
438
|
-
const retryNote = retried
|
|
439
|
-
? '\n_Restarting current request with new variant..._'
|
|
440
|
-
: ''
|
|
441
443
|
await interaction.editReply({
|
|
442
|
-
content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${
|
|
444
|
+
content: `Variant set for this session:\n**${context.providerName}** / **${context.modelName}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
|
|
443
445
|
flags: MessageFlags.SuppressEmbeds,
|
|
444
|
-
components: [],
|
|
446
|
+
components: [actionRow],
|
|
445
447
|
})
|
|
448
|
+
return
|
|
446
449
|
} else if (scope === 'global') {
|
|
447
450
|
await setGlobalModel({ appId: context.appId, modelId, variant })
|
|
448
451
|
await setChannelModel({
|
|
@@ -477,7 +480,11 @@ async function applyVariant({
|
|
|
477
480
|
})
|
|
478
481
|
}
|
|
479
482
|
|
|
480
|
-
|
|
483
|
+
// Clean up the context from memory, except for session scope where the
|
|
484
|
+
// retry button still needs it. TTL handles cleanup in that case.
|
|
485
|
+
if (scope !== 'session') {
|
|
486
|
+
pendingVariantContexts.delete(contextHash)
|
|
487
|
+
}
|
|
481
488
|
} catch (error) {
|
|
482
489
|
logger.error('Error applying variant:', error)
|
|
483
490
|
await interaction.editReply({
|
|
@@ -486,3 +493,51 @@ async function applyVariant({
|
|
|
486
493
|
})
|
|
487
494
|
}
|
|
488
495
|
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Handle the "Resend last message" button shown after a session-scope variant change.
|
|
499
|
+
* Aborts any active run and resends the last user prompt with the new variant.
|
|
500
|
+
*/
|
|
501
|
+
export async function handleVariantRetryButton(
|
|
502
|
+
interaction: ButtonInteraction,
|
|
503
|
+
): Promise<void> {
|
|
504
|
+
const customId = interaction.customId
|
|
505
|
+
if (!customId.startsWith('variant_retry:')) {
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
await interaction.deferUpdate()
|
|
510
|
+
|
|
511
|
+
const contextHash = customId.replace('variant_retry:', '')
|
|
512
|
+
const context = pendingVariantContexts.get(contextHash)
|
|
513
|
+
|
|
514
|
+
// Context may have expired (TTL) — still allow the action via runtime alone.
|
|
515
|
+
const threadId = context?.thread?.id
|
|
516
|
+
|
|
517
|
+
if (!threadId) {
|
|
518
|
+
await interaction.editReply({
|
|
519
|
+
content: 'Could not find the session thread. Please resend your message manually.',
|
|
520
|
+
components: [],
|
|
521
|
+
})
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const runtime = getRuntime(threadId)
|
|
526
|
+
if (!runtime) {
|
|
527
|
+
await interaction.editReply({
|
|
528
|
+
content: 'No active session found. Please resend your message manually.',
|
|
529
|
+
components: [],
|
|
530
|
+
})
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const retried = await runtime.retryLastUserPrompt()
|
|
535
|
+
pendingVariantContexts.delete(contextHash)
|
|
536
|
+
|
|
537
|
+
await interaction.editReply({
|
|
538
|
+
content: retried
|
|
539
|
+
? `Restarting with new variant...`
|
|
540
|
+
: `Variant changed. Send a new message to continue.`,
|
|
541
|
+
components: [],
|
|
542
|
+
})
|
|
543
|
+
}
|
package/src/commands/model.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
import {
|
|
4
4
|
ChatInputCommandInteraction,
|
|
5
5
|
StringSelectMenuInteraction,
|
|
6
|
+
ButtonInteraction,
|
|
6
7
|
StringSelectMenuBuilder,
|
|
8
|
+
ButtonBuilder,
|
|
9
|
+
ButtonStyle,
|
|
7
10
|
ActionRowBuilder,
|
|
8
11
|
ChannelType,
|
|
9
12
|
type ThreadChannel,
|
|
@@ -1020,22 +1023,22 @@ export async function handleModelScopeSelectMenu(
|
|
|
1020
1023
|
`Set model ${modelId}${variantSuffix} for session ${context.sessionId}`,
|
|
1021
1024
|
)
|
|
1022
1025
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1026
|
+
// Show a button so the user can choose to resend the last message.
|
|
1027
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
1028
|
+
// Context is kept alive (TTL handles cleanup) so the button can access it.
|
|
1029
|
+
const retryButton = new ButtonBuilder()
|
|
1030
|
+
.setCustomId(`model_retry:${contextHash}`)
|
|
1031
|
+
.setLabel('Resend last message')
|
|
1032
|
+
.setStyle(ButtonStyle.Secondary)
|
|
1033
|
+
|
|
1034
|
+
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
|
|
1030
1035
|
|
|
1031
|
-
const retryNote = retried
|
|
1032
|
-
? '\n_Restarting current request with new model..._'
|
|
1033
|
-
: ''
|
|
1034
1036
|
await interaction.editReply({
|
|
1035
|
-
content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${
|
|
1037
|
+
content: `Model set for this session:\n**${context.providerName}** / **${modelDisplay}**${variantSuffix}\n\`${modelId}\`${agentTip}`,
|
|
1036
1038
|
flags: MessageFlags.SuppressEmbeds,
|
|
1037
|
-
components: [],
|
|
1039
|
+
components: [actionRow],
|
|
1038
1040
|
})
|
|
1041
|
+
return
|
|
1039
1042
|
} else if (selectedScope === 'global') {
|
|
1040
1043
|
if (!context.appId) {
|
|
1041
1044
|
deleteModelContext(contextHash)
|
|
@@ -1070,8 +1073,11 @@ export async function handleModelScopeSelectMenu(
|
|
|
1070
1073
|
})
|
|
1071
1074
|
}
|
|
1072
1075
|
|
|
1073
|
-
// Clean up the context from memory
|
|
1074
|
-
|
|
1076
|
+
// Clean up the context from memory, except for session scope where the
|
|
1077
|
+
// retry button still needs it. TTL handles cleanup in that case.
|
|
1078
|
+
if (selectedScope !== 'session') {
|
|
1079
|
+
deleteModelContext(contextHash)
|
|
1080
|
+
}
|
|
1075
1081
|
} catch (error) {
|
|
1076
1082
|
modelLogger.error('Error saving model preference:', error)
|
|
1077
1083
|
await interaction.editReply({
|
|
@@ -1080,3 +1086,51 @@ export async function handleModelScopeSelectMenu(
|
|
|
1080
1086
|
})
|
|
1081
1087
|
}
|
|
1082
1088
|
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Handle the "Resend last message" button shown after a session-scope model change.
|
|
1092
|
+
* Aborts any active run and resends the last user prompt with the new model.
|
|
1093
|
+
*/
|
|
1094
|
+
export async function handleModelRetryButton(
|
|
1095
|
+
interaction: ButtonInteraction,
|
|
1096
|
+
): Promise<void> {
|
|
1097
|
+
const customId = interaction.customId
|
|
1098
|
+
if (!customId.startsWith('model_retry:')) {
|
|
1099
|
+
return
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
await interaction.deferUpdate()
|
|
1103
|
+
|
|
1104
|
+
const contextHash = customId.replace('model_retry:', '')
|
|
1105
|
+
const context = pendingModelContexts.get(contextHash)
|
|
1106
|
+
|
|
1107
|
+
// Context may have expired (10 min TTL) — still allow the action via runtime alone.
|
|
1108
|
+
const threadId = context?.thread?.id
|
|
1109
|
+
|
|
1110
|
+
if (!threadId) {
|
|
1111
|
+
await interaction.editReply({
|
|
1112
|
+
content: 'Could not find the session thread. Please resend your message manually.',
|
|
1113
|
+
components: [],
|
|
1114
|
+
})
|
|
1115
|
+
return
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const runtime = getRuntime(threadId)
|
|
1119
|
+
if (!runtime) {
|
|
1120
|
+
await interaction.editReply({
|
|
1121
|
+
content: 'No active session found. Please resend your message manually.',
|
|
1122
|
+
components: [],
|
|
1123
|
+
})
|
|
1124
|
+
return
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const retried = await runtime.retryLastUserPrompt()
|
|
1128
|
+
deleteModelContext(contextHash)
|
|
1129
|
+
|
|
1130
|
+
await interaction.editReply({
|
|
1131
|
+
content: retried
|
|
1132
|
+
? `Restarting with new model...`
|
|
1133
|
+
: `Model changed. Send a new message to continue.`,
|
|
1134
|
+
components: [],
|
|
1135
|
+
})
|
|
1136
|
+
}
|
|
@@ -92,6 +92,152 @@ function takePendingPermissionContext(contextHash: string): PendingPermissionCon
|
|
|
92
92
|
return ctx
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Render rich tool context from permission metadata for Discord/Telegram.
|
|
97
|
+
* Shows what the agent is actually trying to do — the command, file path,
|
|
98
|
+
* URL, content preview, etc. — so the user can make an informed decision.
|
|
99
|
+
*/
|
|
100
|
+
function renderPermissionContext(permission: PermissionRequest): string {
|
|
101
|
+
const tool = permission.permission.toLowerCase()
|
|
102
|
+
const meta = permission.metadata ?? {}
|
|
103
|
+
|
|
104
|
+
// Read a string value from metadata, trying multiple key names
|
|
105
|
+
const getStr = (keys: string[], fallback = ''): string => {
|
|
106
|
+
for (const key of keys) {
|
|
107
|
+
const val = meta[key]
|
|
108
|
+
if (typeof val === 'string' && val.length > 0) return val
|
|
109
|
+
}
|
|
110
|
+
return fallback
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const truncate = (s: string, maxLen = 500): string =>
|
|
114
|
+
s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s
|
|
115
|
+
|
|
116
|
+
const clipBlock = (s: string, limit: number): string =>
|
|
117
|
+
s.length > limit ? s.slice(0, limit - 1) + '…' : s
|
|
118
|
+
|
|
119
|
+
switch (tool) {
|
|
120
|
+
case 'bash':
|
|
121
|
+
case 'shell':
|
|
122
|
+
case 'shell_command':
|
|
123
|
+
case 'cmd':
|
|
124
|
+
case 'terminal': {
|
|
125
|
+
const command = getStr(['command', 'cmd', 'script'])
|
|
126
|
+
const description = getStr(['description'])
|
|
127
|
+
if (!command && !description) return ''
|
|
128
|
+
const parts: string[] = []
|
|
129
|
+
if (description) parts.push(`> *${clipBlock(description, 200)}*`)
|
|
130
|
+
if (command) parts.push(`\`\`\`bash\n${clipBlock(command, 800)}\n\`\`\``)
|
|
131
|
+
return parts.join('\n')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case 'edit':
|
|
135
|
+
case 'multiedit':
|
|
136
|
+
case 'str_replace':
|
|
137
|
+
case 'str_replace_based_edit_tool':
|
|
138
|
+
case 'apply_patch': {
|
|
139
|
+
const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file'])
|
|
140
|
+
const oldString = getStr(['old_string', 'oldString', 'changes', 'diff'])
|
|
141
|
+
const newString = getStr(['new_string', 'newString'])
|
|
142
|
+
if (!filePath && !oldString && !newString) return ''
|
|
143
|
+
const parts: string[] = []
|
|
144
|
+
if (filePath) parts.push(`**File:** \`${filePath}\``)
|
|
145
|
+
if (oldString) {
|
|
146
|
+
parts.push(`**Replace:**\n\`\`\`diff\n- ${clipBlock(oldString, 400)}\n+ ${clipBlock(newString || '', 400)}\n\`\`\``)
|
|
147
|
+
} else if (newString) {
|
|
148
|
+
parts.push(`**New content:**\n\`\`\`\n${clipBlock(newString, 600)}\n\`\`\``)
|
|
149
|
+
}
|
|
150
|
+
return parts.join('\n')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case 'write':
|
|
154
|
+
case 'create':
|
|
155
|
+
case 'file_write': {
|
|
156
|
+
const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file'])
|
|
157
|
+
const content = getStr(['content', 'text', 'data'])
|
|
158
|
+
if (!filePath && !content) return ''
|
|
159
|
+
const parts: string[] = []
|
|
160
|
+
if (filePath) parts.push(`**File:** \`${filePath}\``)
|
|
161
|
+
if (content) {
|
|
162
|
+
parts.push(`\`\`\`\n${clipBlock(content, 600)}\n\`\`\``)
|
|
163
|
+
}
|
|
164
|
+
return parts.join('\n')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case 'webfetch':
|
|
168
|
+
case 'fetch':
|
|
169
|
+
case 'curl':
|
|
170
|
+
case 'wget': {
|
|
171
|
+
const url = getStr(['url', 'uri', 'endpoint'])
|
|
172
|
+
const method = getStr(['method']) || 'GET'
|
|
173
|
+
if (!url) return ''
|
|
174
|
+
return `**URL:** \`${method.toUpperCase()}\` ${url}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case 'read': {
|
|
178
|
+
const filePath = getStr(['filePath', 'file_path', 'path', 'file', 'filename'])
|
|
179
|
+
const parentDir = getStr(['parentDir', 'parent_dir', 'directory'])
|
|
180
|
+
if (!filePath && !parentDir) return ''
|
|
181
|
+
const parts: string[] = []
|
|
182
|
+
if (filePath) parts.push(`**Reading:** \`${filePath}\``)
|
|
183
|
+
if (parentDir) parts.push(`**Directory:** \`${parentDir}\``)
|
|
184
|
+
return parts.join('\n')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'list':
|
|
188
|
+
case 'ls': {
|
|
189
|
+
const listPath = getStr(['path', 'directory', 'filePath'])
|
|
190
|
+
if (!listPath) return ''
|
|
191
|
+
return `**Listing:** \`${listPath}\``
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case 'glob': {
|
|
195
|
+
const pattern = getStr(['pattern', 'glob'])
|
|
196
|
+
if (!pattern) return ''
|
|
197
|
+
return `**Pattern:** \`${pattern}\``
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
case 'grep': {
|
|
201
|
+
const pattern = getStr(['pattern', 'query'])
|
|
202
|
+
if (!pattern) return ''
|
|
203
|
+
return `**Search:** \`${pattern}\``
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case 'external_directory': {
|
|
207
|
+
const filepath = getStr(['filepath', 'path', 'directory'])
|
|
208
|
+
const parentDir = getStr(['parentDir', 'parent_dir'])
|
|
209
|
+
const parts: string[] = []
|
|
210
|
+
if (filepath) parts.push(`**Path:** \`${filepath}\``)
|
|
211
|
+
if (parentDir) parts.push(`**Parent:** \`${parentDir}\``)
|
|
212
|
+
return parts.join('\n')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case 'task':
|
|
216
|
+
case 'subagent': {
|
|
217
|
+
const description = getStr(['description', 'prompt'])
|
|
218
|
+
if (!description) return ''
|
|
219
|
+
return `> ${clipBlock(description, 300)}`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
default: {
|
|
223
|
+
// Generic: show description or first meaningful metadata field
|
|
224
|
+
const description = getStr(['description', 'action', 'operation', 'command'])
|
|
225
|
+
if (description) return `> *${clipBlock(description, 300)}*`
|
|
226
|
+
const relevantKeys = Object.keys(meta).filter(
|
|
227
|
+
(k) => !['sessionID', 'id', 'type'].includes(k),
|
|
228
|
+
)
|
|
229
|
+
if (relevantKeys.length > 0) {
|
|
230
|
+
const preview = relevantKeys
|
|
231
|
+
.slice(0, 3)
|
|
232
|
+
.map((k) => `${k}: ${String(meta[k]).slice(0, 60)}`)
|
|
233
|
+
.join('\n')
|
|
234
|
+
return `\`\`\`\n${clipBlock(preview, 400)}\n\`\`\``
|
|
235
|
+
}
|
|
236
|
+
return ''
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
95
241
|
/**
|
|
96
242
|
* Show permission buttons for a permission request.
|
|
97
243
|
* Displays 3 buttons in a row: Accept, Accept Always, Deny.
|
|
@@ -178,12 +324,14 @@ export async function showPermissionButtons({
|
|
|
178
324
|
permission.permission === 'external_directory'
|
|
179
325
|
? `Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n`
|
|
180
326
|
: ''
|
|
327
|
+
const contextStr = renderPermissionContext(permission)
|
|
181
328
|
const fullContent =
|
|
182
329
|
`⚠️ **Permission Required**\n` +
|
|
183
330
|
subtaskLine +
|
|
184
331
|
`**Type:** \`${permission.permission}\`\n` +
|
|
185
332
|
externalDirLine +
|
|
186
|
-
(patternStr ? `**Pattern:** \`${patternStr}
|
|
333
|
+
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
334
|
+
(contextStr ? `\n${contextStr}` : '')
|
|
187
335
|
const permissionMessage = await thread.send({
|
|
188
336
|
content: fullContent.slice(0, 1900),
|
|
189
337
|
components: [actionRow],
|
|
@@ -215,13 +363,15 @@ function updatePermissionMessage({
|
|
|
215
363
|
context.permission.permission === 'external_directory'
|
|
216
364
|
? 'Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n'
|
|
217
365
|
: ''
|
|
366
|
+
const contextStr = renderPermissionContext(context.permission)
|
|
218
367
|
return message.edit({
|
|
219
368
|
content:
|
|
220
369
|
`⚠️ **Permission Required**\n` +
|
|
221
370
|
`**Type:** \`${context.permission.permission}\`\n` +
|
|
222
371
|
externalDirLine +
|
|
223
372
|
(patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
|
|
224
|
-
|
|
373
|
+
(contextStr ? `\n${contextStr}\n` : '') +
|
|
374
|
+
`\n${status}`,
|
|
225
375
|
components: [],
|
|
226
376
|
})
|
|
227
377
|
})
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
ChatInputCommandInteraction,
|
|
5
|
+
ButtonInteraction,
|
|
6
|
+
ButtonBuilder,
|
|
7
|
+
ButtonStyle,
|
|
8
|
+
ActionRowBuilder,
|
|
5
9
|
ChannelType,
|
|
6
10
|
type ThreadChannel,
|
|
7
11
|
type TextChannel,
|
|
@@ -154,21 +158,69 @@ export async function handleUnsetModelCommand({
|
|
|
154
158
|
: `\`${newModelInfo.model}\` (${formatModelSource(newModelInfo.type, 'agentName' in newModelInfo ? newModelInfo.agentName : undefined)})`
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
const clearedTypeText = clearedType === 'session' ? 'Session' : 'Channel'
|
|
162
|
+
// Show a button so the user can choose to resend the last message (session scope only).
|
|
163
|
+
// Do not auto-retry — the user may want to add context in a new message instead.
|
|
159
164
|
if (isThread && clearedType === 'session' && sessionId) {
|
|
160
165
|
const runtime = getRuntime(channel.id)
|
|
161
166
|
if (runtime) {
|
|
162
|
-
|
|
167
|
+
const retryButton = new ButtonBuilder()
|
|
168
|
+
.setCustomId(`unset_model_retry:${channel.id}`)
|
|
169
|
+
.setLabel('Resend last message')
|
|
170
|
+
.setStyle(ButtonStyle.Secondary)
|
|
171
|
+
|
|
172
|
+
const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents(retryButton)
|
|
173
|
+
|
|
174
|
+
await interaction.editReply({
|
|
175
|
+
content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
|
|
176
|
+
components: [actionRow],
|
|
177
|
+
})
|
|
178
|
+
return
|
|
163
179
|
}
|
|
164
180
|
}
|
|
165
181
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
182
|
+
await interaction.editReply({
|
|
183
|
+
content: `${clearedTypeText} model override removed.\n**Was:** \`${clearedModel}\`\n**Now using:** ${newModelText}`,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Handle the "Resend last message" button after clearing a session model override.
|
|
189
|
+
*/
|
|
190
|
+
export async function handleUnsetModelRetryButton(
|
|
191
|
+
interaction: ButtonInteraction,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
const customId = interaction.customId
|
|
194
|
+
if (!customId.startsWith('unset_model_retry:')) {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await interaction.deferUpdate()
|
|
199
|
+
|
|
200
|
+
const threadId = customId.replace('unset_model_retry:', '')
|
|
201
|
+
if (!threadId) {
|
|
202
|
+
await interaction.editReply({
|
|
203
|
+
content: 'Could not find the session. Please resend your message manually.',
|
|
204
|
+
components: [],
|
|
205
|
+
})
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const runtime = getRuntime(threadId)
|
|
210
|
+
if (!runtime) {
|
|
211
|
+
await interaction.editReply({
|
|
212
|
+
content: 'No active session found. Please resend your message manually.',
|
|
213
|
+
components: [],
|
|
214
|
+
})
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const retried = await runtime.retryLastUserPrompt()
|
|
170
219
|
|
|
171
220
|
await interaction.editReply({
|
|
172
|
-
content:
|
|
221
|
+
content: retried
|
|
222
|
+
? 'Restarting with new model...'
|
|
223
|
+
: 'Model changed. Send a new message to continue.',
|
|
224
|
+
components: [],
|
|
173
225
|
})
|
|
174
226
|
}
|
|
@@ -57,8 +57,12 @@ import {
|
|
|
57
57
|
handleProviderSelectMenu,
|
|
58
58
|
handleModelSelectMenu,
|
|
59
59
|
handleModelScopeSelectMenu,
|
|
60
|
+
handleModelRetryButton,
|
|
60
61
|
} from './commands/model.js'
|
|
61
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
handleUnsetModelCommand,
|
|
64
|
+
handleUnsetModelRetryButton,
|
|
65
|
+
} from './commands/unset-model.js'
|
|
62
66
|
import {
|
|
63
67
|
handleLoginCommand,
|
|
64
68
|
handleLoginSelect,
|
|
@@ -116,6 +120,7 @@ import {
|
|
|
116
120
|
handleModelVariantCommand,
|
|
117
121
|
handleVariantQuickSelectMenu,
|
|
118
122
|
handleVariantScopeSelectMenu,
|
|
123
|
+
handleVariantRetryButton,
|
|
119
124
|
} from './commands/model-variant.js'
|
|
120
125
|
import { hasOttoBotPermission } from './discord-utils.js'
|
|
121
126
|
import { createLogger, LogPrefix } from './logger.js'
|
|
@@ -463,6 +468,21 @@ export function registerInteractionHandler({
|
|
|
463
468
|
return
|
|
464
469
|
}
|
|
465
470
|
|
|
471
|
+
if (customId.startsWith('model_retry:')) {
|
|
472
|
+
await handleModelRetryButton(interaction)
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (customId.startsWith('variant_retry:')) {
|
|
477
|
+
await handleVariantRetryButton(interaction)
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (customId.startsWith('unset_model_retry:')) {
|
|
482
|
+
await handleUnsetModelRetryButton(interaction)
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
|
|
466
486
|
return
|
|
467
487
|
}
|
|
468
488
|
|