kimaki 0.30.0 → 0.31.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 +27 -19
- package/dist/analytics.js +3 -1
- package/dist/btw-prefix-detection.js +9 -0
- package/dist/btw-prefix-detection.test.js +38 -1
- package/dist/cli-commands/send.js +5 -11
- package/dist/cli-runner.js +22 -10
- package/dist/cli-runner.test.js +34 -1
- package/dist/cli-send-thread.e2e.test.js +1 -1
- package/dist/commands/abort.js +16 -4
- package/dist/commands/btw.js +147 -39
- package/dist/commands/context-usage.js +47 -0
- package/dist/commands/context-usage.test.js +49 -0
- package/dist/commands/fork.js +32 -1
- package/dist/commands/model.js +69 -14
- package/dist/commands/new-worktree.js +5 -2
- package/dist/commands/queue.js +37 -30
- package/dist/commands/session.js +7 -2
- package/dist/db.test.js +17 -7
- package/dist/discord-bot.js +70 -47
- package/dist/discord-utils.js +15 -1
- package/dist/errors.js +10 -5
- package/dist/external-directory-permissions.e2e.test.js +4 -4
- package/dist/external-directory-project-deny.e2e.test.js +2 -2
- package/dist/gateway-proxy.e2e.test.js +5 -5
- package/dist/message-formatting.js +50 -15
- package/dist/message-formatting.test.js +12 -11
- package/dist/message-preprocessing.js +53 -75
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
- package/dist/queue-advanced-abort.e2e.test.js +62 -6
- package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
- package/dist/queue-advanced-footer.e2e.test.js +8 -8
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
- package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
- package/dist/queue-delete-message.e2e.test.js +146 -5
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
- package/dist/queue-interrupt-drain.e2e.test.js +24 -14
- package/dist/queue-question-select-drain.e2e.test.js +27 -27
- package/dist/runtime-lifecycle.e2e.test.js +6 -6
- package/dist/session-handler/agent-utils.js +16 -17
- package/dist/session-handler/event-stream-state.js +24 -16
- package/dist/session-handler/event-stream-state.test.js +281 -14
- package/dist/session-handler/thread-session-runtime.js +209 -128
- package/dist/sleep.e2e.test.js +6 -6
- package/dist/system-message.js +214 -392
- package/dist/system-message.test.js +204 -380
- package/dist/task-runner.js +3 -3
- package/dist/task-schedule.js +2 -2
- package/dist/test-utils.js +32 -6
- package/dist/thread-message-queue.e2e.test.js +132 -60
- package/dist/undo-redo.e2e.test.js +3 -3
- package/dist/voice-handler.js +2 -1
- package/dist/voice-message.e2e.test.js +22 -31
- package/dist/voice-routing-input.e2e.test.js +2 -2
- package/dist/voice-routing-question.e2e.test.js +59 -2
- package/dist/voice.js +306 -206
- package/dist/voice.test.js +161 -13
- package/dist/worktree-lifecycle.e2e.test.js +7 -7
- package/package.json +6 -9
- package/skills/critique/SKILL.md +4 -0
- package/skills/holocron/SKILL.md +0 -28
- package/skills/opensrc/SKILL.md +20 -13
- package/skills/playwriter/SKILL.md +1 -1
- package/src/agent-model.e2e.test.ts +28 -19
- package/src/analytics.ts +3 -1
- package/src/btw-prefix-detection.test.ts +39 -1
- package/src/btw-prefix-detection.ts +11 -0
- package/src/cli-commands/send.ts +11 -11
- package/src/cli-runner.test.ts +37 -0
- package/src/cli-runner.ts +26 -10
- package/src/cli-send-thread.e2e.test.ts +1 -1
- package/src/commands/abort.ts +20 -4
- package/src/commands/btw.ts +151 -41
- package/src/commands/context-usage.test.ts +57 -0
- package/src/commands/context-usage.ts +59 -0
- package/src/commands/fork.ts +34 -1
- package/src/commands/model.ts +81 -14
- package/src/commands/new-worktree.ts +5 -2
- package/src/commands/queue.ts +46 -30
- package/src/commands/session.ts +10 -2
- package/src/db.test.ts +18 -6
- package/src/discord-bot.ts +72 -56
- package/src/discord-utils.ts +17 -1
- package/src/errors.ts +12 -6
- package/src/external-directory-permissions.e2e.test.ts +4 -4
- package/src/external-directory-project-deny.e2e.test.ts +2 -2
- package/src/gateway-proxy.e2e.test.ts +5 -5
- package/src/message-formatting.test.ts +12 -11
- package/src/message-formatting.ts +52 -16
- package/src/message-preprocessing.ts +69 -91
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
- package/src/queue-advanced-abort.e2e.test.ts +73 -6
- package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
- package/src/queue-advanced-footer.e2e.test.ts +8 -8
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
- package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
- package/src/queue-delete-message.e2e.test.ts +164 -4
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
- package/src/queue-interrupt-drain.e2e.test.ts +24 -13
- package/src/queue-question-select-drain.e2e.test.ts +31 -28
- package/src/runtime-lifecycle.e2e.test.ts +6 -6
- package/src/session-handler/agent-utils.ts +25 -20
- package/src/session-handler/event-stream-state.test.ts +284 -13
- package/src/session-handler/event-stream-state.ts +37 -21
- package/src/session-handler/thread-runtime-state.ts +7 -0
- package/src/session-handler/thread-session-runtime.ts +255 -155
- package/src/sleep.e2e.test.ts +6 -6
- package/src/system-message.test.ts +216 -379
- package/src/system-message.ts +253 -389
- package/src/task-runner.ts +3 -3
- package/src/task-schedule.ts +2 -2
- package/src/test-utils.ts +55 -12
- package/src/thread-message-queue.e2e.test.ts +136 -63
- package/src/undo-redo.e2e.test.ts +3 -3
- package/src/voice-handler.ts +2 -1
- package/src/voice-message.e2e.test.ts +22 -33
- package/src/voice-routing-input.e2e.test.ts +2 -2
- package/src/voice-routing-question.e2e.test.ts +59 -2
- package/src/voice.test.ts +164 -14
- package/src/voice.ts +400 -279
- package/src/worktree-lifecycle.e2e.test.ts +7 -7
package/dist/commands/queue.js
CHANGED
|
@@ -5,7 +5,8 @@ import { deleteThreadQueueItem, getThreadSession } from '../database.js';
|
|
|
5
5
|
import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
6
6
|
import { getOrCreateRuntime, getRuntime, } from '../session-handler/thread-session-runtime.js';
|
|
7
7
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
8
|
-
import {
|
|
8
|
+
import { asSubtext, QUEUE_PREFIX } from '../message-formatting.js';
|
|
9
|
+
import { extractBtwQueueSuffix } from '../btw-prefix-detection.js';
|
|
9
10
|
import { store } from '../store.js';
|
|
10
11
|
const logger = createLogger(LogPrefix.QUEUE);
|
|
11
12
|
export const QUEUE_REMOVE_CUSTOM_ID_PREFIX = 'queue_remove:';
|
|
@@ -52,6 +53,19 @@ function buildQueueRemoveRow({ threadId, queueId, }) {
|
|
|
52
53
|
.setLabel('Remove from queue')
|
|
53
54
|
.setStyle(ButtonStyle.Secondary));
|
|
54
55
|
}
|
|
56
|
+
// Replies "Queued message" and returns the ack id, so the drain indicator can
|
|
57
|
+
// reply to it. The runtime calls this inside the serialized enqueue.
|
|
58
|
+
function replyQueuedAck({ command, threadId, }) {
|
|
59
|
+
return async ({ queueId, position }) => {
|
|
60
|
+
await command.reply({
|
|
61
|
+
content: asSubtext(`Queued message (position ${position})`),
|
|
62
|
+
components: [buildQueueRemoveRow({ threadId, queueId })],
|
|
63
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
64
|
+
});
|
|
65
|
+
const ack = await command.fetchReply();
|
|
66
|
+
return ack.id;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
55
69
|
export async function handleQueueRemoveButton(interaction) {
|
|
56
70
|
const parsed = parseQueueRemoveCustomId(interaction.customId);
|
|
57
71
|
if (!parsed) {
|
|
@@ -75,7 +89,7 @@ export async function handleQueueRemoveButton(interaction) {
|
|
|
75
89
|
: await deleteThreadQueueItemWithoutRuntime(parsed.queueId);
|
|
76
90
|
if (!removed) {
|
|
77
91
|
await interaction.editReply({
|
|
78
|
-
content:
|
|
92
|
+
content: asSubtext('Queued message is no longer in the queue'),
|
|
79
93
|
components: [],
|
|
80
94
|
});
|
|
81
95
|
return;
|
|
@@ -84,14 +98,24 @@ export async function handleQueueRemoveButton(interaction) {
|
|
|
84
98
|
? `/${removed.command.name}`
|
|
85
99
|
: removed.prompt.slice(0, 120);
|
|
86
100
|
await interaction.editReply({
|
|
87
|
-
content:
|
|
101
|
+
content: asSubtext(`Removed queued message: ${label}`),
|
|
88
102
|
components: [],
|
|
89
103
|
});
|
|
90
104
|
logger.log(`[QUEUE] User ${interaction.user.displayName} removed queued item ${parsed.queueId} in thread ${parsed.threadId}`);
|
|
91
105
|
}
|
|
92
106
|
export async function handleQueueCommand({ command, appId, }) {
|
|
93
|
-
|
|
107
|
+
// /queue always queues; a typed suffix is redundant, so only strip it.
|
|
108
|
+
const rawMessage = command.options.getString('message', true);
|
|
109
|
+
const btw = extractBtwQueueSuffix(rawMessage);
|
|
110
|
+
const message = btw.prompt;
|
|
94
111
|
const channel = command.channel;
|
|
112
|
+
if (!message.trim()) {
|
|
113
|
+
await command.reply({
|
|
114
|
+
content: 'Write a message to queue',
|
|
115
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
95
119
|
if (!channel) {
|
|
96
120
|
await command.reply({
|
|
97
121
|
content: 'This command can only be used in a channel',
|
|
@@ -139,23 +163,15 @@ export async function handleQueueCommand({ command, appId, }) {
|
|
|
139
163
|
// /queue explicitly uses kimaki local queue mode.
|
|
140
164
|
const enqueueResult = await runtime.enqueueIncoming({
|
|
141
165
|
prompt: message,
|
|
166
|
+
queuedAction: btw.forceBtw ? 'btw' : undefined,
|
|
142
167
|
userId: command.user.id,
|
|
143
168
|
username: command.user.displayName,
|
|
144
169
|
appId,
|
|
145
170
|
mode: 'local-queue',
|
|
171
|
+
onLocalQueued: replyQueuedAck({ command, threadId: thread.id }),
|
|
146
172
|
});
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await command.reply({
|
|
150
|
-
content: responseText,
|
|
151
|
-
components: [
|
|
152
|
-
buildQueueRemoveRow({
|
|
153
|
-
threadId: thread.id,
|
|
154
|
-
queueId: enqueueResult.queueId,
|
|
155
|
-
}),
|
|
156
|
-
],
|
|
157
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
158
|
-
});
|
|
173
|
+
// onLocalQueued already posted the ack.
|
|
174
|
+
if (enqueueResult.queued) {
|
|
159
175
|
return;
|
|
160
176
|
}
|
|
161
177
|
const responseText = `${QUEUE_PREFIX}**${command.user.displayName}:** ${message.slice(0, 1000)}${message.length > 1000 ? '...' : ''}`;
|
|
@@ -205,7 +221,7 @@ export async function handleClearQueueCommand({ command, }) {
|
|
|
205
221
|
return;
|
|
206
222
|
}
|
|
207
223
|
await command.reply({
|
|
208
|
-
content:
|
|
224
|
+
content: asSubtext(`Cleared queued message at position ${position}`),
|
|
209
225
|
flags: SILENT_MESSAGE_FLAGS,
|
|
210
226
|
});
|
|
211
227
|
logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
|
|
@@ -223,7 +239,7 @@ export async function handleClearQueueCommand({ command, }) {
|
|
|
223
239
|
list = list.slice(0, 597) + '...';
|
|
224
240
|
}
|
|
225
241
|
await command.reply({
|
|
226
|
-
content:
|
|
242
|
+
content: asSubtext(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
|
|
227
243
|
flags: SILENT_MESSAGE_FLAGS,
|
|
228
244
|
});
|
|
229
245
|
logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
|
|
@@ -297,19 +313,10 @@ export async function handleQueueCommandCommand({ command, appId, }) {
|
|
|
297
313
|
appId,
|
|
298
314
|
command: commandPayload,
|
|
299
315
|
mode: 'local-queue',
|
|
316
|
+
onLocalQueued: replyQueuedAck({ command, threadId: thread.id }),
|
|
300
317
|
});
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
await command.reply({
|
|
304
|
-
content: responseText,
|
|
305
|
-
components: [
|
|
306
|
-
buildQueueRemoveRow({
|
|
307
|
-
threadId: thread.id,
|
|
308
|
-
queueId: enqueueResult.queueId,
|
|
309
|
-
}),
|
|
310
|
-
],
|
|
311
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
312
|
-
});
|
|
318
|
+
// onLocalQueued already posted the ack.
|
|
319
|
+
if (enqueueResult.queued) {
|
|
313
320
|
logger.log(`[QUEUE] User ${command.user.displayName} queued command /${commandName} in thread ${channel.id}`);
|
|
314
321
|
return;
|
|
315
322
|
}
|
package/dist/commands/session.js
CHANGED
|
@@ -12,6 +12,7 @@ import { sendThreadMessage, splitMarkdownForDiscord, SILENT_MESSAGE_FLAGS, resol
|
|
|
12
12
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
13
13
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
14
14
|
import { DiscordOperationError } from '../errors.js';
|
|
15
|
+
import { extractQueueSuffix } from '../message-formatting.js';
|
|
15
16
|
const logger = createLogger(LogPrefix.SESSION);
|
|
16
17
|
// Create only: callers confirm the destination before dispatching the full prompt.
|
|
17
18
|
export async function createNewSessionThread({ textChannel, projectDirectory, sourceWorkspace, prompt, files = [], userId, }) {
|
|
@@ -54,10 +55,14 @@ export async function createNewSessionThread({ textChannel, projectDirectory, so
|
|
|
54
55
|
}
|
|
55
56
|
export async function handleSessionCommand({ command, appId, }) {
|
|
56
57
|
await command.deferReply();
|
|
57
|
-
const prompt = command.options.getString('prompt', true);
|
|
58
|
+
const { prompt, forceQueue } = extractQueueSuffix(command.options.getString('prompt', true));
|
|
58
59
|
const filesString = command.options.getString('files') || '';
|
|
59
60
|
const agent = command.options.getString('agent') || undefined;
|
|
60
61
|
const channel = command.channel;
|
|
62
|
+
if (!prompt.trim()) {
|
|
63
|
+
await command.editReply('Write a prompt for the new session');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
61
66
|
const isThread = channel && [
|
|
62
67
|
ChannelType.PublicThread,
|
|
63
68
|
ChannelType.PrivateThread,
|
|
@@ -139,7 +144,7 @@ export async function handleSessionCommand({ command, appId, }) {
|
|
|
139
144
|
username: command.user.displayName,
|
|
140
145
|
agent,
|
|
141
146
|
appId,
|
|
142
|
-
mode: 'opencode',
|
|
147
|
+
mode: forceQueue ? 'local-queue' : 'opencode',
|
|
143
148
|
}).catch(async (error) => {
|
|
144
149
|
logger.error('New session dispatch failed:', error);
|
|
145
150
|
await sendThreadMessage(thread, 'Could not send the request to OpenCode. Send your request again in this thread.');
|
package/dist/db.test.js
CHANGED
|
@@ -7,11 +7,11 @@ import { afterAll, describe, expect, test } from 'vitest';
|
|
|
7
7
|
import { closeDb, getDb } from './db.js';
|
|
8
8
|
import * as orm from 'drizzle-orm';
|
|
9
9
|
import * as schema from './schema.js';
|
|
10
|
-
import { appendSessionEventsSinceLastTimestamp, cleanupDeletedThread, completeIpcRequest, createIpcRequest, createScheduledTask, createPendingWorkspace, deleteChannelDirectoryById, deleteThreadQueueItem, getChannelDirectory, getDueSessionSleeps, getIpcRequestById, getScheduledTask, getSessionEventSnapshot, getSessionModel, getSessionSleep, getThreadSession, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setChannelDirectory, setChannelVerbosity, setSessionModel, setThreadSession, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
|
|
10
|
+
import { appendSessionEventsSinceLastTimestamp, cleanupDeletedThread, completeIpcRequest, createIpcRequest, createScheduledTask, createPendingWorkspace, deleteChannelDirectoryById, deleteThreadQueueItem, getChannelDirectory, getDueSessionSleeps, getIpcRequestById, getScheduledTask, getSessionEventSnapshot, getSessionAgent, getSessionModel, getSessionSleep, getThreadSession, insertThreadQueueItem, listAllThreadQueueItems, listThreadQueueItems, setChannelDirectory, setChannelVerbosity, setSessionAgent, setSessionModel, setThreadSession, updateThreadQueueItemPayload, upsertSessionSleep, } from './database.js';
|
|
11
11
|
import { createClient } from '@libsql/client';
|
|
12
12
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
13
13
|
import { chooseLockPort } from './test-utils.js';
|
|
14
|
-
import {
|
|
14
|
+
import { copySessionPreferences } from './commands/model.js';
|
|
15
15
|
afterAll(async () => {
|
|
16
16
|
await closeDb();
|
|
17
17
|
});
|
|
@@ -566,7 +566,9 @@ describe('getDb', () => {
|
|
|
566
566
|
await db.delete(schema.thread_workspaces).where(orm.eq(schema.thread_workspaces.thread_id, threadId));
|
|
567
567
|
await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId));
|
|
568
568
|
});
|
|
569
|
-
|
|
569
|
+
// Regression: btw forks lost the agent and ran `build` instead of the source
|
|
570
|
+
// agent, which changed tools and busted the whole prompt cache.
|
|
571
|
+
test('copySessionPreferences snapshots source session agent and model to forked session', async () => {
|
|
570
572
|
const db = await getDb();
|
|
571
573
|
const sourceSessionId = `test-source-session-${crypto.randomUUID()}`;
|
|
572
574
|
const targetSessionId = `test-target-session-${crypto.randomUUID()}`;
|
|
@@ -578,18 +580,26 @@ describe('getDb', () => {
|
|
|
578
580
|
modelId: 'anthropic/claude-opus-4-6',
|
|
579
581
|
variant: 'thinking',
|
|
580
582
|
});
|
|
581
|
-
await
|
|
583
|
+
await setSessionAgent(sourceSessionId, 'opus');
|
|
584
|
+
await copySessionPreferences({
|
|
582
585
|
sourceSessionId,
|
|
583
586
|
targetSessionId,
|
|
584
587
|
getClient,
|
|
585
588
|
});
|
|
586
|
-
|
|
589
|
+
expect({
|
|
590
|
+
agent: await getSessionAgent(targetSessionId),
|
|
591
|
+
model: await getSessionModel(targetSessionId),
|
|
592
|
+
}).toMatchInlineSnapshot(`
|
|
587
593
|
{
|
|
588
|
-
"
|
|
589
|
-
"
|
|
594
|
+
"agent": "opus",
|
|
595
|
+
"model": {
|
|
596
|
+
"modelId": "anthropic/claude-opus-4-6",
|
|
597
|
+
"variant": "thinking",
|
|
598
|
+
},
|
|
590
599
|
}
|
|
591
600
|
`);
|
|
592
601
|
await db.delete(schema.session_models).where(orm.inArray(schema.session_models.session_id, [sourceSessionId, targetSessionId]));
|
|
602
|
+
await db.delete(schema.session_agents).where(orm.inArray(schema.session_agents.session_id, [sourceSessionId, targetSessionId]));
|
|
593
603
|
});
|
|
594
604
|
test('session event persistence uses (timestamp, event_index) ordering for deterministic same-ms replay', async () => {
|
|
595
605
|
const db = await getDb();
|
package/dist/discord-bot.js
CHANGED
|
@@ -13,15 +13,15 @@ import { stopOpencodeServer, } from './opencode.js';
|
|
|
13
13
|
import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
|
|
14
14
|
import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
|
|
15
15
|
import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
|
|
16
|
-
import {
|
|
16
|
+
import { asSubtext } from './message-formatting.js';
|
|
17
17
|
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
|
|
18
18
|
import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
|
|
19
19
|
import YAML from 'yaml';
|
|
20
|
-
import { getFileAttachments, getTextAttachments,
|
|
21
|
-
import {
|
|
20
|
+
import { getFileAttachments, getTextAttachments, resolveContentMentions } from './message-formatting.js';
|
|
21
|
+
import { extractBtwQueueSuffix } from './btw-prefix-detection.js';
|
|
22
22
|
import { isVoiceAttachment } from './voice-attachment.js';
|
|
23
23
|
import { forkSessionToBtwThread } from './commands/btw.js';
|
|
24
|
-
import {
|
|
24
|
+
import { preprocessExistingThreadMessage, preprocessNewThreadMessage, resolveMessagePrompt, } from './message-preprocessing.js';
|
|
25
25
|
import { cancelPendingActionButtons } from './commands/action-buttons.js';
|
|
26
26
|
import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
|
|
27
27
|
import { cancelPendingFileUpload } from './commands/file-upload.js';
|
|
@@ -48,7 +48,7 @@ export { initializeOpencodeForDirectory, assertCompatibleOpencodeVersion } from
|
|
|
48
48
|
export { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, } from './discord-utils.js';
|
|
49
49
|
export { getOpencodeSystemMessage } from './system-message.js';
|
|
50
50
|
export { ensureKimakiCategory, ensureKimakiAudioCategory, createProjectChannels, createDefaultKimakiChannel, getChannelsWithDescriptions, } from './channel-management.js';
|
|
51
|
-
import { ChannelType, Client, Events, GatewayIntentBits, Partials, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
51
|
+
import { ChannelType, Client, Events, RESTEvents, GatewayIntentBits, Partials, ThreadAutoArchiveDuration, } from 'discord.js';
|
|
52
52
|
import fs from 'node:fs';
|
|
53
53
|
import path from 'node:path';
|
|
54
54
|
import dedent from 'string-dedent';
|
|
@@ -299,6 +299,10 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
299
299
|
discordClient.on(Events.Error, (error) => {
|
|
300
300
|
discordLogger.error('[GATEWAY] Client error:', formatErrorWithStack(error));
|
|
301
301
|
});
|
|
302
|
+
// discord.js silently waits out 429s, so log them to explain slow REST calls.
|
|
303
|
+
discordClient.rest.on(RESTEvents.RateLimited, (info) => {
|
|
304
|
+
discordLogger.warn(`[REST] Rate limited ${info.method} ${info.route} retryAfter=${info.retryAfter}ms scope=${info.scope} global=${info.global} sublimitTimeout=${info.sublimitTimeout}ms`);
|
|
305
|
+
});
|
|
302
306
|
discordClient.on(Events.ShardError, (error, shardId) => {
|
|
303
307
|
const state = getOrCreateShardState(shardId);
|
|
304
308
|
state.lastError = error;
|
|
@@ -596,35 +600,54 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
596
600
|
// `. btw` suffix mirrors /btw for fast side-question forks.
|
|
597
601
|
// Works like queue: just the word "btw" at the end after punctuation
|
|
598
602
|
// or newline. The whole message (minus the suffix) becomes the fork prompt.
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
if (btwResult?.forceBtw && projectDirectory && !isLeadingMentionToOtherUser) {
|
|
603
|
+
const suffix = extractBtwQueueSuffix(message.content || '');
|
|
604
|
+
if (suffix.forceBtw && !suffix.forceQueue && projectDirectory
|
|
605
|
+
&& worktreeInfo?.status !== 'pending' && !isLeadingMentionToOtherUser) {
|
|
603
606
|
threadIngressSlot?.release();
|
|
604
607
|
const btwSdkDir = worktreeInfo?.status === 'ready' &&
|
|
605
608
|
worktreeInfo.workspace_directory
|
|
606
609
|
? worktreeInfo.workspace_directory
|
|
607
610
|
: projectDirectory;
|
|
611
|
+
// Ack right away: fork + thread creation can take seconds. Runs in
|
|
612
|
+
// parallel with the fork and is edited with the result at the end.
|
|
613
|
+
const ackPromise = message.reply({
|
|
614
|
+
content: asSubtext('Forking session to answer this side question...'),
|
|
615
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
616
|
+
}).catch((error) => {
|
|
617
|
+
discordLogger.warn('Could not send btw ack:', error);
|
|
618
|
+
return undefined;
|
|
619
|
+
});
|
|
620
|
+
// Long `kimaki send` prompts arrive as prompt.md, so the fork needs attachments too.
|
|
621
|
+
const [btwImages, btwTextAttachments] = await Promise.all([
|
|
622
|
+
getFileAttachments(message),
|
|
623
|
+
getTextAttachments(message),
|
|
624
|
+
]);
|
|
608
625
|
const result = await forkSessionToBtwThread({
|
|
609
626
|
sourceThread: thread,
|
|
610
627
|
projectDirectory,
|
|
611
628
|
sdkDirectory: btwSdkDir,
|
|
612
|
-
prompt:
|
|
613
|
-
|
|
614
|
-
|
|
629
|
+
prompt: suffix.prompt,
|
|
630
|
+
modelPrompt: [suffix.prompt, btwTextAttachments].filter(Boolean).join('\n\n'),
|
|
631
|
+
images: btwImages.length > 0 ? btwImages : undefined,
|
|
632
|
+
userId: cliInjectedUserId || message.author.id,
|
|
633
|
+
username: cliInjectedUsername ||
|
|
634
|
+
message.member?.displayName ||
|
|
635
|
+
message.author.displayName,
|
|
615
636
|
appId: currentAppId,
|
|
616
637
|
});
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
638
|
+
const resultContent = result instanceof Error
|
|
639
|
+
? result.message
|
|
640
|
+
: `Session forked! Continue in ${result.thread.toString()}`;
|
|
641
|
+
const ack = await ackPromise;
|
|
642
|
+
const edited = ack
|
|
643
|
+
? await ack.edit({ content: resultContent }).catch((error) => {
|
|
644
|
+
discordLogger.warn('Could not edit btw ack:', error);
|
|
645
|
+
return undefined;
|
|
646
|
+
})
|
|
647
|
+
: undefined;
|
|
648
|
+
if (!edited) {
|
|
649
|
+
await message.reply({ content: resultContent, flags: SILENT_MESSAGE_FLAGS });
|
|
623
650
|
}
|
|
624
|
-
await message.reply({
|
|
625
|
-
content: `Session forked! Continue in ${result.thread.toString()}`,
|
|
626
|
-
flags: SILENT_MESSAGE_FLAGS,
|
|
627
|
-
});
|
|
628
651
|
return;
|
|
629
652
|
}
|
|
630
653
|
const hasVoiceAttachment = message.attachments.some((attachment) => {
|
|
@@ -677,7 +700,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
677
700
|
}
|
|
678
701
|
void cancelPendingFileUpload(thread.id);
|
|
679
702
|
};
|
|
680
|
-
if (!hasVoiceAttachment) {
|
|
703
|
+
if (!hasVoiceAttachment && !suffix.forceQueue) {
|
|
681
704
|
await dismissSourceUi();
|
|
682
705
|
}
|
|
683
706
|
// A sleep wake only becomes a turn if it can still claim its own row.
|
|
@@ -707,6 +730,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
707
730
|
message.author.displayName,
|
|
708
731
|
sourceMessageId: message.id,
|
|
709
732
|
sourceThreadId: thread.id,
|
|
733
|
+
sourceChannelId: message.channelId,
|
|
710
734
|
appId: currentAppId,
|
|
711
735
|
agent: cliInjectedAgent,
|
|
712
736
|
model: cliInjectedModel,
|
|
@@ -734,7 +758,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
734
758
|
});
|
|
735
759
|
// Routing must finish before touching source UI. This chain is separate
|
|
736
760
|
// from dispatchAction, so abort waiting does not block session events.
|
|
737
|
-
if (hasVoiceAttachment && !result.skip) {
|
|
761
|
+
if (hasVoiceAttachment && !result.skip && result.mode !== 'local-queue') {
|
|
738
762
|
await dismissSourceUi();
|
|
739
763
|
}
|
|
740
764
|
return result;
|
|
@@ -742,7 +766,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
742
766
|
});
|
|
743
767
|
// Notify when a voice message was queued instead of sent immediately
|
|
744
768
|
if (enqueueResult.queued && enqueueResult.position) {
|
|
745
|
-
await sendThreadMessage(thread,
|
|
769
|
+
await sendThreadMessage(thread, asSubtext(`Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`));
|
|
746
770
|
}
|
|
747
771
|
}
|
|
748
772
|
if (channel.type === ChannelType.GuildText) {
|
|
@@ -879,6 +903,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
879
903
|
username: message.member?.displayName || message.author.displayName,
|
|
880
904
|
sourceMessageId: message.id,
|
|
881
905
|
sourceThreadId: thread.id,
|
|
906
|
+
sourceChannelId: message.channelId,
|
|
882
907
|
appId: currentAppId,
|
|
883
908
|
preprocess: async () => {
|
|
884
909
|
return preprocessNewThreadMessage({
|
|
@@ -944,22 +969,27 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
944
969
|
const runtime = getRuntime(channel.id);
|
|
945
970
|
if (!runtime)
|
|
946
971
|
return;
|
|
947
|
-
//
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
972
|
+
// Same resolution as initial ingress, so the edited prompt matches.
|
|
973
|
+
const { prompt, mode, queuedAction } = await resolveMessagePrompt({
|
|
974
|
+
message,
|
|
975
|
+
text: resolveContentMentions(message),
|
|
976
|
+
});
|
|
951
977
|
// If the edit removed the queue suffix, remove the item from the queue.
|
|
952
978
|
// If the suffix is still present, update the prompt.
|
|
953
|
-
const result = await runtime.updateQueuedMessage(
|
|
979
|
+
const result = await runtime.updateQueuedMessage({
|
|
980
|
+
sourceMessageId: message.id,
|
|
981
|
+
newPrompt: mode === 'local-queue' ? prompt : '',
|
|
982
|
+
queuedAction,
|
|
983
|
+
});
|
|
954
984
|
if (result.found && channel.isThread()) {
|
|
955
985
|
const displayName = message.member?.displayName ?? message.author.displayName;
|
|
956
986
|
if (result.removed) {
|
|
957
987
|
discordLogger.log(`[MESSAGE_EDIT] Removed queued message ${message.id} in thread ${channel.id}`);
|
|
958
|
-
await sendThreadMessage(channel,
|
|
988
|
+
await sendThreadMessage(channel, asSubtext(`**${displayName}** removed message from queue`));
|
|
959
989
|
}
|
|
960
990
|
else {
|
|
961
991
|
discordLogger.log(`[MESSAGE_EDIT] Updated queued message ${message.id} in thread ${channel.id}`);
|
|
962
|
-
await sendThreadMessage(channel,
|
|
992
|
+
await sendThreadMessage(channel, asSubtext(`**${displayName}** edited queued message`));
|
|
963
993
|
}
|
|
964
994
|
}
|
|
965
995
|
}
|
|
@@ -982,7 +1012,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
982
1012
|
if (!removed)
|
|
983
1013
|
return;
|
|
984
1014
|
discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
|
|
985
|
-
await sendThreadMessage(channel,
|
|
1015
|
+
await sendThreadMessage(channel, asSubtext(`**${removed.username}** removed message from queue`));
|
|
986
1016
|
}
|
|
987
1017
|
catch (error) {
|
|
988
1018
|
discordLogger.error('Error handling message delete:', error instanceof Error ? error.stack : String(error));
|
|
@@ -1030,14 +1060,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1030
1060
|
return; // Not an auto-start thread
|
|
1031
1061
|
}
|
|
1032
1062
|
discordLogger.log(`[BOT_SESSION] Detected bot-initiated thread: ${thread.name}`);
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
const
|
|
1038
|
-
const prompt = textAttachmentsContent
|
|
1039
|
-
? `${messageText}\n\n${textAttachmentsContent}`
|
|
1040
|
-
: messageText;
|
|
1063
|
+
const resolvedPrompt = await resolveMessagePrompt({
|
|
1064
|
+
message: starterMessage,
|
|
1065
|
+
text: resolveContentMentions(starterMessage).trim(),
|
|
1066
|
+
});
|
|
1067
|
+
const { prompt } = resolvedPrompt;
|
|
1041
1068
|
if (!prompt) {
|
|
1042
1069
|
discordLogger.log(`[BOT_SESSION] No prompt found in starter message`);
|
|
1043
1070
|
return;
|
|
@@ -1163,7 +1190,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1163
1190
|
permissions: marker.permissions,
|
|
1164
1191
|
injectionGuardPatterns: marker.injectionGuardPatterns,
|
|
1165
1192
|
parentSessionId: marker.parentSessionId,
|
|
1166
|
-
mode:
|
|
1193
|
+
mode: resolvedPrompt.mode,
|
|
1167
1194
|
sessionStartSource: botThreadStartSource
|
|
1168
1195
|
? {
|
|
1169
1196
|
scheduleKind: botThreadStartSource.scheduleKind,
|
|
@@ -1172,11 +1199,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1172
1199
|
}
|
|
1173
1200
|
: undefined,
|
|
1174
1201
|
preprocess: async () => {
|
|
1175
|
-
return
|
|
1176
|
-
prompt,
|
|
1177
|
-
mode: 'opencode',
|
|
1178
|
-
...(fileAttachments.length > 0 && { images: fileAttachments }),
|
|
1179
|
-
};
|
|
1202
|
+
return resolvedPrompt;
|
|
1180
1203
|
},
|
|
1181
1204
|
});
|
|
1182
1205
|
}
|
package/dist/discord-utils.js
CHANGED
|
@@ -10,8 +10,9 @@ import { discordApiUrl } from './discord-urls.js';
|
|
|
10
10
|
import { Lexer } from 'marked';
|
|
11
11
|
import { splitTablesFromMarkdown } from './format-tables.js';
|
|
12
12
|
import { sessionPartContent, shouldLeadWithBlankLine, } from './message-formatting.js';
|
|
13
|
-
import { getChannelDirectory, getThreadWorktreeOrWorkspace } from './database.js';
|
|
13
|
+
import { getChannelDirectory, getThreadIdBySessionId, getThreadWorktreeOrWorkspace } from './database.js';
|
|
14
14
|
import { DiscordOperationError } from './errors.js';
|
|
15
|
+
import YAML from 'yaml';
|
|
15
16
|
import { limitHeadingDepth } from './limit-heading-depth.js';
|
|
16
17
|
import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
|
|
17
18
|
import { createLogger, LogPrefix } from './logger.js';
|
|
@@ -20,6 +21,19 @@ import mime from 'mime';
|
|
|
20
21
|
import fs from 'node:fs';
|
|
21
22
|
import path from 'node:path';
|
|
22
23
|
const discordLogger = createLogger(LogPrefix.DISCORD);
|
|
24
|
+
/** Starter embed with the YAML marker. Links the parent thread when the parent session is known locally. */
|
|
25
|
+
export async function buildThreadStartEmbeds(marker) {
|
|
26
|
+
const parentThreadId = marker.parentSessionId
|
|
27
|
+
? await getThreadIdBySessionId(marker.parentSessionId)
|
|
28
|
+
: undefined;
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
color: 0x2b2d31,
|
|
32
|
+
...(parentThreadId && { description: `Parent thread: <#${parentThreadId}>` }),
|
|
33
|
+
footer: { text: YAML.stringify(marker) },
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
}
|
|
23
37
|
/**
|
|
24
38
|
* Centralized permission check for Kimaki bot access.
|
|
25
39
|
* Returns true if the member has permission to use the bot:
|
package/dist/errors.js
CHANGED
|
@@ -63,6 +63,16 @@ export class TranscriptionError extends errore.createTaggedError({
|
|
|
63
63
|
message: 'Transcription failed: $reason',
|
|
64
64
|
}) {
|
|
65
65
|
}
|
|
66
|
+
export class TranscriptionApiError extends errore.createTaggedError({
|
|
67
|
+
name: 'TranscriptionApiError',
|
|
68
|
+
message: 'Transcription API returned HTTP $status: $body',
|
|
69
|
+
}) {
|
|
70
|
+
}
|
|
71
|
+
export class TranscriptionBlockedError extends errore.createTaggedError({
|
|
72
|
+
name: 'TranscriptionBlockedError',
|
|
73
|
+
message: 'Transcription blocked by the provider content filter ($reason)',
|
|
74
|
+
}) {
|
|
75
|
+
}
|
|
66
76
|
export class SpeechGenerationError extends errore.createTaggedError({
|
|
67
77
|
name: 'SpeechGenerationError',
|
|
68
78
|
message: 'Speech generation failed: $reason',
|
|
@@ -96,11 +106,6 @@ export class NoResponseContentError extends errore.createTaggedError({
|
|
|
96
106
|
message: 'No response content from model',
|
|
97
107
|
}) {
|
|
98
108
|
}
|
|
99
|
-
export class NoToolResponseError extends errore.createTaggedError({
|
|
100
|
-
name: 'NoToolResponseError',
|
|
101
|
-
message: 'No valid tool responses',
|
|
102
|
-
}) {
|
|
103
|
-
}
|
|
104
109
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
105
110
|
// BOUNDARY ERRORS - Wrapping external library exceptions at .catch() sites
|
|
106
111
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -67,18 +67,18 @@ describe('external directory permissions', () => {
|
|
|
67
67
|
-# *using deterministic-provider/deterministic-v2*
|
|
68
68
|
> reading external directory
|
|
69
69
|
|
|
70
|
-
┣ read *probe.txt*
|
|
70
|
+
-# ┣ read *probe.txt*
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
external-directory-probe-done
|
|
73
73
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
74
74
|
--- from: user (external-directory-tester)
|
|
75
75
|
EXTERNAL_DIRECTORY_PROBE_MARKER followup
|
|
76
76
|
--- from: assistant (TestBot)
|
|
77
77
|
> reading external directory
|
|
78
78
|
|
|
79
|
-
┣ read *probe.txt*
|
|
79
|
+
-# ┣ read *probe.txt*
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
external-directory-probe-done
|
|
82
82
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
83
83
|
`);
|
|
84
84
|
expect(text).not.toContain('Permission Required');
|
|
@@ -57,9 +57,9 @@ describe('external directory project deny', () => {
|
|
|
57
57
|
-# *using deterministic-provider/deterministic-v2*
|
|
58
58
|
> reading external directory
|
|
59
59
|
|
|
60
|
-
┣ read *probe.txt*
|
|
60
|
+
-# ┣ read *probe.txt*
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
external-directory-probe-denied"
|
|
63
63
|
`);
|
|
64
64
|
// A deny is silent: it must not fall back to asking the user.
|
|
65
65
|
expect(text).not.toContain('Permission Required');
|
|
@@ -324,7 +324,7 @@ describeIf('gateway-proxy e2e', () => {
|
|
|
324
324
|
hello from gateway proxy test
|
|
325
325
|
--- from: assistant (TestBot)
|
|
326
326
|
-# *using deterministic-provider/deterministic-v2*
|
|
327
|
-
|
|
327
|
+
gateway-proxy-reply
|
|
328
328
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
329
329
|
`);
|
|
330
330
|
expect(reply).toBeDefined();
|
|
@@ -351,12 +351,12 @@ describeIf('gateway-proxy e2e', () => {
|
|
|
351
351
|
hello from gateway proxy test
|
|
352
352
|
--- from: assistant (TestBot)
|
|
353
353
|
-# *using deterministic-provider/deterministic-v2*
|
|
354
|
-
|
|
354
|
+
gateway-proxy-reply
|
|
355
355
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
356
356
|
--- from: user (proxy-tester)
|
|
357
357
|
follow up through proxy
|
|
358
358
|
--- from: assistant (TestBot)
|
|
359
|
-
|
|
359
|
+
gateway-proxy-reply
|
|
360
360
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
361
361
|
`);
|
|
362
362
|
expect(reply).toBeDefined();
|
|
@@ -383,12 +383,12 @@ describeIf('gateway-proxy e2e', () => {
|
|
|
383
383
|
hello from gateway proxy test
|
|
384
384
|
--- from: assistant (TestBot)
|
|
385
385
|
-# *using deterministic-provider/deterministic-v2*
|
|
386
|
-
|
|
386
|
+
gateway-proxy-reply
|
|
387
387
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
388
388
|
--- from: user (proxy-tester)
|
|
389
389
|
follow up through proxy
|
|
390
390
|
--- from: assistant (TestBot)
|
|
391
|
-
|
|
391
|
+
gateway-proxy-reply
|
|
392
392
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
393
393
|
--- from: user (proxy-tester)
|
|
394
394
|
!echo proxy-shell-test
|