kimaki 0.11.0 → 0.13.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.
- package/dist/agent-model.e2e.test.js +91 -1
- package/dist/btw-prefix-detection.js +13 -15
- package/dist/btw-prefix-detection.test.js +60 -30
- package/dist/cli-runner.js +36 -12
- package/dist/cli.js +10 -0
- package/dist/commands/abort.js +1 -1
- package/dist/commands/agent.js +14 -16
- package/dist/commands/mention-mode.js +0 -1
- package/dist/commands/model-variant.js +2 -2
- package/dist/commands/model.js +47 -27
- package/dist/commands/restart-opencode-server.js +1 -1
- package/dist/commands/undo-redo.js +2 -2
- package/dist/commands/unset-model.js +2 -2
- package/dist/commands/upgrade.js +1 -2
- package/dist/commands/verbosity.js +0 -1
- package/dist/commands/worktree-settings.js +0 -1
- package/dist/discord-bot.js +65 -15
- package/dist/discord-command-registration.js +1 -1
- package/dist/discord-utils.js +14 -0
- package/dist/discord-utils.test.js +51 -1
- package/dist/external-opencode-sync.js +119 -54
- package/dist/interaction-handler.js +4 -0
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +1 -1
- package/dist/message-formatting.js +91 -0
- package/dist/message-formatting.test.js +206 -1
- package/dist/message-preprocessing.js +1 -1
- package/dist/opencode-interrupt-plugin.js +14 -2
- package/dist/opencode-interrupt-plugin.test.js +22 -3
- package/dist/opencode.js +34 -158
- package/dist/queue-advanced-model-switch.e2e.test.js +1 -1
- package/dist/session-handler/agent-utils.js +9 -9
- package/dist/session-handler/thread-runtime-state.js +29 -0
- package/dist/session-handler/thread-session-runtime.js +51 -9
- package/dist/store.js +2 -0
- package/dist/system-message.test.js +16 -0
- package/dist/thread-message-queue.e2e.test.js +198 -1
- package/dist/voice-handler.js +91 -68
- package/package.json +6 -6
- package/skills/holocron/SKILL.md +432 -0
- package/skills/npm-package/SKILL.md +12 -2
- package/skills/termcast/SKILL.md +32 -846
- package/skills/tuistory/SKILL.md +71 -0
- package/src/agent-model.e2e.test.ts +117 -0
- package/src/btw-prefix-detection.test.ts +61 -30
- package/src/btw-prefix-detection.ts +15 -19
- package/src/cli-runner.ts +36 -12
- package/src/cli.ts +22 -0
- package/src/commands/abort.ts +1 -1
- package/src/commands/agent.ts +14 -17
- package/src/commands/mention-mode.ts +0 -1
- package/src/commands/model-variant.ts +2 -2
- package/src/commands/model.ts +63 -37
- package/src/commands/restart-opencode-server.ts +1 -1
- package/src/commands/undo-redo.ts +2 -2
- package/src/commands/unset-model.ts +1 -2
- package/src/commands/upgrade.ts +1 -2
- package/src/commands/verbosity.ts +0 -1
- package/src/commands/worktree-settings.ts +0 -1
- package/src/discord-bot.ts +76 -13
- package/src/discord-command-registration.ts +1 -1
- package/src/discord-utils.test.ts +63 -2
- package/src/discord-utils.ts +19 -0
- package/src/external-opencode-sync.ts +147 -64
- package/src/interaction-handler.ts +5 -0
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +1 -1
- package/src/message-formatting.test.ts +247 -1
- package/src/message-formatting.ts +93 -1
- package/src/message-preprocessing.ts +1 -1
- package/src/opencode-interrupt-plugin.test.ts +27 -3
- package/src/opencode-interrupt-plugin.ts +15 -3
- package/src/opencode.ts +36 -152
- package/src/queue-advanced-model-switch.e2e.test.ts +1 -1
- package/src/session-handler/agent-utils.ts +11 -11
- package/src/session-handler/thread-runtime-state.ts +35 -0
- package/src/session-handler/thread-session-runtime.ts +67 -8
- package/src/store.ts +17 -0
- package/src/system-message.test.ts +16 -0
- package/src/thread-message-queue.e2e.test.ts +227 -1
- package/src/voice-handler.ts +106 -78
package/dist/commands/model.js
CHANGED
|
@@ -11,6 +11,20 @@ import { createLogger, LogPrefix } from '../logger.js';
|
|
|
11
11
|
import * as errore from 'errore';
|
|
12
12
|
import { buildPaginatedOptions, parsePaginationValue } from './paginated-select.js';
|
|
13
13
|
const modelLogger = createLogger(LogPrefix.MODEL);
|
|
14
|
+
function buildSafeSelectOption({ label, value, description, }) {
|
|
15
|
+
const trimmedLabel = label?.trim();
|
|
16
|
+
const trimmedValue = value?.trim();
|
|
17
|
+
const safeLabel = (trimmedLabel || trimmedValue || 'Unknown').slice(0, 100);
|
|
18
|
+
const safeValue = trimmedValue || trimmedLabel || '';
|
|
19
|
+
if (!safeLabel || !safeValue) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
label: safeLabel,
|
|
24
|
+
value: safeValue,
|
|
25
|
+
description: description?.slice(0, 100),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
14
28
|
// Store context by hash to avoid customId length limits (Discord max: 100 chars).
|
|
15
29
|
// Entries are TTL'd to prevent unbounded growth when users open /model and never
|
|
16
30
|
// interact with the select menu.
|
|
@@ -172,7 +186,7 @@ export async function getCurrentModelInfo({ sessionId, channelId, appId, agentPr
|
|
|
172
186
|
export async function handleModelCommand({ interaction, appId, }) {
|
|
173
187
|
modelLogger.log('[MODEL] handleModelCommand called');
|
|
174
188
|
// Defer reply immediately to avoid 3-second timeout
|
|
175
|
-
await interaction.deferReply(
|
|
189
|
+
await interaction.deferReply();
|
|
176
190
|
modelLogger.log('[MODEL] Deferred reply');
|
|
177
191
|
const channel = interaction.channel;
|
|
178
192
|
if (!channel) {
|
|
@@ -307,15 +321,16 @@ export async function handleModelCommand({ interaction, appId, }) {
|
|
|
307
321
|
const contextHash = crypto.randomBytes(8).toString('hex');
|
|
308
322
|
setModelContext(contextHash, context);
|
|
309
323
|
const allProviderOptions = [...availableProviders]
|
|
310
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
324
|
+
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''))
|
|
311
325
|
.map((provider) => {
|
|
312
326
|
const modelCount = Object.keys(provider.models || {}).length;
|
|
313
|
-
return {
|
|
314
|
-
label: provider.name
|
|
327
|
+
return buildSafeSelectOption({
|
|
328
|
+
label: provider.name,
|
|
315
329
|
value: provider.id,
|
|
316
|
-
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available
|
|
317
|
-
};
|
|
318
|
-
})
|
|
330
|
+
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`,
|
|
331
|
+
});
|
|
332
|
+
})
|
|
333
|
+
.filter((option) => !!option);
|
|
319
334
|
const { options } = buildPaginatedOptions({
|
|
320
335
|
allOptions: allProviderOptions,
|
|
321
336
|
page: 0,
|
|
@@ -383,15 +398,16 @@ export async function handleProviderSelectMenu(interaction) {
|
|
|
383
398
|
const { all: allProviders, connected } = providersResponse.data;
|
|
384
399
|
const availableProviders = allProviders.filter((p) => connected.includes(p.id));
|
|
385
400
|
const allProviderOptions = [...availableProviders]
|
|
386
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
401
|
+
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''))
|
|
387
402
|
.map((p) => {
|
|
388
403
|
const modelCount = Object.keys(p.models || {}).length;
|
|
389
|
-
return {
|
|
390
|
-
label: p.name
|
|
404
|
+
return buildSafeSelectOption({
|
|
405
|
+
label: p.name,
|
|
391
406
|
value: p.id,
|
|
392
|
-
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available
|
|
393
|
-
};
|
|
394
|
-
})
|
|
407
|
+
description: `${modelCount} model${modelCount !== 1 ? 's' : ''} available`,
|
|
408
|
+
});
|
|
409
|
+
})
|
|
410
|
+
.filter((option) => !!option);
|
|
395
411
|
const { options } = buildPaginatedOptions({ allOptions: allProviderOptions, page: providerNavPage });
|
|
396
412
|
const selectMenu = new StringSelectMenuBuilder()
|
|
397
413
|
.setCustomId(`model_provider:${contextHash}`)
|
|
@@ -434,10 +450,11 @@ export async function handleProviderSelectMenu(interaction) {
|
|
|
434
450
|
const models = Object.entries(provider.models || {})
|
|
435
451
|
.map(([modelId, model]) => ({
|
|
436
452
|
id: modelId,
|
|
437
|
-
name: model.name,
|
|
453
|
+
name: model.name || modelId,
|
|
438
454
|
releaseDate: model.release_date,
|
|
439
455
|
}))
|
|
440
|
-
.
|
|
456
|
+
.filter((model) => model.id && model.name)
|
|
457
|
+
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || ''));
|
|
441
458
|
if (models.length === 0) {
|
|
442
459
|
await interaction.editReply({
|
|
443
460
|
content: `No models available for ${provider.name}`,
|
|
@@ -447,19 +464,21 @@ export async function handleProviderSelectMenu(interaction) {
|
|
|
447
464
|
}
|
|
448
465
|
// Update context with provider info and reuse the same hash
|
|
449
466
|
context.providerId = selectedProviderId;
|
|
450
|
-
context.providerName = provider.name;
|
|
467
|
+
context.providerName = provider.name?.trim() || provider.id;
|
|
451
468
|
context.modelPage = 0;
|
|
452
469
|
setModelContext(contextHash, context);
|
|
453
|
-
const allModelOptions = models
|
|
470
|
+
const allModelOptions = models
|
|
471
|
+
.map((model) => {
|
|
454
472
|
const dateStr = model.releaseDate
|
|
455
473
|
? new Date(model.releaseDate).toLocaleDateString()
|
|
456
474
|
: 'Unknown date';
|
|
457
|
-
return {
|
|
458
|
-
label: model.name
|
|
475
|
+
return buildSafeSelectOption({
|
|
476
|
+
label: model.name,
|
|
459
477
|
value: model.id,
|
|
460
|
-
description: dateStr
|
|
461
|
-
};
|
|
462
|
-
})
|
|
478
|
+
description: dateStr,
|
|
479
|
+
});
|
|
480
|
+
})
|
|
481
|
+
.filter((option) => !!option);
|
|
463
482
|
const { options } = buildPaginatedOptions({
|
|
464
483
|
allOptions: allModelOptions,
|
|
465
484
|
page: 0,
|
|
@@ -527,13 +546,14 @@ export async function handleModelSelectMenu(interaction) {
|
|
|
527
546
|
return;
|
|
528
547
|
}
|
|
529
548
|
const allModelOptions = Object.entries(provider.models || {})
|
|
530
|
-
.map(([modelId, model]) => ({
|
|
531
|
-
label: model.name
|
|
549
|
+
.map(([modelId, model]) => buildSafeSelectOption({
|
|
550
|
+
label: model.name || modelId,
|
|
532
551
|
value: modelId,
|
|
533
|
-
description:
|
|
552
|
+
description: model.release_date
|
|
534
553
|
? new Date(model.release_date).toLocaleDateString()
|
|
535
|
-
: 'Unknown date'
|
|
554
|
+
: 'Unknown date',
|
|
536
555
|
}))
|
|
556
|
+
.filter((option) => !!option)
|
|
537
557
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
538
558
|
const { options } = buildPaginatedOptions({ allOptions: allModelOptions, page: modelNavPage });
|
|
539
559
|
const selectMenu = new StringSelectMenuBuilder()
|
|
@@ -708,7 +728,7 @@ export async function handleModelScopeSelectMenu(interaction) {
|
|
|
708
728
|
const modelDisplay = modelId.split('/')[1] || modelId;
|
|
709
729
|
const variant = context.selectedVariant ?? null;
|
|
710
730
|
const variantSuffix = variant ? ` (${variant})` : '';
|
|
711
|
-
const agentTip = '\n_Tip: create [agent .md files](https://
|
|
731
|
+
const agentTip = '\n_Tip: create [agent .md files](https://kimaki.dev/docs/model-switching) in .opencode/agent/ for one-command model switching_';
|
|
712
732
|
try {
|
|
713
733
|
if (selectedScope === 'session') {
|
|
714
734
|
if (!context.sessionId) {
|
|
@@ -46,7 +46,7 @@ export async function handleRestartOpencodeServerCommand({ command, appId, }) {
|
|
|
46
46
|
}
|
|
47
47
|
const { projectDirectory } = resolved;
|
|
48
48
|
// Defer reply since restart may take a moment
|
|
49
|
-
await command.deferReply(
|
|
49
|
+
await command.deferReply();
|
|
50
50
|
// Dispose all runtimes for this directory/channel scope.
|
|
51
51
|
// disposeRuntimesForDirectory aborts active runs, kills listeners, and
|
|
52
52
|
// removes runtimes from the registry. Scoped by channelId so runtimes
|
|
@@ -58,7 +58,7 @@ export async function handleUndoCommand({ command, }) {
|
|
|
58
58
|
});
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
|
-
await command.deferReply(
|
|
61
|
+
await command.deferReply();
|
|
62
62
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
63
63
|
if (getClient instanceof Error) {
|
|
64
64
|
await command.editReply(`Failed to undo: ${getClient.message}`);
|
|
@@ -209,7 +209,7 @@ export async function handleRedoCommand({ command, }) {
|
|
|
209
209
|
});
|
|
210
210
|
return;
|
|
211
211
|
}
|
|
212
|
-
await command.deferReply(
|
|
212
|
+
await command.deferReply();
|
|
213
213
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
214
214
|
if (getClient instanceof Error) {
|
|
215
215
|
await command.editReply(`Failed to redo: ${getClient.message}`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// /unset-model-override command - Remove model overrides and use default instead.
|
|
2
|
-
import { ChatInputCommandInteraction, ChannelType,
|
|
2
|
+
import { ChatInputCommandInteraction, ChannelType, } from 'discord.js';
|
|
3
3
|
import { getChannelModel, getSessionModel, getThreadSession, clearSessionModel, } from '../database.js';
|
|
4
4
|
import { getDb } from '../db.js';
|
|
5
5
|
import * as orm from 'drizzle-orm';
|
|
@@ -35,7 +35,7 @@ function formatModelSource(type, agentName) {
|
|
|
35
35
|
*/
|
|
36
36
|
export async function handleUnsetModelCommand({ interaction, appId, }) {
|
|
37
37
|
unsetModelLogger.log('[UNSET-MODEL] handleUnsetModelCommand called');
|
|
38
|
-
await interaction.deferReply(
|
|
38
|
+
await interaction.deferReply();
|
|
39
39
|
const channel = interaction.channel;
|
|
40
40
|
if (!channel) {
|
|
41
41
|
await interaction.editReply({
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// /upgrade-and-restart command - Upgrade kimaki to the latest version and restart the bot.
|
|
2
2
|
// Checks npm for a newer version, installs it globally, then spawns a new kimaki process.
|
|
3
3
|
// The new process kills the old one on startup (kimaki's single-instance lock).
|
|
4
|
-
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js';
|
|
5
4
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
6
5
|
import { getCurrentVersion, upgrade } from '../upgrade.js';
|
|
7
6
|
import { spawn } from 'node:child_process';
|
|
8
7
|
const logger = createLogger(LogPrefix.CLI);
|
|
9
8
|
export async function handleUpgradeAndRestartCommand({ command, }) {
|
|
10
|
-
await command.deferReply(
|
|
9
|
+
await command.deferReply();
|
|
11
10
|
logger.log('[UPGRADE] /upgrade-and-restart triggered');
|
|
12
11
|
try {
|
|
13
12
|
const currentVersion = getCurrentVersion();
|
|
@@ -37,6 +37,5 @@ export async function handleToggleWorktreesCommand({ command, }) {
|
|
|
37
37
|
content: nextEnabled
|
|
38
38
|
? `Worktrees **enabled** for this channel.\n\nNew sessions started from messages in **#${channel.name}** will now automatically create git worktrees.\n\nNew setting for **#${channel.name}**: **enabled**.`
|
|
39
39
|
: `Worktrees **disabled** for this channel.\n\nNew sessions started from messages in **#${channel.name}** will use the main project directory.\n\nNew setting for **#${channel.name}**: **disabled**.`,
|
|
40
|
-
flags: MessageFlags.Ephemeral,
|
|
41
40
|
});
|
|
42
41
|
}
|
package/dist/discord-bot.js
CHANGED
|
@@ -6,14 +6,14 @@ import { stopOpencodeServer, } from './opencode.js';
|
|
|
6
6
|
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
|
|
7
7
|
import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
|
|
8
8
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
9
|
-
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, } from './discord-utils.js';
|
|
9
|
+
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
|
|
10
10
|
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
11
11
|
import YAML from 'yaml';
|
|
12
12
|
import { getTextAttachments, resolveMentions, } from './message-formatting.js';
|
|
13
|
-
import {
|
|
13
|
+
import { extractBtwSuffix } from './btw-prefix-detection.js';
|
|
14
14
|
import { isVoiceAttachment } from './voice-attachment.js';
|
|
15
15
|
import { forkSessionToBtwThread } from './commands/btw.js';
|
|
16
|
-
import { getChannelReferencePermissionRules, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
|
|
16
|
+
import { extractQueueSuffix, getChannelReferencePermissionRules, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
|
|
17
17
|
import { cancelPendingActionButtons } from './commands/action-buttons.js';
|
|
18
18
|
import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
|
|
19
19
|
import { cancelPendingFileUpload } from './commands/file-upload.js';
|
|
@@ -30,6 +30,7 @@ import { markDiscordGatewayReady, stopHranaServer } from './hrana-server.js';
|
|
|
30
30
|
import { notifyError } from './sentry.js';
|
|
31
31
|
import { flushDebouncedProcessCallbacks } from './debounced-process-flush.js';
|
|
32
32
|
import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js';
|
|
33
|
+
import { store } from './store.js';
|
|
33
34
|
import { startExternalOpencodeSessionSync, stopExternalOpencodeSessionSync, } from './external-opencode-sync.js';
|
|
34
35
|
export { initDatabase, closeDatabase, getChannelDirectory, } from './database.js';
|
|
35
36
|
export { initializeOpencodeForDirectory } from './opencode.js';
|
|
@@ -144,6 +145,7 @@ export async function createDiscordClient() {
|
|
|
144
145
|
// Read REST API URL lazily so gateway mode can set store.discordBaseUrl
|
|
145
146
|
// after module import but before client creation.
|
|
146
147
|
const restApiUrl = getDiscordRestApiUrl();
|
|
148
|
+
const { allowedMentions } = store.getState();
|
|
147
149
|
return new Client({
|
|
148
150
|
intents: [
|
|
149
151
|
GatewayIntentBits.Guilds,
|
|
@@ -158,6 +160,7 @@ export async function createDiscordClient() {
|
|
|
158
160
|
Partials.ThreadMember,
|
|
159
161
|
],
|
|
160
162
|
rest: { api: restApiUrl },
|
|
163
|
+
allowedMentions: { parse: allowedMentions },
|
|
161
164
|
});
|
|
162
165
|
}
|
|
163
166
|
export async function startDiscordBot({ token, appId, discordClient, useWorktrees, }) {
|
|
@@ -304,7 +307,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
304
307
|
// still need Kimaki permission so multi-agent orchestration stays opt-in.
|
|
305
308
|
const isInjectedSelfBotMessage = isCliInjectedPrompt && message.author?.id === discordClient.user?.id;
|
|
306
309
|
if (message.author?.bot && !isInjectedSelfBotMessage) {
|
|
307
|
-
|
|
310
|
+
const member = await resolveGuildMessageMember(message);
|
|
311
|
+
if (!hasKimakiBotPermission(member, message.guild)) {
|
|
308
312
|
return;
|
|
309
313
|
}
|
|
310
314
|
}
|
|
@@ -343,15 +347,19 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
343
347
|
}
|
|
344
348
|
}
|
|
345
349
|
}
|
|
346
|
-
if (!isCliInjectedPrompt && message.guild
|
|
347
|
-
|
|
350
|
+
if (!isCliInjectedPrompt && message.guild) {
|
|
351
|
+
const member = await resolveGuildMessageMember(message);
|
|
352
|
+
if (!member) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (hasNoKimakiRole(member)) {
|
|
348
356
|
await message.reply({
|
|
349
357
|
content: `You have the **no-kimaki** role which blocks bot access.\nRemove this role to use Kimaki.`,
|
|
350
358
|
flags: SILENT_MESSAGE_FLAGS,
|
|
351
359
|
});
|
|
352
360
|
return;
|
|
353
361
|
}
|
|
354
|
-
if (!hasKimakiBotPermission(message.
|
|
362
|
+
if (!hasKimakiBotPermission(member, message.guild)) {
|
|
355
363
|
await message.reply({
|
|
356
364
|
content: `You don't have permission to start sessions.\nTo use Kimaki, ask a server admin to give you the **Kimaki** role.`,
|
|
357
365
|
flags: SILENT_MESSAGE_FLAGS,
|
|
@@ -450,18 +458,17 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
450
458
|
return;
|
|
451
459
|
}
|
|
452
460
|
}
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
? extractBtwPrefix(message.content || '')
|
|
461
|
+
// `. btw` suffix mirrors /btw for fast side-question forks.
|
|
462
|
+
// Works like queue: just the word "btw" at the end after punctuation
|
|
463
|
+
// or newline. The whole message (minus the suffix) becomes the fork prompt.
|
|
464
|
+
const btwResult = projectDirectory && worktreeInfo?.status !== 'pending'
|
|
465
|
+
? extractBtwSuffix(message.content || '')
|
|
459
466
|
: null;
|
|
460
|
-
if (
|
|
467
|
+
if (btwResult?.forceBtw && projectDirectory) {
|
|
461
468
|
const result = await forkSessionToBtwThread({
|
|
462
469
|
sourceThread: thread,
|
|
463
470
|
projectDirectory,
|
|
464
|
-
prompt:
|
|
471
|
+
prompt: btwResult.prompt,
|
|
465
472
|
userId: message.author.id,
|
|
466
473
|
username: message.member?.displayName || message.author.displayName,
|
|
467
474
|
appId: currentAppId,
|
|
@@ -738,6 +745,49 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
738
745
|
}
|
|
739
746
|
}
|
|
740
747
|
});
|
|
748
|
+
// Handle user message edits to update queued messages.
|
|
749
|
+
// When a user edits a message that is still waiting in kimaki's local queue,
|
|
750
|
+
// the queue item is updated with the new content. If the edit removes the
|
|
751
|
+
// queue suffix, the item is removed from the queue.
|
|
752
|
+
discordClient.on(Events.MessageUpdate, async (_oldMessage, newMessage) => {
|
|
753
|
+
try {
|
|
754
|
+
// Fetch full message if partial (cache miss). Needed for mentions
|
|
755
|
+
// and content to be fully resolved.
|
|
756
|
+
const message = newMessage.partial
|
|
757
|
+
? await newMessage.fetch().catch(() => null)
|
|
758
|
+
: newMessage;
|
|
759
|
+
if (!message)
|
|
760
|
+
return;
|
|
761
|
+
if (message.author.bot)
|
|
762
|
+
return;
|
|
763
|
+
if (!message.content)
|
|
764
|
+
return;
|
|
765
|
+
const channel = message.channel;
|
|
766
|
+
const isThread = [
|
|
767
|
+
ChannelType.PublicThread,
|
|
768
|
+
ChannelType.PrivateThread,
|
|
769
|
+
ChannelType.AnnouncementThread,
|
|
770
|
+
].includes(channel.type);
|
|
771
|
+
if (!isThread)
|
|
772
|
+
return;
|
|
773
|
+
const runtime = getRuntime(channel.id);
|
|
774
|
+
if (!runtime)
|
|
775
|
+
return;
|
|
776
|
+
// Use resolveMentions to match initial preprocessing and preserve
|
|
777
|
+
// newlines (stripMentions collapses them, breaking final-line queue
|
|
778
|
+
// suffix detection).
|
|
779
|
+
const { prompt, forceQueue } = extractQueueSuffix(resolveMentions(message));
|
|
780
|
+
// If the edit removed the queue suffix, remove the item from the queue.
|
|
781
|
+
// If the suffix is still present, update the prompt.
|
|
782
|
+
const result = runtime.updateQueuedMessage(message.id, forceQueue ? prompt : '');
|
|
783
|
+
if (result.found) {
|
|
784
|
+
discordLogger.log(`[MESSAGE_EDIT] ${result.removed ? 'Removed' : 'Updated'} queued message ${message.id} in thread ${channel.id}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
discordLogger.error('Error handling message update:', error instanceof Error ? error.stack : String(error));
|
|
789
|
+
}
|
|
790
|
+
});
|
|
741
791
|
// Handle bot-initiated threads created by `kimaki send` (without --notify-only)
|
|
742
792
|
// Uses JSON embed marker to pass options (start, worktree name)
|
|
743
793
|
discordClient.on(Events.ThreadCreate, async (thread, newlyCreated) => {
|
|
@@ -395,7 +395,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
395
395
|
.setDMPermission(false)
|
|
396
396
|
.addStringOption((opt) => opt
|
|
397
397
|
.setName('prompt')
|
|
398
|
-
.setDescription('Send a
|
|
398
|
+
.setDescription('Send a prompt with this agent')
|
|
399
399
|
.setRequired(false))
|
|
400
400
|
.toJSON());
|
|
401
401
|
}
|
package/dist/discord-utils.js
CHANGED
|
@@ -72,6 +72,20 @@ export function hasKimakiAdminPermission(member, guild) {
|
|
|
72
72
|
const hasKimakiRole = hasRoleByName(member, 'kimaki', guild);
|
|
73
73
|
return isOwner || isAdmin || canManageServer || hasKimakiRole;
|
|
74
74
|
}
|
|
75
|
+
export async function resolveGuildMessageMember(message) {
|
|
76
|
+
if (!message.guild)
|
|
77
|
+
return null;
|
|
78
|
+
if (message.member)
|
|
79
|
+
return message.member;
|
|
80
|
+
const fetchedMember = await message.guild.members
|
|
81
|
+
.fetch(message.author.id)
|
|
82
|
+
.catch((e) => new Error('Failed to fetch guild member', { cause: e }));
|
|
83
|
+
if (fetchedMember instanceof Error) {
|
|
84
|
+
discordLogger.warn(`[PERMISSION] Denying message ${message.id}: ${fetchedMember.message}`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return fetchedMember;
|
|
88
|
+
}
|
|
75
89
|
function hasRoleByName(member, roleName, guild) {
|
|
76
90
|
const target = roleName.toLowerCase();
|
|
77
91
|
if (member instanceof GuildMember) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PermissionsBitField } from 'discord.js';
|
|
2
2
|
import { afterEach, describe, expect, test } from 'vitest';
|
|
3
|
-
import { hasKimakiAdminPermission, hasKimakiBotPermission, splitMarkdownForDiscord } from './discord-utils.js';
|
|
3
|
+
import { hasKimakiAdminPermission, hasKimakiBotPermission, resolveGuildMessageMember, splitMarkdownForDiscord, } from './discord-utils.js';
|
|
4
4
|
import { store } from './store.js';
|
|
5
5
|
describe('splitMarkdownForDiscord', () => {
|
|
6
6
|
test('never returns chunks over the max length with code fences', () => {
|
|
@@ -198,3 +198,53 @@ describe('hasKimakiAdminPermission', () => {
|
|
|
198
198
|
expect(hasKimakiAdminPermission(member, guild)).toBe(true);
|
|
199
199
|
});
|
|
200
200
|
});
|
|
201
|
+
describe('resolveGuildMessageMember', () => {
|
|
202
|
+
test('uses hydrated message member without fetching', async () => {
|
|
203
|
+
const member = { id: 'member-id' };
|
|
204
|
+
const message = {
|
|
205
|
+
guild: {
|
|
206
|
+
members: {
|
|
207
|
+
fetch() {
|
|
208
|
+
throw new Error('should not fetch');
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
member,
|
|
213
|
+
author: { id: 'member-id' },
|
|
214
|
+
id: 'message-id',
|
|
215
|
+
};
|
|
216
|
+
await expect(resolveGuildMessageMember(message)).resolves.toBe(member);
|
|
217
|
+
});
|
|
218
|
+
test('fetches missing guild message member', async () => {
|
|
219
|
+
const member = { id: 'member-id' };
|
|
220
|
+
const message = {
|
|
221
|
+
guild: {
|
|
222
|
+
members: {
|
|
223
|
+
fetch(id) {
|
|
224
|
+
expect(id).toBe('member-id');
|
|
225
|
+
return Promise.resolve(member);
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
member: null,
|
|
230
|
+
author: { id: 'member-id' },
|
|
231
|
+
id: 'message-id',
|
|
232
|
+
};
|
|
233
|
+
await expect(resolveGuildMessageMember(message)).resolves.toBe(member);
|
|
234
|
+
});
|
|
235
|
+
test('denies when missing guild message member cannot be fetched', async () => {
|
|
236
|
+
const message = {
|
|
237
|
+
guild: {
|
|
238
|
+
members: {
|
|
239
|
+
fetch() {
|
|
240
|
+
return Promise.reject(new Error('missing member'));
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
member: null,
|
|
245
|
+
author: { id: 'member-id' },
|
|
246
|
+
id: 'message-id',
|
|
247
|
+
};
|
|
248
|
+
await expect(resolveGuildMessageMember(message)).resolves.toBe(null);
|
|
249
|
+
});
|
|
250
|
+
});
|