kimaki 0.24.0 → 0.25.0

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 (46) hide show
  1. package/dist/cli.js +5 -0
  2. package/dist/cloudflare-transcription.js +56 -0
  3. package/dist/commands/model-variant.js +17 -1
  4. package/dist/commands/queue.js +64 -0
  5. package/dist/config.js +9 -0
  6. package/dist/discord-bot.js +1 -5
  7. package/dist/discord-command-registration.js +17 -12
  8. package/dist/external-directory-permissions.e2e.test.js +82 -0
  9. package/dist/external-directory-project-deny.e2e.test.js +68 -0
  10. package/dist/format-tables.js +53 -5
  11. package/dist/format-tables.test.js +121 -0
  12. package/dist/interaction-handler.js +8 -6
  13. package/dist/message-preprocessing.js +2 -25
  14. package/dist/opencode.js +126 -96
  15. package/dist/queue-advanced-e2e-setup.js +63 -27
  16. package/dist/queue-advanced-permissions-typing.e2e.test.js +4 -0
  17. package/dist/session-handler/thread-session-runtime.js +5 -20
  18. package/dist/store.js +1 -0
  19. package/dist/voice-handler.js +37 -11
  20. package/package.json +3 -3
  21. package/src/cli.ts +11 -0
  22. package/src/cloudflare-transcription.ts +76 -0
  23. package/src/commands/model-variant.ts +26 -0
  24. package/src/commands/queue.ts +83 -0
  25. package/src/config.ts +10 -0
  26. package/src/discord-bot.ts +0 -5
  27. package/src/discord-command-registration.ts +22 -15
  28. package/src/external-directory-permissions.e2e.test.ts +99 -0
  29. package/src/external-directory-project-deny.e2e.test.ts +82 -0
  30. package/src/format-tables.test.ts +126 -0
  31. package/src/format-tables.ts +68 -5
  32. package/src/interaction-handler.ts +10 -5
  33. package/src/message-preprocessing.ts +2 -37
  34. package/src/opencode.ts +132 -111
  35. package/src/queue-advanced-e2e-setup.ts +78 -29
  36. package/src/queue-advanced-permissions-typing.e2e.test.ts +4 -0
  37. package/src/session-handler/thread-runtime-state.ts +0 -4
  38. package/src/session-handler/thread-session-runtime.ts +3 -25
  39. package/src/store.ts +10 -0
  40. package/src/voice-handler.ts +37 -10
  41. package/dist/channel-reference-permissions.e2e.test.js +0 -87
  42. package/dist/commands/add-dir.js +0 -169
  43. package/dist/commands/add-dir.test.js +0 -126
  44. package/src/channel-reference-permissions.e2e.test.ts +0 -104
  45. package/src/commands/add-dir.test.ts +0 -154
  46. package/src/commands/add-dir.ts +0 -244
package/dist/cli.js CHANGED
@@ -39,6 +39,7 @@ cli
39
39
  .option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
40
40
  .option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
41
41
  .option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
42
+ .option('--restrict-directories', 'Only allow the agent to access the session working directory and a few known-safe paths. Any other folder asks for permission. By default every directory is allowed and you protect folders with deny/ask rules in opencode.json')
42
43
  .option('--permission-timeout-minutes <minutes>', 'Permission prompt timeout in minutes before auto-rejecting (default: 10)')
43
44
  .option('--disable-sync', 'Disable background sync of external OpenCode sessions into Discord')
44
45
  .option('--no-analytics', 'Disable anonymous product analytics (Strada). Same as KIMAKI_STRADA_ENABLED=0')
@@ -155,6 +156,7 @@ cli
155
156
  ...(options.mentionMode && { defaultMentionMode: true }),
156
157
  ...(options.noCritique && { critiqueEnabled: false }),
157
158
  ...(options.allowAllUsers && { allowAllUsers: true }),
159
+ ...(options.restrictDirectories && { restrictExternalDirectories: true }),
158
160
  ...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
159
161
  ...(options.noAutoUpgrade && { autoUpgradeEnabled: false }),
160
162
  ...(options.disableSync && { syncEnabled: false }),
@@ -171,6 +173,9 @@ cli
171
173
  if (options.allowAllUsers) {
172
174
  cliLogger.log('Allow all users: any Discord member can start sessions (no-kimaki role still blocks)');
173
175
  }
176
+ if (options.restrictDirectories) {
177
+ cliLogger.log('Restricted directories: the agent asks before reading outside the working directory');
178
+ }
174
179
  if (permissionTimeoutMs !== undefined) {
175
180
  cliLogger.log(`Permission timeout set to ${options.permissionTimeoutMinutes} minutes`);
176
181
  }
@@ -0,0 +1,56 @@
1
+ // Free Whisper transcription fallback via the kimaki.dev Cloudflare Worker.
2
+ // Only usable in gateway mode: the worker authenticates the request against
3
+ // the shared gateway_clients Postgres table using the CLI's own
4
+ // clientId:clientSecret pair (the same credentials already used for
5
+ // gateway-proxy REST/WebSocket calls), then runs
6
+ // @cf/openai/whisper-large-v3-turbo on Cloudflare's own Workers AI account.
7
+ // This means transcription costs Kimaki, never the user, so voice messages
8
+ // work out of the box for gateway-mode installs with no OpenAI/Gemini key
9
+ // configured.
10
+ //
11
+ // See website/src/server.tsx POST /api/transcribe for the server side.
12
+ // Rate limits (per-client burst + daily request cap) are enforced there,
13
+ // not here — a 429 from the worker just means this fallback is unavailable
14
+ // right now and the caller should fall back to the "add API key" dialog.
15
+ import { TranscriptionError } from './errors.js';
16
+ import { createLogger, LogPrefix } from './logger.js';
17
+ const voiceLogger = createLogger(LogPrefix.VOICE);
18
+ const KIMAKI_TRANSCRIBE_URL = process.env.KIMAKI_TRANSCRIBE_URL || 'https://kimaki.dev/api/transcribe';
19
+ export async function transcribeViaKimakiGateway({ audio, mediaType, clientId, clientSecret, }) {
20
+ const response = await fetch(KIMAKI_TRANSCRIBE_URL, {
21
+ method: 'POST',
22
+ headers: {
23
+ Authorization: `Bearer ${clientId}:${clientSecret}`,
24
+ 'Content-Type': mediaType || 'audio/ogg',
25
+ },
26
+ body: audio,
27
+ }).catch((cause) => {
28
+ return new TranscriptionError({
29
+ reason: 'Failed to reach kimaki.dev transcription endpoint',
30
+ cause,
31
+ });
32
+ });
33
+ if (response instanceof TranscriptionError)
34
+ return response;
35
+ if (!response.ok) {
36
+ const bodyText = await response.text().catch(() => '');
37
+ voiceLogger.log(`Kimaki gateway transcription failed: ${response.status} ${bodyText}`);
38
+ return new TranscriptionError({
39
+ reason: `kimaki.dev returned ${response.status}${bodyText ? `: ${bodyText}` : ''}`,
40
+ });
41
+ }
42
+ const data = (await response.json().catch((cause) => {
43
+ return new TranscriptionError({
44
+ reason: 'Failed to parse transcription response',
45
+ cause,
46
+ });
47
+ }));
48
+ if (data instanceof TranscriptionError)
49
+ return data;
50
+ if (!data.text) {
51
+ return new TranscriptionError({
52
+ reason: 'Empty transcription response from kimaki gateway',
53
+ });
54
+ }
55
+ return data.text;
56
+ }
@@ -6,7 +6,7 @@
6
6
  // Cross-menu state: Discord doesn't expose already-selected values on sibling
7
7
  // select menus in the same message. We track partial selections in the context
8
8
  // Map. Whichever menu fires second sees the first selection stored and applies.
9
- import { StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
9
+ import { ChatInputCommandInteraction, StringSelectMenuInteraction, StringSelectMenuBuilder, ActionRowBuilder, ChannelType, MessageFlags, } from 'discord.js';
10
10
  import crypto from 'node:crypto';
11
11
  import { setChannelModel, getThreadSession, setGlobalModel, getVariantCascade, } from '../database.js';
12
12
  import { initializeOpencodeForDirectory } from '../opencode.js';
@@ -39,6 +39,22 @@ function formatSourceLabel(info) {
39
39
  return 'none';
40
40
  }
41
41
  }
42
+ /** /model-variant slash command entrypoint — defers reply then delegates to the shared picker. */
43
+ export async function handleModelVariantCommand({ interaction, appId, }) {
44
+ await interaction.deferReply();
45
+ const channel = interaction.channel;
46
+ if (!channel) {
47
+ await interaction.editReply({
48
+ content: 'This command can only be used in a channel',
49
+ });
50
+ return;
51
+ }
52
+ await showModelVariantPicker({
53
+ channel,
54
+ appId,
55
+ editReply: (options) => interaction.editReply(options),
56
+ });
57
+ }
42
58
  export async function showModelVariantPicker({ channel, appId, editReply, }) {
43
59
  const isThread = [
44
60
  ChannelType.PublicThread,
@@ -114,6 +114,70 @@ export async function handleQueueCommand({ command, appId, }) {
114
114
  flags: SILENT_MESSAGE_FLAGS,
115
115
  });
116
116
  }
117
+ export async function handleClearQueueCommand({ command, }) {
118
+ const channel = command.channel;
119
+ const position = command.options.getInteger('position') ?? undefined;
120
+ if (!channel) {
121
+ await command.reply({
122
+ content: 'This command can only be used in a channel',
123
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
124
+ });
125
+ return;
126
+ }
127
+ const isThread = [
128
+ ChannelType.PublicThread,
129
+ ChannelType.PrivateThread,
130
+ ChannelType.AnnouncementThread,
131
+ ].includes(channel.type);
132
+ if (!isThread) {
133
+ await command.reply({
134
+ content: 'This command can only be used in a thread',
135
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
136
+ });
137
+ return;
138
+ }
139
+ const runtime = getRuntime(channel.id);
140
+ const queueLength = runtime?.getQueueLength() ?? 0;
141
+ if (queueLength === 0) {
142
+ await command.reply({
143
+ content: 'No messages in queue',
144
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
145
+ });
146
+ return;
147
+ }
148
+ if (position !== undefined) {
149
+ const removed = runtime?.removeQueuePosition(position);
150
+ if (!removed) {
151
+ await command.reply({
152
+ content: `No queued message at position ${position}`,
153
+ flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
154
+ });
155
+ return;
156
+ }
157
+ await command.reply({
158
+ content: `Cleared queued message at position ${position}`,
159
+ flags: SILENT_MESSAGE_FLAGS,
160
+ });
161
+ logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
162
+ return;
163
+ }
164
+ const cleared = runtime?.clearQueue() ?? [];
165
+ const lines = cleared.map((item, i) => {
166
+ const label = item.command
167
+ ? `/${item.command.name}`
168
+ : item.prompt;
169
+ return `${i + 1}. ${label}`;
170
+ });
171
+ let list = lines.join('\n');
172
+ if (list.length > 600) {
173
+ list = list.slice(0, 597) + '...';
174
+ }
175
+ await command.reply({
176
+ content: `Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`,
177
+ flags: SILENT_MESSAGE_FLAGS,
178
+ });
179
+ logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
180
+ }
117
181
  export async function handleQueueCommandCommand({ command, appId, }) {
118
182
  const commandName = command.options.getString('command', true);
119
183
  const args = command.options.getString('arguments') || '';
package/dist/config.js CHANGED
@@ -70,6 +70,15 @@ export function setProjectsDir(dir) {
70
70
  export function getPermissionTimeoutMs() {
71
71
  return store.getState().permissionTimeoutMs;
72
72
  }
73
+ /**
74
+ * Whether external directory access is restricted to the session working
75
+ * directory plus a few known-safe paths.
76
+ * Defaults to false: every directory is allowed and users protect specific
77
+ * folders with their own `deny`/`ask` rules in opencode.json.
78
+ */
79
+ export function getRestrictExternalDirectories() {
80
+ return store.getState().restrictExternalDirectories;
81
+ }
73
82
  const DEFAULT_LOCK_PORT = 29988;
74
83
  /**
75
84
  * Derive a lock port from the data directory path.
@@ -20,7 +20,7 @@ import { getFileAttachments, getTextAttachments, resolveMentions, } from './mess
20
20
  import { extractBtwSuffix } from './btw-prefix-detection.js';
21
21
  import { isVoiceAttachment } from './voice-attachment.js';
22
22
  import { forkSessionToBtwThread } from './commands/btw.js';
23
- import { extractQueueSuffix, getChannelReferencePermissionRules, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
23
+ import { extractQueueSuffix, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
24
24
  import { cancelPendingActionButtons } from './commands/action-buttons.js';
25
25
  import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
26
26
  import { cancelPendingFileUpload } from './commands/file-upload.js';
@@ -1088,12 +1088,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
1088
1088
  }
1089
1089
  : undefined,
1090
1090
  preprocess: async () => {
1091
- const permissionRules = await getChannelReferencePermissionRules({
1092
- message: starterMessage,
1093
- });
1094
1091
  return {
1095
1092
  prompt,
1096
- permissionRules,
1097
1093
  mode: 'opencode',
1098
1094
  ...(fileAttachments.length > 0 && { images: fileAttachments }),
1099
1095
  };
@@ -167,18 +167,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
167
167
  })
168
168
  .setDMPermission(false)
169
169
  .toJSON(),
170
- new SlashCommandBuilder()
171
- .setName('add-dir')
172
- .setDescription(truncateCommandDescription('Allow the current session to access an extra directory or * for all folders'))
173
- .addStringOption((option) => {
174
- option
175
- .setName('directory')
176
- .setDescription(truncateCommandDescription('Directory to allow, resolved from the current worktree. Use * for all folders'))
177
- .setRequired(false);
178
- return option;
179
- })
180
- .setDMPermission(false)
181
- .toJSON(),
182
170
  new SlashCommandBuilder()
183
171
  .setName('abort')
184
172
  .setDescription(truncateCommandDescription('Abort, stop, terminate, or cancel the current OpenCode request in this thread'))
@@ -226,6 +214,11 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
226
214
  .setDescription(truncateCommandDescription('Set model preference, change thinking level, or clear overrides'))
227
215
  .setDMPermission(false)
228
216
  .toJSON(),
217
+ new SlashCommandBuilder()
218
+ .setName('model-variant')
219
+ .setDescription(truncateCommandDescription('Change thinking level for current model. Tied to the model; lost when you switch models'))
220
+ .setDMPermission(false)
221
+ .toJSON(),
229
222
  new SlashCommandBuilder()
230
223
  .setName('login')
231
224
  .setDescription(truncateCommandDescription('Authenticate with an AI provider (OAuth or API key). Use this instead of /connect'))
@@ -248,6 +241,18 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
248
241
  })
249
242
  .setDMPermission(false)
250
243
  .toJSON(),
244
+ new SlashCommandBuilder()
245
+ .setName('clear-queue')
246
+ .setDescription(truncateCommandDescription('Clear all queued messages in this thread'))
247
+ .addIntegerOption((option) => {
248
+ option
249
+ .setName('position')
250
+ .setDescription(truncateCommandDescription('1-based queued message position to clear (default: all)'))
251
+ .setMinValue(1);
252
+ return option;
253
+ })
254
+ .setDMPermission(false)
255
+ .toJSON(),
251
256
  new SlashCommandBuilder()
252
257
  .setName('queue-command')
253
258
  .setDescription(truncateCommandDescription('Queue a user command to run after the current response finishes'))
@@ -0,0 +1,82 @@
1
+ // E2e test for the default external_directory permission: every directory is
2
+ // allowed, so reading a file outside the project never shows a permission
3
+ // prompt. The old behaviour (allow-list + prompt) is now opt-in behind the
4
+ // --restrict-directories CLI flag.
5
+ import { describe, expect, test } from 'vitest';
6
+ import fs from 'node:fs';
7
+ import { EXTERNAL_DIRECTORY_PROBE_DIR, EXTERNAL_DIRECTORY_PROBE_FILE, setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
8
+ import { waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
9
+ const TEXT_CHANNEL_ID = '200000000000001021';
10
+ describe('external directory permissions', () => {
11
+ const ctx = setupQueueAdvancedSuite({
12
+ channelId: TEXT_CHANNEL_ID,
13
+ channelName: 'qa-external-directory-e2e',
14
+ dirName: 'qa-external-directory-e2e',
15
+ username: 'external-directory-tester',
16
+ });
17
+ test('reads outside the project without a permission prompt', async () => {
18
+ fs.mkdirSync(EXTERNAL_DIRECTORY_PROBE_DIR, { recursive: true });
19
+ fs.writeFileSync(EXTERNAL_DIRECTORY_PROBE_FILE, 'external directory file');
20
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
21
+ content: 'EXTERNAL_DIRECTORY_PROBE_MARKER first',
22
+ });
23
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
24
+ timeout: 4_000,
25
+ predicate: (t) => {
26
+ return t.name?.includes('EXTERNAL_DIRECTORY_PROBE_MARKER') ?? false;
27
+ },
28
+ });
29
+ const th = ctx.discord.thread(thread.id);
30
+ await waitForBotMessageContaining({
31
+ discord: ctx.discord,
32
+ threadId: thread.id,
33
+ userId: TEST_USER_ID,
34
+ text: 'external-directory-probe-done',
35
+ timeout: 8_000,
36
+ });
37
+ await waitForFooterMessage({
38
+ discord: ctx.discord,
39
+ threadId: thread.id,
40
+ timeout: 4_000,
41
+ afterMessageIncludes: 'external-directory-probe-done',
42
+ afterAuthorId: ctx.discord.botUserId,
43
+ });
44
+ await th.user(TEST_USER_ID).sendMessage({
45
+ content: 'EXTERNAL_DIRECTORY_PROBE_MARKER followup',
46
+ });
47
+ await waitForBotMessageContaining({
48
+ discord: ctx.discord,
49
+ threadId: thread.id,
50
+ userId: TEST_USER_ID,
51
+ text: 'external-directory-probe-done',
52
+ afterUserMessageIncludes: 'followup',
53
+ timeout: 8_000,
54
+ });
55
+ await waitForFooterMessage({
56
+ discord: ctx.discord,
57
+ threadId: thread.id,
58
+ timeout: 4_000,
59
+ afterMessageIncludes: 'external-directory-probe-done',
60
+ afterAuthorId: ctx.discord.botUserId,
61
+ });
62
+ const text = await th.text();
63
+ expect(text).toMatchInlineSnapshot(`
64
+ "--- from: user (external-directory-tester)
65
+ EXTERNAL_DIRECTORY_PROBE_MARKER first
66
+ --- from: assistant (TestBot)
67
+ *using deterministic-provider/deterministic-v2*
68
+ ⬥ reading external directory
69
+ ┣ read *probe.txt*
70
+ ⬥ external-directory-probe-done
71
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
72
+ --- from: user (external-directory-tester)
73
+ EXTERNAL_DIRECTORY_PROBE_MARKER followup
74
+ --- from: assistant (TestBot)
75
+ ⬥ reading external directory
76
+ ┣ read *probe.txt*
77
+ ⬥ external-directory-probe-done
78
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
79
+ `);
80
+ expect(text).not.toContain('Permission Required');
81
+ });
82
+ });
@@ -0,0 +1,68 @@
1
+ // Regression test: a `deny` rule in the project's own opencode.json must still
2
+ // win over kimaki's allow-all default.
3
+ //
4
+ // This guards a real bug. opencode evaluates permissions with findLast() over
5
+ // merge(agent.permission, session.permission), so session rules are evaluated
6
+ // LAST and override user config. An earlier version of the allow-all default
7
+ // put `external_directory: '*' allow` into the session ruleset, which silently
8
+ // made every user `deny` rule a no-op. The allow now lives in the generated
9
+ // server config instead, where the project opencode.json deep-merges on top.
10
+ import { describe, expect, test } from 'vitest';
11
+ import fs from 'node:fs';
12
+ import { EXTERNAL_DIRECTORY_PROBE_DIR, EXTERNAL_DIRECTORY_PROBE_FILE, setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
13
+ import { waitForBotMessageContaining } from './test-utils.js';
14
+ const TEXT_CHANNEL_ID = '200000000000001023';
15
+ describe('external directory project deny', () => {
16
+ const ctx = setupQueueAdvancedSuite({
17
+ channelId: TEXT_CHANNEL_ID,
18
+ channelName: 'qa-external-directory-deny-e2e',
19
+ dirName: 'qa-external-directory-deny-e2e',
20
+ username: 'external-directory-deny-tester',
21
+ // Exactly what a user would write in their own opencode.json to protect a
22
+ // folder. Kimaki's generated config allows '*', this must still beat it.
23
+ projectPermission: {
24
+ external_directory: {
25
+ [EXTERNAL_DIRECTORY_PROBE_DIR]: 'deny',
26
+ [`${EXTERNAL_DIRECTORY_PROBE_DIR}/*`]: 'deny',
27
+ },
28
+ },
29
+ });
30
+ test('project opencode.json deny beats the allow-all default', async () => {
31
+ fs.mkdirSync(EXTERNAL_DIRECTORY_PROBE_DIR, { recursive: true });
32
+ fs.writeFileSync(EXTERNAL_DIRECTORY_PROBE_FILE, 'protected file');
33
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
34
+ content: 'EXTERNAL_DIRECTORY_PROBE_MARKER denied',
35
+ });
36
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
37
+ timeout: 4_000,
38
+ predicate: (t) => {
39
+ return t.name?.includes('EXTERNAL_DIRECTORY_PROBE_MARKER') ?? false;
40
+ },
41
+ });
42
+ const th = ctx.discord.thread(thread.id);
43
+ // The deterministic matcher only emits this after the read comes back
44
+ // rejected by a permission rule, so seeing it proves the deny applied.
45
+ await waitForBotMessageContaining({
46
+ discord: ctx.discord,
47
+ threadId: thread.id,
48
+ userId: TEST_USER_ID,
49
+ text: 'external-directory-probe-denied',
50
+ timeout: 8_000,
51
+ });
52
+ const text = await th.text();
53
+ expect(text).toMatchInlineSnapshot(`
54
+ "--- from: user (external-directory-deny-tester)
55
+ EXTERNAL_DIRECTORY_PROBE_MARKER denied
56
+ --- from: assistant (TestBot)
57
+ *using deterministic-provider/deterministic-v2*
58
+ ⬥ reading external directory
59
+ ┣ read *probe.txt*
60
+ ⬥ external-directory-probe-denied"
61
+ `);
62
+ // A deny is silent: it must not fall back to asking the user.
63
+ expect(text).not.toContain('Permission Required');
64
+ // And the read must not have succeeded, which is what the old
65
+ // session-level '*' allow rule caused.
66
+ expect(text).not.toContain('external-directory-probe-done');
67
+ });
68
+ });
@@ -267,7 +267,7 @@ function flattenCalloutChildren({ segments, }) {
267
267
  return [
268
268
  {
269
269
  type: ComponentType.TextDisplay,
270
- content: segment.text.trim(),
270
+ content: clampTextDisplayContent(segment.text.trim()),
271
271
  },
272
272
  ];
273
273
  }
@@ -279,15 +279,29 @@ function flattenCalloutChildren({ segments, }) {
279
279
  });
280
280
  });
281
281
  }
282
+ // Splits callout children into chunks that fit both the 40-component budget
283
+ // (reserving 1 for the Container wrapper itself, including nested children
284
+ // like ActionRow buttons via childComponentCost) and the 4000-char text
285
+ // budget. Mirrors chunkRowsByComponentLimit's approach for table rows.
282
286
  function chunkCalloutChildrenByComponentLimit({ children, }) {
283
287
  const chunks = [];
284
288
  let currentChunk = [];
289
+ let currentCost = 1; // reserve for the Container wrapper
290
+ let currentText = 0;
285
291
  for (const child of children) {
286
- if (currentChunk.length > 0 && currentChunk.length + 2 > MAX_COMPONENTS) {
292
+ const childCost = childComponentCost(child);
293
+ const childText = componentTextSize(child);
294
+ if (currentChunk.length > 0 &&
295
+ (currentCost + childCost > MAX_COMPONENTS ||
296
+ currentText + childText > MAX_TEXT_SIZE)) {
287
297
  chunks.push(currentChunk);
288
298
  currentChunk = [];
299
+ currentCost = 1;
300
+ currentText = 0;
289
301
  }
290
302
  currentChunk.push(child);
303
+ currentCost += childCost;
304
+ currentText += childText;
291
305
  }
292
306
  if (currentChunk.length > 0) {
293
307
  chunks.push(currentChunk);
@@ -423,16 +437,34 @@ function buildTextRow({ headers, cells, }) {
423
437
  const value = cell ? getRenderedCellText({ cell }) : '';
424
438
  return `**${key}** ${value}`;
425
439
  });
440
+ const content = clampTextDisplayContent(lines.join('\n'));
426
441
  return {
427
442
  components: [
428
443
  {
429
444
  type: ComponentType.TextDisplay,
430
- content: lines.join('\n'),
445
+ content,
431
446
  },
432
447
  ],
433
448
  componentCost: 1,
449
+ textSize: content.length,
434
450
  };
435
451
  }
452
+ // Discord's TextDisplay component enforces the same 4000-char content limit
453
+ // as the overall message text budget. A single row with one huge cell value
454
+ // (e.g. a long stack trace) could otherwise exceed it on its own, which no
455
+ // amount of row-level chunking can fix. `maxLength` lets callers reserve
456
+ // budget for sibling content (e.g. button labels) that shares the same
457
+ // message-wide text limit.
458
+ function clampTextDisplayContent(content, maxLength = MAX_TEXT_SIZE) {
459
+ const safeMax = Math.max(0, maxLength);
460
+ if (content.length <= safeMax) {
461
+ return content;
462
+ }
463
+ if (safeMax === 0) {
464
+ return '';
465
+ }
466
+ return content.slice(0, safeMax - 1) + '…';
467
+ }
436
468
  function buildButtonRow({ headers, cells, }) {
437
469
  const buttonCells = cells.filter((cell) => {
438
470
  return cell.type === 'button';
@@ -463,32 +495,48 @@ function buildButtonRow({ headers, cells, }) {
463
495
  type: ComponentType.ActionRow,
464
496
  components: buttons,
465
497
  };
498
+ const buttonLabelSize = buttonCells.reduce((sum, cell) => {
499
+ return sum + (cell.type === 'button' ? cell.label.length : 0);
500
+ }, 0);
501
+ // Reserve budget for button labels first since they can't be truncated,
502
+ // then clamp the TextDisplay content to whatever remains of MAX_TEXT_SIZE.
503
+ const content = clampTextDisplayContent(lines.join('\n'), MAX_TEXT_SIZE - buttonLabelSize);
466
504
  return {
467
505
  components: [
468
506
  {
469
507
  type: ComponentType.TextDisplay,
470
- content: lines.join('\n'),
508
+ content,
471
509
  },
472
510
  actionRow,
473
511
  ],
474
512
  componentCost: 2 + buttons.length,
513
+ textSize: content.length + buttonLabelSize,
475
514
  };
476
515
  }
516
+ // Splits rows into chunks that fit both the 40-component budget and the
517
+ // 4000-char displayable text budget. Wide/long tables (many long cell values)
518
+ // can stay under the component count while still exceeding Discord's text
519
+ // limit, which previously caused sendMessage to fail with a 400 error.
477
520
  function chunkRowsByComponentLimit({ rows, }) {
478
521
  const chunks = [];
479
522
  let currentChunk = [];
480
523
  let currentCost = 1;
524
+ let currentText = 0;
481
525
  for (const row of rows) {
482
526
  const separatorCost = currentChunk.length > 0 ? 1 : 0;
483
527
  const nextCost = currentCost + separatorCost + row.componentCost;
484
- if (currentChunk.length > 0 && nextCost > MAX_COMPONENTS) {
528
+ const nextText = currentText + row.textSize;
529
+ if (currentChunk.length > 0 &&
530
+ (nextCost > MAX_COMPONENTS || nextText > MAX_TEXT_SIZE)) {
485
531
  chunks.push(currentChunk);
486
532
  currentChunk = [row];
487
533
  currentCost = 1 + row.componentCost;
534
+ currentText = row.textSize;
488
535
  continue;
489
536
  }
490
537
  currentChunk.push(row);
491
538
  currentCost = nextCost;
539
+ currentText = nextText;
492
540
  }
493
541
  if (currentChunk.length > 0) {
494
542
  chunks.push(currentChunk);