kimaki 0.4.86 → 0.4.88
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 +5 -1
- package/dist/cli-send-thread.e2e.test.js +280 -0
- package/dist/commands/btw.js +111 -0
- package/dist/discord-bot.js +9 -0
- package/dist/discord-command-registration.js +53 -41
- package/dist/gateway-proxy.e2e.test.js +5 -3
- package/dist/interaction-handler.js +4 -15
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/markdown.test.js +32 -0
- package/dist/message-finish-field.e2e.test.js +164 -0
- package/dist/opencode.js +54 -35
- package/dist/queue-advanced-abort.e2e.test.js +1 -1
- package/dist/queue-advanced-action-buttons.e2e.test.js +12 -1
- package/dist/queue-advanced-footer.e2e.test.js +42 -5
- package/dist/queue-advanced-model-switch.e2e.test.js +5 -2
- package/dist/queue-advanced-permissions-typing.e2e.test.js +13 -1
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -2
- package/dist/queue-question-select-drain.e2e.test.js +2 -1
- package/dist/runtime-lifecycle.e2e.test.js +3 -1
- package/dist/session-handler/event-stream-state.test.js +3 -0
- package/dist/thread-message-queue.e2e.test.js +5 -6
- package/dist/undo-redo.e2e.test.js +1 -0
- package/dist/voice-message.e2e.test.js +6 -1
- package/package.json +6 -5
- package/skills/critique/SKILL.md +3 -37
- package/skills/gitchamber/SKILL.md +93 -0
- package/skills/goke/SKILL.md +3 -1
- package/src/agent-model.e2e.test.ts +5 -1
- package/src/cli-send-thread.e2e.test.ts +365 -0
- package/src/commands/btw.ts +158 -0
- package/src/discord-bot.ts +10 -0
- package/src/discord-command-registration.ts +64 -49
- package/src/gateway-proxy.e2e.test.ts +5 -3
- package/src/interaction-handler.ts +8 -15
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/markdown.test.ts +32 -0
- package/src/message-finish-field.e2e.test.ts +191 -0
- package/src/opencode.ts +54 -35
- package/src/queue-advanced-abort.e2e.test.ts +1 -1
- package/src/queue-advanced-action-buttons.e2e.test.ts +12 -1
- package/src/queue-advanced-footer.e2e.test.ts +42 -5
- package/src/queue-advanced-model-switch.e2e.test.ts +5 -2
- package/src/queue-advanced-permissions-typing.e2e.test.ts +13 -1
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -2
- package/src/queue-question-select-drain.e2e.test.ts +2 -1
- package/src/runtime-lifecycle.e2e.test.ts +3 -1
- package/src/session-handler/event-stream-state.test.ts +3 -0
- package/src/thread-message-queue.e2e.test.ts +5 -6
- package/src/undo-redo.e2e.test.ts +1 -0
- package/src/voice-message.e2e.test.ts +6 -1
|
@@ -345,7 +345,8 @@ describe('agent model resolution', () => {
|
|
|
345
345
|
Reply with exactly: system-context-check
|
|
346
346
|
--- from: assistant (TestBot)
|
|
347
347
|
⬥ system-context-ok
|
|
348
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
348
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
349
|
+
⬥ ok"
|
|
349
350
|
`);
|
|
350
351
|
}, 15_000);
|
|
351
352
|
test('new thread uses channel model when channel model preference is set', async () => {
|
|
@@ -512,6 +513,7 @@ describe('agent model resolution', () => {
|
|
|
512
513
|
Reply with exactly: second-thread-msg
|
|
513
514
|
--- from: assistant (TestBot)
|
|
514
515
|
⬥ ok
|
|
516
|
+
⬥ ok
|
|
515
517
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
|
|
516
518
|
`);
|
|
517
519
|
const secondMessages = await discord.thread(thread.id).getMessages();
|
|
@@ -594,6 +596,7 @@ describe('agent model resolution', () => {
|
|
|
594
596
|
Reply with exactly: default-second-msg
|
|
595
597
|
--- from: assistant (TestBot)
|
|
596
598
|
⬥ ok
|
|
599
|
+
⬥ ok
|
|
597
600
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
598
601
|
`);
|
|
599
602
|
const secondMessages = await discord.thread(thread.id).getMessages();
|
|
@@ -661,6 +664,7 @@ describe('agent model resolution', () => {
|
|
|
661
664
|
Reply with exactly: after-switch-msg
|
|
662
665
|
--- from: assistant (TestBot)
|
|
663
666
|
⬥ ok
|
|
667
|
+
⬥ ok
|
|
664
668
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
|
|
665
669
|
`);
|
|
666
670
|
const secondFooter = [...(await discord.thread(thread.id).getMessages())]
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// E2e test for `kimaki send --channel` flow.
|
|
2
|
+
// Reproduces the race condition where the bot's MessageCreate GuildText handler
|
|
3
|
+
// tries to call startThread() on the same message that the CLI already created
|
|
4
|
+
// a thread for via REST, causing DiscordAPIError[160004].
|
|
5
|
+
//
|
|
6
|
+
// The test simulates the exact flow: bot posts a starter message with a
|
|
7
|
+
// `start: true` embed marker, then creates a thread on that message via REST.
|
|
8
|
+
// The ThreadCreate handler should pick it up and start a session. The
|
|
9
|
+
// MessageCreate handler must NOT try to startThread() on the same message.
|
|
10
|
+
//
|
|
11
|
+
// Uses opencode-deterministic-provider (no real LLM calls).
|
|
12
|
+
// Poll timeouts: 4s max, 100ms interval.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import url from 'node:url';
|
|
16
|
+
import { describe, beforeAll, afterAll, test, expect } from 'vitest';
|
|
17
|
+
import { ChannelType, Client, GatewayIntentBits, Partials, Routes, } from 'discord.js';
|
|
18
|
+
import { DigitalDiscord } from 'discord-digital-twin/src';
|
|
19
|
+
import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
|
|
20
|
+
import { setDataDir } from './config.js';
|
|
21
|
+
import { store } from './store.js';
|
|
22
|
+
import { startDiscordBot } from './discord-bot.js';
|
|
23
|
+
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, } from './database.js';
|
|
24
|
+
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
25
|
+
import { initializeOpencodeForDirectory, stopOpencodeServer, } from './opencode.js';
|
|
26
|
+
import { chooseLockPort, cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
27
|
+
import yaml from 'js-yaml';
|
|
28
|
+
const TEST_USER_ID = '200000000000000830';
|
|
29
|
+
const TEXT_CHANNEL_ID = '200000000000000831';
|
|
30
|
+
const BOT_USER_ID = '200000000000000832';
|
|
31
|
+
function createRunDirectories() {
|
|
32
|
+
const root = path.resolve(process.cwd(), 'tmp', 'cli-send-thread-e2e');
|
|
33
|
+
fs.mkdirSync(root, { recursive: true });
|
|
34
|
+
const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
|
|
35
|
+
const projectDirectory = path.join(root, 'project');
|
|
36
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
37
|
+
return { root, dataDir, projectDirectory };
|
|
38
|
+
}
|
|
39
|
+
function createDiscordJsClient({ restUrl }) {
|
|
40
|
+
return new Client({
|
|
41
|
+
intents: [
|
|
42
|
+
GatewayIntentBits.Guilds,
|
|
43
|
+
GatewayIntentBits.GuildMessages,
|
|
44
|
+
GatewayIntentBits.MessageContent,
|
|
45
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
46
|
+
],
|
|
47
|
+
partials: [
|
|
48
|
+
Partials.Channel,
|
|
49
|
+
Partials.Message,
|
|
50
|
+
Partials.User,
|
|
51
|
+
Partials.ThreadMember,
|
|
52
|
+
],
|
|
53
|
+
rest: {
|
|
54
|
+
api: restUrl,
|
|
55
|
+
version: '10',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function createDeterministicMatchers() {
|
|
60
|
+
const userReplyMatcher = {
|
|
61
|
+
id: 'user-reply',
|
|
62
|
+
priority: 10,
|
|
63
|
+
when: {
|
|
64
|
+
lastMessageRole: 'user',
|
|
65
|
+
latestUserTextIncludes: 'Reply with exactly:',
|
|
66
|
+
},
|
|
67
|
+
then: {
|
|
68
|
+
parts: [
|
|
69
|
+
{ type: 'stream-start', warnings: [] },
|
|
70
|
+
{ type: 'text-start', id: 'default-reply' },
|
|
71
|
+
{ type: 'text-delta', id: 'default-reply', delta: 'ok' },
|
|
72
|
+
{ type: 'text-end', id: 'default-reply' },
|
|
73
|
+
{
|
|
74
|
+
type: 'finish',
|
|
75
|
+
finishReason: 'stop',
|
|
76
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
partDelaysMs: [0, 100, 0, 0, 0],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
return [userReplyMatcher];
|
|
83
|
+
}
|
|
84
|
+
describe('kimaki send --channel thread creation', () => {
|
|
85
|
+
let directories;
|
|
86
|
+
let discord;
|
|
87
|
+
let botClient;
|
|
88
|
+
let previousDefaultVerbosity = null;
|
|
89
|
+
let testStartTime = Date.now();
|
|
90
|
+
beforeAll(async () => {
|
|
91
|
+
testStartTime = Date.now();
|
|
92
|
+
directories = createRunDirectories();
|
|
93
|
+
const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID });
|
|
94
|
+
process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
|
|
95
|
+
setDataDir(directories.dataDir);
|
|
96
|
+
previousDefaultVerbosity = store.getState().defaultVerbosity;
|
|
97
|
+
store.setState({ defaultVerbosity: 'tools_and_text' });
|
|
98
|
+
const digitalDiscordDbPath = path.join(directories.dataDir, 'digital-discord.db');
|
|
99
|
+
discord = new DigitalDiscord({
|
|
100
|
+
botUser: { id: BOT_USER_ID },
|
|
101
|
+
guild: {
|
|
102
|
+
name: 'CLI Send E2E Guild',
|
|
103
|
+
// Use bot as guild owner so bot-authored messages pass
|
|
104
|
+
// hasKimakiBotPermission (owner check). This matches production where
|
|
105
|
+
// the bot typically has admin or is the app owner. Without this, the
|
|
106
|
+
// MessageCreate handler drops bot messages before reaching the GuildText
|
|
107
|
+
// path, hiding the race condition we're testing.
|
|
108
|
+
ownerId: BOT_USER_ID,
|
|
109
|
+
},
|
|
110
|
+
channels: [
|
|
111
|
+
{
|
|
112
|
+
id: TEXT_CHANNEL_ID,
|
|
113
|
+
name: 'cli-send-e2e',
|
|
114
|
+
type: ChannelType.GuildText,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
users: [
|
|
118
|
+
{
|
|
119
|
+
id: TEST_USER_ID,
|
|
120
|
+
username: 'cli-send-tester',
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
dbUrl: `file:${digitalDiscordDbPath}`,
|
|
124
|
+
});
|
|
125
|
+
await discord.start();
|
|
126
|
+
const providerNpm = url
|
|
127
|
+
.pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
|
|
128
|
+
.toString();
|
|
129
|
+
const opencodeConfig = buildDeterministicOpencodeConfig({
|
|
130
|
+
providerName: 'deterministic-provider',
|
|
131
|
+
providerNpm,
|
|
132
|
+
model: 'deterministic-v2',
|
|
133
|
+
smallModel: 'deterministic-v2',
|
|
134
|
+
settings: {
|
|
135
|
+
strict: false,
|
|
136
|
+
matchers: createDeterministicMatchers(),
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
|
|
140
|
+
const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
|
|
141
|
+
const hranaResult = await startHranaServer({ dbPath });
|
|
142
|
+
if (hranaResult instanceof Error) {
|
|
143
|
+
throw hranaResult;
|
|
144
|
+
}
|
|
145
|
+
process.env['KIMAKI_DB_URL'] = hranaResult;
|
|
146
|
+
await initDatabase();
|
|
147
|
+
await setBotToken(discord.botUserId, discord.botToken);
|
|
148
|
+
await setChannelDirectory({
|
|
149
|
+
channelId: TEXT_CHANNEL_ID,
|
|
150
|
+
directory: directories.projectDirectory,
|
|
151
|
+
channelType: 'text',
|
|
152
|
+
});
|
|
153
|
+
await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools_and_text');
|
|
154
|
+
botClient = createDiscordJsClient({ restUrl: discord.restUrl });
|
|
155
|
+
await startDiscordBot({
|
|
156
|
+
token: discord.botToken,
|
|
157
|
+
appId: discord.botUserId,
|
|
158
|
+
discordClient: botClient,
|
|
159
|
+
});
|
|
160
|
+
// Pre-warm the opencode server
|
|
161
|
+
const warmup = await initializeOpencodeForDirectory(directories.projectDirectory);
|
|
162
|
+
if (warmup instanceof Error) {
|
|
163
|
+
throw warmup;
|
|
164
|
+
}
|
|
165
|
+
}, 60_000);
|
|
166
|
+
afterAll(async () => {
|
|
167
|
+
if (directories) {
|
|
168
|
+
await cleanupTestSessions({
|
|
169
|
+
projectDirectory: directories.projectDirectory,
|
|
170
|
+
testStartTime,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (botClient) {
|
|
174
|
+
botClient.destroy();
|
|
175
|
+
}
|
|
176
|
+
await stopOpencodeServer();
|
|
177
|
+
await Promise.all([
|
|
178
|
+
closeDatabase().catch(() => {
|
|
179
|
+
return;
|
|
180
|
+
}),
|
|
181
|
+
stopHranaServer().catch(() => {
|
|
182
|
+
return;
|
|
183
|
+
}),
|
|
184
|
+
discord?.stop().catch(() => {
|
|
185
|
+
return;
|
|
186
|
+
}),
|
|
187
|
+
]);
|
|
188
|
+
delete process.env['KIMAKI_LOCK_PORT'];
|
|
189
|
+
delete process.env['KIMAKI_DB_URL'];
|
|
190
|
+
if (previousDefaultVerbosity) {
|
|
191
|
+
store.setState({ defaultVerbosity: previousDefaultVerbosity });
|
|
192
|
+
}
|
|
193
|
+
if (directories) {
|
|
194
|
+
fs.rmSync(directories.dataDir, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
}, 10_000);
|
|
197
|
+
test('bot-posted starter message with start marker creates thread without DiscordAPIError[160004]', async () => {
|
|
198
|
+
// Simulate what `kimaki send --channel` does:
|
|
199
|
+
// 1. Bot posts a starter message with `start: true` embed marker
|
|
200
|
+
// 2. Bot creates a thread on that message via REST
|
|
201
|
+
// The ThreadCreate handler should pick it up. The MessageCreate GuildText
|
|
202
|
+
// handler must NOT try to startThread() on the same message (race).
|
|
203
|
+
const prompt = 'Reply with exactly: cli-send-test';
|
|
204
|
+
const embedMarker = {
|
|
205
|
+
start: true,
|
|
206
|
+
username: 'cli-send-tester',
|
|
207
|
+
userId: TEST_USER_ID,
|
|
208
|
+
};
|
|
209
|
+
// Step 1: Bot posts the starter message (same as CLI's sendDiscordMessageWithOptionalAttachment)
|
|
210
|
+
const starterMessage = (await botClient.rest.post(Routes.channelMessages(TEXT_CHANNEL_ID), {
|
|
211
|
+
body: {
|
|
212
|
+
content: prompt,
|
|
213
|
+
embeds: [
|
|
214
|
+
{ color: 0x2b2d31, footer: { text: yaml.dump(embedMarker) } },
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
}));
|
|
218
|
+
// Give the bot's MessageCreate handler time to process the starter
|
|
219
|
+
// message. Without the fix, the handler enters the GuildText path and
|
|
220
|
+
// tries to startThread() on this message, which races the CLI's thread
|
|
221
|
+
// creation below. The digital twin enforces Discord's 160004 uniqueness
|
|
222
|
+
// constraint, so the second startThread call fails.
|
|
223
|
+
await new Promise((resolve) => {
|
|
224
|
+
setTimeout(resolve, 200);
|
|
225
|
+
});
|
|
226
|
+
// Verify the MessageCreate handler did NOT create a thread on this
|
|
227
|
+
// message. If the handler ignored the start marker (correct behavior),
|
|
228
|
+
// no thread exists yet and the REST call below succeeds.
|
|
229
|
+
const threadsBeforeCliCreate = await discord
|
|
230
|
+
.channel(TEXT_CHANNEL_ID)
|
|
231
|
+
.getThreads();
|
|
232
|
+
const preExistingThread = threadsBeforeCliCreate.find((t) => {
|
|
233
|
+
return t.name?.includes('cli-send-test');
|
|
234
|
+
});
|
|
235
|
+
// This is the core regression assertion: without the fix in discord-bot.ts
|
|
236
|
+
// (skipping start markers in the GuildText handler), the MessageCreate
|
|
237
|
+
// handler would create a thread here, and the CLI's REST call below would
|
|
238
|
+
// fail with 160004.
|
|
239
|
+
expect(preExistingThread).toBeUndefined();
|
|
240
|
+
// Step 2: Bot creates a thread on the starter message (same as CLI's Routes.threads call)
|
|
241
|
+
const threadData = (await botClient.rest.post(Routes.threads(TEXT_CHANNEL_ID, starterMessage.id), {
|
|
242
|
+
body: {
|
|
243
|
+
name: 'cli-send-test',
|
|
244
|
+
auto_archive_duration: 1440,
|
|
245
|
+
},
|
|
246
|
+
}));
|
|
247
|
+
// Add test user to thread
|
|
248
|
+
await botClient.rest.put(Routes.threadMembers(threadData.id, TEST_USER_ID));
|
|
249
|
+
// Wait for the bot to reply with the ⬥ prefix (proves ThreadCreate
|
|
250
|
+
// handler picked up the starter message and started a session)
|
|
251
|
+
await waitForBotMessageContaining({
|
|
252
|
+
discord,
|
|
253
|
+
threadId: threadData.id,
|
|
254
|
+
userId: discord.botUserId,
|
|
255
|
+
text: '⬥',
|
|
256
|
+
timeout: 4_000,
|
|
257
|
+
});
|
|
258
|
+
// Wait for footer message (proves session completed successfully)
|
|
259
|
+
await waitForFooterMessage({
|
|
260
|
+
discord,
|
|
261
|
+
threadId: threadData.id,
|
|
262
|
+
timeout: 4_000,
|
|
263
|
+
afterMessageIncludes: '⬥',
|
|
264
|
+
afterAuthorId: discord.botUserId,
|
|
265
|
+
});
|
|
266
|
+
// Verify no DiscordAPIError[160004] or other errors in the thread.
|
|
267
|
+
// Before the fix, the MessageCreate GuildText handler would race the
|
|
268
|
+
// CLI's thread creation and produce an error message here.
|
|
269
|
+
const messages = await discord.thread(threadData.id).getMessages();
|
|
270
|
+
const errorMessages = messages.filter((m) => {
|
|
271
|
+
return m.content.includes('Error:') || m.content.includes('160004');
|
|
272
|
+
});
|
|
273
|
+
expect(errorMessages).toHaveLength(0);
|
|
274
|
+
// Verify at least one ⬥ reply exists (session produced output)
|
|
275
|
+
const botReplies = messages.filter((m) => {
|
|
276
|
+
return (m.author.id === discord.botUserId && m.content.startsWith('⬥'));
|
|
277
|
+
});
|
|
278
|
+
expect(botReplies.length).toBeGreaterThanOrEqual(1);
|
|
279
|
+
}, 15_000);
|
|
280
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// /btw command - Fork the current session with full context and send a new prompt.
|
|
2
|
+
// Unlike /fork, this does not replay past messages in Discord. It just creates
|
|
3
|
+
// a new thread, forks the entire session (no messageID), and immediately
|
|
4
|
+
// dispatches the user's prompt so the forked session starts working right away.
|
|
5
|
+
import { ChannelType, ThreadAutoArchiveDuration, MessageFlags, } from 'discord.js';
|
|
6
|
+
import { getThreadSession, setThreadSession } from '../database.js';
|
|
7
|
+
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
8
|
+
import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
|
|
9
|
+
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
10
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
11
|
+
const logger = createLogger(LogPrefix.FORK);
|
|
12
|
+
export async function handleBtwCommand({ command, appId, }) {
|
|
13
|
+
const channel = command.channel;
|
|
14
|
+
if (!channel) {
|
|
15
|
+
await command.reply({
|
|
16
|
+
content: 'This command can only be used in a channel',
|
|
17
|
+
flags: MessageFlags.Ephemeral,
|
|
18
|
+
});
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const isThread = [
|
|
22
|
+
ChannelType.PublicThread,
|
|
23
|
+
ChannelType.PrivateThread,
|
|
24
|
+
ChannelType.AnnouncementThread,
|
|
25
|
+
].includes(channel.type);
|
|
26
|
+
if (!isThread) {
|
|
27
|
+
await command.reply({
|
|
28
|
+
content: 'This command can only be used in a thread with an active session',
|
|
29
|
+
flags: MessageFlags.Ephemeral,
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const prompt = command.options.getString('prompt', true);
|
|
34
|
+
const resolved = await resolveWorkingDirectory({
|
|
35
|
+
channel: channel,
|
|
36
|
+
});
|
|
37
|
+
if (!resolved) {
|
|
38
|
+
await command.reply({
|
|
39
|
+
content: 'Could not determine project directory for this channel',
|
|
40
|
+
flags: MessageFlags.Ephemeral,
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const { projectDirectory } = resolved;
|
|
45
|
+
const sessionId = await getThreadSession(channel.id);
|
|
46
|
+
if (!sessionId) {
|
|
47
|
+
await command.reply({
|
|
48
|
+
content: 'No active session in this thread',
|
|
49
|
+
flags: MessageFlags.Ephemeral,
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
await command.deferReply({ flags: MessageFlags.Ephemeral });
|
|
54
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
55
|
+
if (getClient instanceof Error) {
|
|
56
|
+
await command.editReply({
|
|
57
|
+
content: `Failed to fork session: ${getClient.message}`,
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
// Fork the entire session (no messageID = fork at the latest point)
|
|
63
|
+
const forkResponse = await getClient().session.fork({
|
|
64
|
+
sessionID: sessionId,
|
|
65
|
+
});
|
|
66
|
+
if (!forkResponse.data) {
|
|
67
|
+
await command.editReply('Failed to fork session');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const forkedSession = forkResponse.data;
|
|
71
|
+
const textChannel = await resolveTextChannel(channel);
|
|
72
|
+
if (!textChannel) {
|
|
73
|
+
await command.editReply('Could not resolve parent text channel');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const threadName = `btw: ${prompt}`.slice(0, 100);
|
|
77
|
+
const thread = await textChannel.threads.create({
|
|
78
|
+
name: threadName,
|
|
79
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
80
|
+
reason: `btw fork from session ${sessionId}`,
|
|
81
|
+
});
|
|
82
|
+
// Claim the forked session immediately so external polling does not race
|
|
83
|
+
await setThreadSession(thread.id, forkedSession.id);
|
|
84
|
+
await thread.members.add(command.user.id);
|
|
85
|
+
logger.log(`Created btw fork session ${forkedSession.id} in thread ${thread.id} from ${sessionId}`);
|
|
86
|
+
// Short status message with prompt instead of replaying past messages
|
|
87
|
+
const sourceThreadLink = `<#${channel.id}>`;
|
|
88
|
+
await sendThreadMessage(thread, `Reusing context from ${sourceThreadLink} to answer prompt...\n${prompt}`);
|
|
89
|
+
// Create runtime and dispatch the prompt immediately
|
|
90
|
+
const runtime = getOrCreateRuntime({
|
|
91
|
+
threadId: thread.id,
|
|
92
|
+
thread,
|
|
93
|
+
projectDirectory,
|
|
94
|
+
sdkDirectory: projectDirectory,
|
|
95
|
+
channelId: textChannel.id,
|
|
96
|
+
appId,
|
|
97
|
+
});
|
|
98
|
+
await runtime.enqueueIncoming({
|
|
99
|
+
prompt,
|
|
100
|
+
userId: command.user.id,
|
|
101
|
+
username: command.user.displayName,
|
|
102
|
+
appId,
|
|
103
|
+
mode: 'opencode',
|
|
104
|
+
});
|
|
105
|
+
await command.editReply(`Session forked! Continue in ${thread.toString()}`);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
logger.error('Error in /btw:', error);
|
|
109
|
+
await command.editReply(`Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/dist/discord-bot.js
CHANGED
|
@@ -512,6 +512,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
512
512
|
}
|
|
513
513
|
}
|
|
514
514
|
if (channel.type === ChannelType.GuildText) {
|
|
515
|
+
// `kimaki send` posts a starter message with a `start` embed marker,
|
|
516
|
+
// then creates the thread via REST. The ThreadCreate handler picks up
|
|
517
|
+
// that thread and starts the session. If we don't skip here, this
|
|
518
|
+
// handler races the CLI to call startThread() on the same message,
|
|
519
|
+
// causing DiscordAPIError[160004] "A thread has already been created
|
|
520
|
+
// for this message".
|
|
521
|
+
if (promptMarker?.start) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
515
524
|
const textChannel = channel;
|
|
516
525
|
voiceLogger.log(`[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`);
|
|
517
526
|
const channelConfig = await getChannelDirectory(textChannel.id);
|
|
@@ -161,11 +161,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
161
161
|
})
|
|
162
162
|
.setDMPermission(false)
|
|
163
163
|
.toJSON(),
|
|
164
|
-
new SlashCommandBuilder()
|
|
165
|
-
.setName('toggle-mention-mode')
|
|
166
|
-
.setDescription(truncateCommandDescription('Toggle mention-only mode (bot only responds when @mentioned)'))
|
|
167
|
-
.setDMPermission(false)
|
|
168
|
-
.toJSON(),
|
|
169
164
|
new SlashCommandBuilder()
|
|
170
165
|
.setName('add-project')
|
|
171
166
|
.setDescription(truncateCommandDescription('Create Discord channels for a project. Use `npx kimaki project add` for unlisted projects'))
|
|
@@ -214,11 +209,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
214
209
|
.setDescription(truncateCommandDescription('Compact the session context by summarizing conversation history'))
|
|
215
210
|
.setDMPermission(false)
|
|
216
211
|
.toJSON(),
|
|
217
|
-
new SlashCommandBuilder()
|
|
218
|
-
.setName('stop')
|
|
219
|
-
.setDescription(truncateCommandDescription('Abort the current OpenCode request in this thread'))
|
|
220
|
-
.setDMPermission(false)
|
|
221
|
-
.toJSON(),
|
|
222
212
|
new SlashCommandBuilder()
|
|
223
213
|
.setName('share')
|
|
224
214
|
.setDescription(truncateCommandDescription('Share the current session as a public URL'))
|
|
@@ -234,6 +224,18 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
234
224
|
.setDescription(truncateCommandDescription('Fork the session from a past user message'))
|
|
235
225
|
.setDMPermission(false)
|
|
236
226
|
.toJSON(),
|
|
227
|
+
new SlashCommandBuilder()
|
|
228
|
+
.setName('btw')
|
|
229
|
+
.setDescription(truncateCommandDescription('Ask something without polluting or blocking the current session'))
|
|
230
|
+
.addStringOption((option) => {
|
|
231
|
+
option
|
|
232
|
+
.setName('prompt')
|
|
233
|
+
.setDescription(truncateCommandDescription('The message to send in the forked session'))
|
|
234
|
+
.setRequired(true);
|
|
235
|
+
return option;
|
|
236
|
+
})
|
|
237
|
+
.setDMPermission(false)
|
|
238
|
+
.toJSON(),
|
|
237
239
|
new SlashCommandBuilder()
|
|
238
240
|
.setName('model')
|
|
239
241
|
.setDescription(truncateCommandDescription('Set the preferred model for this channel or session'))
|
|
@@ -338,11 +340,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
338
340
|
.setDescription(truncateCommandDescription('Show current session ID and opencode attach command for this thread'))
|
|
339
341
|
.setDMPermission(false)
|
|
340
342
|
.toJSON(),
|
|
341
|
-
new SlashCommandBuilder()
|
|
342
|
-
.setName('memory-snapshot')
|
|
343
|
-
.setDescription(truncateCommandDescription('Write a V8 heap snapshot to disk for memory debugging'))
|
|
344
|
-
.setDMPermission(false)
|
|
345
|
-
.toJSON(),
|
|
346
343
|
new SlashCommandBuilder()
|
|
347
344
|
.setName('upgrade-and-restart')
|
|
348
345
|
.setDescription(truncateCommandDescription('Upgrade kimaki to the latest version and restart the bot'))
|
|
@@ -369,10 +366,43 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
369
366
|
.setDMPermission(false)
|
|
370
367
|
.toJSON(),
|
|
371
368
|
];
|
|
372
|
-
//
|
|
369
|
+
// Dynamic commands are registered in priority order: agents → user commands → skills → MCP prompts.
|
|
370
|
+
// This ordering matters because we slice to MAX_DISCORD_COMMANDS (100) at the end,
|
|
371
|
+
// so lower-priority dynamic commands get trimmed first if the total exceeds the limit.
|
|
372
|
+
// 1. Agent-specific quick commands like /plan-agent, /build-agent
|
|
373
|
+
// Filter to primary/all mode agents (same as /agent command shows), excluding hidden agents
|
|
374
|
+
const primaryAgents = agents.filter((a) => (a.mode === 'primary' || a.mode === 'all') && !a.hidden);
|
|
375
|
+
for (const agent of primaryAgents) {
|
|
376
|
+
const sanitizedName = sanitizeAgentName(agent.name);
|
|
377
|
+
// Skip if sanitized name is empty or would create invalid command name
|
|
378
|
+
// Discord command names must start with a lowercase letter or number
|
|
379
|
+
if (!sanitizedName || !/^[a-z0-9]/.test(sanitizedName)) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
// Truncate base name before appending suffix so the -agent suffix is never
|
|
383
|
+
// lost to Discord's 32-char command name limit.
|
|
384
|
+
const agentSuffix = '-agent';
|
|
385
|
+
const agentBaseName = sanitizedName.slice(0, 32 - agentSuffix.length);
|
|
386
|
+
const commandName = `${agentBaseName}${agentSuffix}`;
|
|
387
|
+
const description = buildQuickAgentCommandDescription({
|
|
388
|
+
agentName: agent.name,
|
|
389
|
+
description: agent.description,
|
|
390
|
+
});
|
|
391
|
+
commands.push(new SlashCommandBuilder()
|
|
392
|
+
.setName(commandName)
|
|
393
|
+
.setDescription(truncateCommandDescription(description))
|
|
394
|
+
.setDMPermission(false)
|
|
395
|
+
.toJSON());
|
|
396
|
+
}
|
|
397
|
+
// 2. User-defined commands, skills, and MCP prompts (ordered by priority)
|
|
373
398
|
// Also populate registeredUserCommands in the store for /queue-command autocomplete
|
|
374
399
|
const newRegisteredCommands = [];
|
|
375
|
-
|
|
400
|
+
// Sort: regular commands first, then skills, then MCP prompts
|
|
401
|
+
const sourceOrder = { config: 0, skill: 1, mcp: 2 };
|
|
402
|
+
const sortedUserCommands = [...userCommands].sort((a, b) => {
|
|
403
|
+
return (sourceOrder[a.source || ''] ?? 0) - (sourceOrder[b.source || ''] ?? 0);
|
|
404
|
+
});
|
|
405
|
+
for (const cmd of sortedUserCommands) {
|
|
376
406
|
if (SKIP_USER_COMMANDS.includes(cmd.name)) {
|
|
377
407
|
continue;
|
|
378
408
|
}
|
|
@@ -415,30 +445,12 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
415
445
|
.toJSON());
|
|
416
446
|
}
|
|
417
447
|
store.setState({ registeredUserCommands: newRegisteredCommands });
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
// Discord command names must start with a lowercase letter or number
|
|
425
|
-
if (!sanitizedName || !/^[a-z0-9]/.test(sanitizedName)) {
|
|
426
|
-
continue;
|
|
427
|
-
}
|
|
428
|
-
// Truncate base name before appending suffix so the -agent suffix is never
|
|
429
|
-
// lost to Discord's 32-char command name limit.
|
|
430
|
-
const agentSuffix = '-agent';
|
|
431
|
-
const agentBaseName = sanitizedName.slice(0, 32 - agentSuffix.length);
|
|
432
|
-
const commandName = `${agentBaseName}${agentSuffix}`;
|
|
433
|
-
const description = buildQuickAgentCommandDescription({
|
|
434
|
-
agentName: agent.name,
|
|
435
|
-
description: agent.description,
|
|
436
|
-
});
|
|
437
|
-
commands.push(new SlashCommandBuilder()
|
|
438
|
-
.setName(commandName)
|
|
439
|
-
.setDescription(truncateCommandDescription(description))
|
|
440
|
-
.setDMPermission(false)
|
|
441
|
-
.toJSON());
|
|
448
|
+
// Discord allows max 100 guild commands. Slice to stay within the limit,
|
|
449
|
+
// trimming lowest-priority dynamic commands (MCP prompts, then skills) first.
|
|
450
|
+
const MAX_DISCORD_COMMANDS = 100;
|
|
451
|
+
if (commands.length > MAX_DISCORD_COMMANDS) {
|
|
452
|
+
cliLogger.warn(`COMMANDS: ${commands.length} commands exceed Discord limit of ${MAX_DISCORD_COMMANDS}, truncating to ${MAX_DISCORD_COMMANDS}`);
|
|
453
|
+
commands.length = MAX_DISCORD_COMMANDS;
|
|
442
454
|
}
|
|
443
455
|
const rest = createDiscordRest(token);
|
|
444
456
|
const uniqueGuildIds = Array.from(new Set(guildIds.filter((guildId) => guildId)));
|
|
@@ -353,8 +353,9 @@ describeIf('gateway-proxy e2e', () => {
|
|
|
353
353
|
--- from: user (proxy-tester)
|
|
354
354
|
follow up through proxy
|
|
355
355
|
--- from: assistant (TestBot)
|
|
356
|
-
⬥
|
|
357
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
356
|
+
⬥ ok
|
|
357
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
358
|
+
⬥ ok"
|
|
358
359
|
`);
|
|
359
360
|
expect(reply).toBeDefined();
|
|
360
361
|
expect(reply.content.trim().length).toBeGreaterThan(0);
|
|
@@ -384,8 +385,9 @@ describeIf('gateway-proxy e2e', () => {
|
|
|
384
385
|
--- from: user (proxy-tester)
|
|
385
386
|
follow up through proxy
|
|
386
387
|
--- from: assistant (TestBot)
|
|
387
|
-
⬥
|
|
388
|
+
⬥ ok
|
|
388
389
|
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
390
|
+
⬥ ok
|
|
389
391
|
--- from: user (proxy-tester)
|
|
390
392
|
!echo proxy-shell-test
|
|
391
393
|
--- from: assistant (TestBot)
|
|
@@ -8,7 +8,6 @@ import { handleMergeWorktreeCommand, handleMergeWorktreeAutocomplete, } from './
|
|
|
8
8
|
import { handleToggleWorktreesCommand } from './commands/worktree-settings.js';
|
|
9
9
|
import { handleWorktreesCommand } from './commands/worktrees.js';
|
|
10
10
|
import { handleTasksCommand } from './commands/tasks.js';
|
|
11
|
-
import { handleToggleMentionModeCommand } from './commands/mention-mode.js';
|
|
12
11
|
import { handleResumeCommand, handleResumeAutocomplete, } from './commands/resume.js';
|
|
13
12
|
import { handleAddProjectCommand, handleAddProjectAutocomplete, } from './commands/add-project.js';
|
|
14
13
|
import { handleRemoveProjectCommand, handleRemoveProjectAutocomplete, } from './commands/remove-project.js';
|
|
@@ -19,6 +18,7 @@ import { handleCompactCommand } from './commands/compact.js';
|
|
|
19
18
|
import { handleShareCommand } from './commands/share.js';
|
|
20
19
|
import { handleDiffCommand } from './commands/diff.js';
|
|
21
20
|
import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
|
|
21
|
+
import { handleBtwCommand } from './commands/btw.js';
|
|
22
22
|
import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, } from './commands/model.js';
|
|
23
23
|
import { handleUnsetModelCommand } from './commands/unset-model.js';
|
|
24
24
|
import { handleLoginCommand, handleLoginSelect, handleLoginTextButton, handleLoginTextModalSubmit, handleLoginApiKeyButton, handleOAuthCodeButton, handleOAuthCodeModalSubmit, handleApiKeyModalSubmit, } from './commands/login.js';
|
|
@@ -36,7 +36,6 @@ import { handleRestartOpencodeServerCommand } from './commands/restart-opencode-
|
|
|
36
36
|
import { handleRunCommand } from './commands/run-command.js';
|
|
37
37
|
import { handleContextUsageCommand } from './commands/context-usage.js';
|
|
38
38
|
import { handleSessionIdCommand } from './commands/session-id.js';
|
|
39
|
-
import { handleMemorySnapshotCommand } from './commands/memory-snapshot.js';
|
|
40
39
|
import { handleUpgradeAndRestartCommand } from './commands/upgrade.js';
|
|
41
40
|
import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
|
|
42
41
|
import { handleScreenshareCommand, handleScreenshareStopCommand, } from './commands/screenshare.js';
|
|
@@ -120,12 +119,6 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
120
119
|
appId,
|
|
121
120
|
});
|
|
122
121
|
return;
|
|
123
|
-
case 'toggle-mention-mode':
|
|
124
|
-
await handleToggleMentionModeCommand({
|
|
125
|
-
command: interaction,
|
|
126
|
-
appId,
|
|
127
|
-
});
|
|
128
|
-
return;
|
|
129
122
|
case 'resume':
|
|
130
123
|
await handleResumeCommand({ command: interaction, appId });
|
|
131
124
|
return;
|
|
@@ -142,7 +135,6 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
142
135
|
});
|
|
143
136
|
return;
|
|
144
137
|
case 'abort':
|
|
145
|
-
case 'stop':
|
|
146
138
|
await handleAbortCommand({ command: interaction, appId });
|
|
147
139
|
return;
|
|
148
140
|
case 'compact':
|
|
@@ -157,6 +149,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
157
149
|
case 'fork':
|
|
158
150
|
await handleForkCommand(interaction);
|
|
159
151
|
return;
|
|
152
|
+
case 'btw':
|
|
153
|
+
await handleBtwCommand({ command: interaction, appId });
|
|
154
|
+
return;
|
|
160
155
|
case 'model':
|
|
161
156
|
await handleModelCommand({ interaction, appId });
|
|
162
157
|
return;
|
|
@@ -205,12 +200,6 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
205
200
|
case 'session-id':
|
|
206
201
|
await handleSessionIdCommand({ command: interaction, appId });
|
|
207
202
|
return;
|
|
208
|
-
case 'memory-snapshot':
|
|
209
|
-
await handleMemorySnapshotCommand({
|
|
210
|
-
command: interaction,
|
|
211
|
-
appId,
|
|
212
|
-
});
|
|
213
|
-
return;
|
|
214
203
|
case 'upgrade-and-restart':
|
|
215
204
|
await handleUpgradeAndRestartCommand({
|
|
216
205
|
command: interaction,
|