kimaki 0.4.73 → 0.4.74
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 +244 -2
- package/dist/cli.js +101 -27
- package/dist/commands/worktrees.js +1 -0
- package/dist/database.js +12 -34
- package/dist/db.js +1 -0
- package/dist/generated/internal/class.js +4 -4
- package/dist/generated/internal/prismaNamespace.js +2 -1
- package/dist/generated/internal/prismaNamespaceBrowser.js +2 -1
- package/dist/opencode-interrupt-plugin.js +70 -24
- package/dist/opencode-interrupt-plugin.test.js +150 -5
- package/dist/opencode-plugin.js +0 -149
- package/dist/opencode.js +0 -5
- package/dist/queue-advanced-abort.e2e.test.js +11 -15
- package/dist/store.js +0 -1
- package/dist/system-message.js +16 -0
- package/dist/thread-message-queue.e2e.test.js +0 -3
- package/dist/worktrees.js +13 -7
- package/package.json +5 -5
- package/schema.prisma +1 -0
- package/src/agent-model.e2e.test.ts +314 -2
- package/src/cli.ts +119 -28
- package/src/commands/worktrees.ts +1 -0
- package/src/database.ts +13 -34
- package/src/db.ts +1 -0
- package/src/generated/internal/class.ts +4 -4
- package/src/generated/internal/prismaNamespace.ts +2 -1
- package/src/generated/internal/prismaNamespaceBrowser.ts +2 -1
- package/src/generated/models/bot_tokens.ts +41 -1
- package/src/opencode-interrupt-plugin.test.ts +197 -3
- package/src/opencode-interrupt-plugin.ts +83 -26
- package/src/opencode-plugin.ts +5 -185
- package/src/opencode.ts +1 -7
- package/src/queue-advanced-abort.e2e.test.ts +12 -15
- package/src/schema.sql +2 -1
- package/src/store.ts +0 -7
- package/src/system-message.ts +16 -0
- package/src/thread-message-queue.e2e.test.ts +0 -3
- package/src/worktrees.ts +14 -10
|
@@ -14,7 +14,7 @@ import net from 'node:net';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import url from 'node:url';
|
|
16
16
|
import { describe, beforeAll, afterAll, test, expect, } from 'vitest';
|
|
17
|
-
import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
|
|
17
|
+
import { ChannelType, Client, GatewayIntentBits, Partials, REST, Routes, SlashCommandBuilder } from 'discord.js';
|
|
18
18
|
import { DigitalDiscord } from 'discord-digital-twin/src';
|
|
19
19
|
import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
|
|
20
20
|
import { setDataDir } from './config.js';
|
|
@@ -25,9 +25,11 @@ import { getPrisma } from './db.js';
|
|
|
25
25
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
26
26
|
import { initializeOpencodeForDirectory } from './opencode.js';
|
|
27
27
|
import { cleanupOpencodeServers, cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
28
|
+
import { buildQuickAgentCommandDescription } from './commands/agent.js';
|
|
28
29
|
const TEST_USER_ID = '200000000000000920';
|
|
29
30
|
const TEXT_CHANNEL_ID = '200000000000000921';
|
|
30
31
|
const AGENT_MODEL = 'agent-model-v2';
|
|
32
|
+
const PLAN_AGENT_MODEL = 'plan-model-v2';
|
|
31
33
|
const CHANNEL_MODEL = 'channel-model-v2';
|
|
32
34
|
const DEFAULT_MODEL = 'deterministic-v2';
|
|
33
35
|
const PROVIDER_NAME = 'deterministic-provider';
|
|
@@ -200,14 +202,20 @@ describe('agent model resolution', () => {
|
|
|
200
202
|
// Add extra models to the provider so opencode accepts them
|
|
201
203
|
const providerConfig = opencodeConfig.provider[PROVIDER_NAME];
|
|
202
204
|
providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL };
|
|
205
|
+
providerConfig.models[PLAN_AGENT_MODEL] = { name: PLAN_AGENT_MODEL };
|
|
203
206
|
providerConfig.models[CHANNEL_MODEL] = { name: CHANNEL_MODEL };
|
|
204
207
|
fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
|
|
205
|
-
// Create
|
|
208
|
+
// Create agent .md files with custom models
|
|
206
209
|
createAgentFile({
|
|
207
210
|
projectDirectory: directories.projectDirectory,
|
|
208
211
|
agentName: 'test-agent',
|
|
209
212
|
model: `${PROVIDER_NAME}/${AGENT_MODEL}`,
|
|
210
213
|
});
|
|
214
|
+
createAgentFile({
|
|
215
|
+
projectDirectory: directories.projectDirectory,
|
|
216
|
+
agentName: 'plan',
|
|
217
|
+
model: `${PROVIDER_NAME}/${PLAN_AGENT_MODEL}`,
|
|
218
|
+
});
|
|
211
219
|
const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
|
|
212
220
|
const hranaResult = await startHranaServer({ dbPath });
|
|
213
221
|
if (hranaResult instanceof Error) {
|
|
@@ -228,6 +236,20 @@ describe('agent model resolution', () => {
|
|
|
228
236
|
appId: discord.botUserId,
|
|
229
237
|
discordClient: botClient,
|
|
230
238
|
});
|
|
239
|
+
// Register quick agent slash commands so /plan-agent and /test-agent-agent
|
|
240
|
+
// are resolvable by handleQuickAgentCommand via guild.commands.fetch().
|
|
241
|
+
const agentCommands = ['test-agent', 'plan'].map((agentName) => {
|
|
242
|
+
return new SlashCommandBuilder()
|
|
243
|
+
.setName(`${agentName}-agent`)
|
|
244
|
+
.setDescription(buildQuickAgentCommandDescription({
|
|
245
|
+
agentName,
|
|
246
|
+
description: `Switch to ${agentName} agent`,
|
|
247
|
+
}))
|
|
248
|
+
.setDMPermission(false)
|
|
249
|
+
.toJSON();
|
|
250
|
+
});
|
|
251
|
+
const rest = new REST({ version: '10', api: discord.restUrl }).setToken(discord.botToken);
|
|
252
|
+
await rest.put(Routes.applicationGuildCommands(discord.botUserId, discord.guildId), { body: agentCommands });
|
|
231
253
|
// Pre-warm the opencode server so agent discovery happens
|
|
232
254
|
const warmup = await initializeOpencodeForDirectory(directories.projectDirectory);
|
|
233
255
|
if (warmup instanceof Error) {
|
|
@@ -449,4 +471,224 @@ describe('agent model resolution', () => {
|
|
|
449
471
|
expect(footerMessage.content).toContain(CHANNEL_MODEL);
|
|
450
472
|
expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
|
|
451
473
|
}, 15_000);
|
|
474
|
+
test('changing channel agent via /plan-agent does not affect existing thread model', async () => {
|
|
475
|
+
// 1. Set channel agent to test-agent (uses AGENT_MODEL)
|
|
476
|
+
await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
|
|
477
|
+
// 2. Send a message to create a thread
|
|
478
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
479
|
+
content: 'Reply with exactly: first-thread-msg',
|
|
480
|
+
});
|
|
481
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
482
|
+
timeout: 4_000,
|
|
483
|
+
predicate: (t) => {
|
|
484
|
+
return t.name === 'Reply with exactly: first-thread-msg';
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
// Wait for footer — proves first run completed with test-agent's model
|
|
488
|
+
await waitForFooterMessage({
|
|
489
|
+
discord,
|
|
490
|
+
threadId: thread.id,
|
|
491
|
+
timeout: 4_000,
|
|
492
|
+
afterMessageIncludes: 'ok',
|
|
493
|
+
afterAuthorId: discord.botUserId,
|
|
494
|
+
});
|
|
495
|
+
const firstMessages = await discord.thread(thread.id).getMessages();
|
|
496
|
+
const firstFooter = firstMessages.find((m) => {
|
|
497
|
+
return (m.author.id === discord.botUserId && m.content.startsWith('*'));
|
|
498
|
+
});
|
|
499
|
+
expect(firstFooter).toBeDefined();
|
|
500
|
+
// Verify the first run used test-agent's model
|
|
501
|
+
expect(firstFooter.content).toContain(AGENT_MODEL);
|
|
502
|
+
// 3. Switch channel agent to plan via /plan-agent in the CHANNEL
|
|
503
|
+
const { id: interactionId } = await discord
|
|
504
|
+
.channel(TEXT_CHANNEL_ID)
|
|
505
|
+
.user(TEST_USER_ID)
|
|
506
|
+
.runSlashCommand({ name: 'plan-agent' });
|
|
507
|
+
await discord
|
|
508
|
+
.channel(TEXT_CHANNEL_ID)
|
|
509
|
+
.waitForInteractionAck({ interactionId, timeout: 4_000 });
|
|
510
|
+
// 4. Send a second message in the EXISTING thread
|
|
511
|
+
const th = discord.thread(thread.id);
|
|
512
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
513
|
+
content: 'Reply with exactly: second-thread-msg',
|
|
514
|
+
});
|
|
515
|
+
// Wait for second footer (anchor on the user message, not bot reply)
|
|
516
|
+
await waitForFooterMessage({
|
|
517
|
+
discord,
|
|
518
|
+
threadId: thread.id,
|
|
519
|
+
timeout: 4_000,
|
|
520
|
+
afterMessageIncludes: 'second-thread-msg',
|
|
521
|
+
afterAuthorId: TEST_USER_ID,
|
|
522
|
+
});
|
|
523
|
+
expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
|
|
524
|
+
"--- from: user (agent-model-tester)
|
|
525
|
+
Reply with exactly: first-thread-msg
|
|
526
|
+
--- from: assistant (TestBot)
|
|
527
|
+
⬥ ok
|
|
528
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
529
|
+
--- from: user (agent-model-tester)
|
|
530
|
+
Reply with exactly: second-thread-msg
|
|
531
|
+
--- from: assistant (TestBot)
|
|
532
|
+
⬥ ok
|
|
533
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
|
|
534
|
+
`);
|
|
535
|
+
const secondMessages = await discord.thread(thread.id).getMessages();
|
|
536
|
+
const secondFooter = [...secondMessages]
|
|
537
|
+
.reverse()
|
|
538
|
+
.find((m) => {
|
|
539
|
+
return (m.author.id === discord.botUserId && m.content.startsWith('*'));
|
|
540
|
+
});
|
|
541
|
+
expect(secondFooter).toBeDefined();
|
|
542
|
+
// The existing thread should still use test-agent's model (AGENT_MODEL),
|
|
543
|
+
// NOT plan agent's model (PLAN_AGENT_MODEL)
|
|
544
|
+
expect(secondFooter.content).toContain(AGENT_MODEL);
|
|
545
|
+
expect(secondFooter.content).not.toContain(PLAN_AGENT_MODEL);
|
|
546
|
+
}, 20_000);
|
|
547
|
+
test('thread created with no agent keeps default model after channel agent is set', async () => {
|
|
548
|
+
// Clear any channel agent — thread starts with default (no agent)
|
|
549
|
+
const prisma = await getPrisma();
|
|
550
|
+
await prisma.channel_agents.deleteMany({
|
|
551
|
+
where: { channel_id: TEXT_CHANNEL_ID },
|
|
552
|
+
});
|
|
553
|
+
// Also clear channel model so we get the pure default
|
|
554
|
+
await prisma.channel_models.deleteMany({
|
|
555
|
+
where: { channel_id: TEXT_CHANNEL_ID },
|
|
556
|
+
});
|
|
557
|
+
// 1. Send a message to create a thread (no channel agent set)
|
|
558
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
559
|
+
content: 'Reply with exactly: default-thread-msg',
|
|
560
|
+
});
|
|
561
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
562
|
+
timeout: 4_000,
|
|
563
|
+
predicate: (t) => {
|
|
564
|
+
return t.name === 'Reply with exactly: default-thread-msg';
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
// Wait for footer — should show the default model
|
|
568
|
+
await waitForFooterMessage({
|
|
569
|
+
discord,
|
|
570
|
+
threadId: thread.id,
|
|
571
|
+
timeout: 4_000,
|
|
572
|
+
afterMessageIncludes: 'ok',
|
|
573
|
+
afterAuthorId: discord.botUserId,
|
|
574
|
+
});
|
|
575
|
+
const firstMessages = await discord.thread(thread.id).getMessages();
|
|
576
|
+
const firstFooter = firstMessages.find((m) => {
|
|
577
|
+
return (m.author.id === discord.botUserId && m.content.startsWith('*'));
|
|
578
|
+
});
|
|
579
|
+
expect(firstFooter).toBeDefined();
|
|
580
|
+
// First run uses the default model (no agent set)
|
|
581
|
+
expect(firstFooter.content).toContain(DEFAULT_MODEL);
|
|
582
|
+
expect(firstFooter.content).not.toContain(AGENT_MODEL);
|
|
583
|
+
// 2. Set channel agent to test-agent via /test-agent-agent in the CHANNEL
|
|
584
|
+
const { id: interactionId } = await discord
|
|
585
|
+
.channel(TEXT_CHANNEL_ID)
|
|
586
|
+
.user(TEST_USER_ID)
|
|
587
|
+
.runSlashCommand({ name: 'test-agent-agent' });
|
|
588
|
+
await discord
|
|
589
|
+
.channel(TEXT_CHANNEL_ID)
|
|
590
|
+
.waitForInteractionAck({ interactionId, timeout: 4_000 });
|
|
591
|
+
// 3. Send a second message in the EXISTING thread
|
|
592
|
+
await discord
|
|
593
|
+
.thread(thread.id)
|
|
594
|
+
.user(TEST_USER_ID)
|
|
595
|
+
.sendMessage({
|
|
596
|
+
content: 'Reply with exactly: default-second-msg',
|
|
597
|
+
});
|
|
598
|
+
await waitForFooterMessage({
|
|
599
|
+
discord,
|
|
600
|
+
threadId: thread.id,
|
|
601
|
+
timeout: 4_000,
|
|
602
|
+
afterMessageIncludes: 'default-second-msg',
|
|
603
|
+
afterAuthorId: TEST_USER_ID,
|
|
604
|
+
});
|
|
605
|
+
expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
|
|
606
|
+
"--- from: user (agent-model-tester)
|
|
607
|
+
Reply with exactly: default-thread-msg
|
|
608
|
+
--- from: assistant (TestBot)
|
|
609
|
+
⬥ ok
|
|
610
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
611
|
+
--- from: user (agent-model-tester)
|
|
612
|
+
Reply with exactly: default-second-msg
|
|
613
|
+
--- from: assistant (TestBot)
|
|
614
|
+
⬥ ok
|
|
615
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
616
|
+
`);
|
|
617
|
+
const secondMessages = await discord.thread(thread.id).getMessages();
|
|
618
|
+
const secondFooter = [...secondMessages]
|
|
619
|
+
.reverse()
|
|
620
|
+
.find((m) => {
|
|
621
|
+
return (m.author.id === discord.botUserId && m.content.startsWith('*'));
|
|
622
|
+
});
|
|
623
|
+
expect(secondFooter).toBeDefined();
|
|
624
|
+
// The existing thread should still use the DEFAULT model,
|
|
625
|
+
// NOT the test-agent's model (AGENT_MODEL)
|
|
626
|
+
expect(secondFooter.content).toContain(DEFAULT_MODEL);
|
|
627
|
+
expect(secondFooter.content).not.toContain(AGENT_MODEL);
|
|
628
|
+
}, 20_000);
|
|
629
|
+
test('/plan-agent inside a thread switches the model for that thread', async () => {
|
|
630
|
+
// 1. Start with test-agent on the channel
|
|
631
|
+
await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
|
|
632
|
+
// 2. Create a thread — first run uses test-agent's model
|
|
633
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
634
|
+
content: 'Reply with exactly: switch-in-thread-msg',
|
|
635
|
+
});
|
|
636
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
637
|
+
timeout: 4_000,
|
|
638
|
+
predicate: (t) => {
|
|
639
|
+
return t.name === 'Reply with exactly: switch-in-thread-msg';
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
await waitForFooterMessage({
|
|
643
|
+
discord,
|
|
644
|
+
threadId: thread.id,
|
|
645
|
+
timeout: 4_000,
|
|
646
|
+
afterMessageIncludes: 'ok',
|
|
647
|
+
afterAuthorId: discord.botUserId,
|
|
648
|
+
});
|
|
649
|
+
const firstFooter = (await discord.thread(thread.id).getMessages()).find((m) => {
|
|
650
|
+
return (m.author.id === discord.botUserId && m.content.startsWith('*'));
|
|
651
|
+
});
|
|
652
|
+
expect(firstFooter).toBeDefined();
|
|
653
|
+
expect(firstFooter.content).toContain(AGENT_MODEL);
|
|
654
|
+
// 3. Run /plan-agent INSIDE the thread
|
|
655
|
+
const th = discord.thread(thread.id);
|
|
656
|
+
const { id: interactionId } = await th
|
|
657
|
+
.user(TEST_USER_ID)
|
|
658
|
+
.runSlashCommand({ name: 'plan-agent' });
|
|
659
|
+
await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
|
|
660
|
+
// 4. Send a second message in the same thread
|
|
661
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
662
|
+
content: 'Reply with exactly: after-switch-msg',
|
|
663
|
+
});
|
|
664
|
+
await waitForFooterMessage({
|
|
665
|
+
discord,
|
|
666
|
+
threadId: thread.id,
|
|
667
|
+
timeout: 4_000,
|
|
668
|
+
afterMessageIncludes: 'after-switch-msg',
|
|
669
|
+
afterAuthorId: TEST_USER_ID,
|
|
670
|
+
});
|
|
671
|
+
expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
|
|
672
|
+
"--- from: user (agent-model-tester)
|
|
673
|
+
Reply with exactly: switch-in-thread-msg
|
|
674
|
+
--- from: assistant (TestBot)
|
|
675
|
+
⬥ ok
|
|
676
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
|
|
677
|
+
Switched to **plan** agent for this session (was **test-agent**)
|
|
678
|
+
--- from: user (agent-model-tester)
|
|
679
|
+
Reply with exactly: after-switch-msg
|
|
680
|
+
--- from: assistant (TestBot)
|
|
681
|
+
⬥ ok
|
|
682
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
|
|
683
|
+
`);
|
|
684
|
+
const secondFooter = [...(await discord.thread(thread.id).getMessages())]
|
|
685
|
+
.reverse()
|
|
686
|
+
.find((m) => {
|
|
687
|
+
return (m.author.id === discord.botUserId && m.content.startsWith('*'));
|
|
688
|
+
});
|
|
689
|
+
expect(secondFooter).toBeDefined();
|
|
690
|
+
// After /plan-agent in the thread, model should switch to plan's model
|
|
691
|
+
expect(secondFooter.content).toContain(PLAN_AGENT_MODEL);
|
|
692
|
+
expect(secondFooter.content).not.toContain(AGENT_MODEL);
|
|
693
|
+
}, 20_000);
|
|
452
694
|
});
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { goke } from 'goke';
|
|
|
6
6
|
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, select, spinner, } from '@clack/prompts';
|
|
7
7
|
import { deduplicateByKey, generateBotInstallUrl, generateDiscordInstallUrlForBot, KIMAKI_GATEWAY_APP_ID, KIMAKI_WEBSITE_URL, abbreviatePath, } from './utils.js';
|
|
8
8
|
import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, ensureKimakiCategory, createProjectChannels, } from './discord-bot.js';
|
|
9
|
-
import { getBotTokenWithMode, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getPrisma, createScheduledTask, listScheduledTasks, cancelScheduledTask, getSessionStartSourcesBySessionIds,
|
|
9
|
+
import { getBotTokenWithMode, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getPrisma, createScheduledTask, listScheduledTasks, cancelScheduledTask, getSessionStartSourcesBySessionIds, } from './database.js';
|
|
10
10
|
import { ShareMarkdown } from './markdown.js';
|
|
11
11
|
import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts, } from './session-search.js';
|
|
12
12
|
import { formatWorktreeName } from './commands/worktree.js';
|
|
@@ -85,22 +85,25 @@ function appIdFromToken(token) {
|
|
|
85
85
|
// In gateway mode, also sets store.discordBaseUrl so REST calls
|
|
86
86
|
// are routed through the gateway-proxy REST endpoint.
|
|
87
87
|
async function resolveBotCredentials({ appIdOverride } = {}) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const appId = appIdOverride || appIdFromToken(envToken);
|
|
93
|
-
return { token: envToken, appId };
|
|
94
|
-
}
|
|
88
|
+
// DB first: getBotTokenWithMode() sets store.discordBaseUrl which is
|
|
89
|
+
// required in gateway mode so REST calls route through the proxy.
|
|
90
|
+
// Without this, inherited KIMAKI_BOT_TOKEN (a gateway credential like
|
|
91
|
+
// clientId:clientSecret) would be sent directly to discord.com → 401.
|
|
95
92
|
const botRow = await getBotTokenWithMode().catch((e) => {
|
|
96
93
|
cliLogger.error('Database error:', e instanceof Error ? e.message : String(e));
|
|
97
94
|
return null;
|
|
98
95
|
});
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
if (botRow) {
|
|
97
|
+
return { token: botRow.token, appId: appIdOverride || botRow.appId };
|
|
98
|
+
}
|
|
99
|
+
// Fall back to env var for CI/headless deployments with no database
|
|
100
|
+
const envToken = process.env.KIMAKI_BOT_TOKEN;
|
|
101
|
+
if (envToken) {
|
|
102
|
+
const appId = appIdOverride || appIdFromToken(envToken);
|
|
103
|
+
return { token: envToken, appId };
|
|
102
104
|
}
|
|
103
|
-
|
|
105
|
+
cliLogger.error('No bot token found. Set KIMAKI_BOT_TOKEN env var or run `kimaki` first to set up.');
|
|
106
|
+
process.exit(EXIT_NO_RESTART);
|
|
104
107
|
}
|
|
105
108
|
function isThreadChannelType(type) {
|
|
106
109
|
return [
|
|
@@ -967,7 +970,6 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
|
|
|
967
970
|
startCaffeinate();
|
|
968
971
|
const forceRestartOnboarding = Boolean(restartOnboarding);
|
|
969
972
|
const forceGateway = Boolean(gateway);
|
|
970
|
-
store.setState({ preferGateway: forceGateway });
|
|
971
973
|
// Step 0: Ensure required CLI tools are installed (OpenCode + Bun)
|
|
972
974
|
await ensureCommandAvailable({
|
|
973
975
|
name: 'opencode',
|
|
@@ -1253,11 +1255,14 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
|
|
|
1253
1255
|
if (isGatewayMode) {
|
|
1254
1256
|
store.setState({ discordBaseUrl: KIMAKI_GATEWAY_PROXY_REST_BASE_URL });
|
|
1255
1257
|
}
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
await
|
|
1258
|
+
// Mark this bot as the most recently used so subcommands in separate
|
|
1259
|
+
// processes (send, upload-to-discord, project list) pick the correct bot.
|
|
1260
|
+
// getBotTokenWithMode() orders by last_used_at DESC as cross-process
|
|
1261
|
+
// source of truth.
|
|
1262
|
+
await (await getPrisma()).bot_tokens.update({
|
|
1263
|
+
where: { app_id: appId },
|
|
1264
|
+
data: { last_used_at: new Date() },
|
|
1265
|
+
});
|
|
1261
1266
|
const hasConfiguredTextChannels = Boolean(await (await getPrisma()).channel_directories.findFirst({
|
|
1262
1267
|
where: { channel_type: 'text' },
|
|
1263
1268
|
select: { channel_id: true },
|
|
@@ -2495,6 +2500,54 @@ cli
|
|
|
2495
2500
|
cliLogger.log(channelUrl);
|
|
2496
2501
|
process.exit(0);
|
|
2497
2502
|
});
|
|
2503
|
+
cli
|
|
2504
|
+
.command('user list', 'Search for Discord users in a guild/server. Returns user IDs for mentions.')
|
|
2505
|
+
.option('-g, --guild <guildId>', 'Discord guild/server ID (required)')
|
|
2506
|
+
.option('-q, --query [query]', 'Search query to filter users by name')
|
|
2507
|
+
.action(async (options) => {
|
|
2508
|
+
try {
|
|
2509
|
+
if (!options.guild) {
|
|
2510
|
+
cliLogger.error('Guild ID is required. Use --guild <guildId>');
|
|
2511
|
+
process.exit(EXIT_NO_RESTART);
|
|
2512
|
+
}
|
|
2513
|
+
const guildId = String(options.guild);
|
|
2514
|
+
await initDatabase();
|
|
2515
|
+
const { token: botToken } = await resolveBotCredentials();
|
|
2516
|
+
const rest = createDiscordRest(botToken);
|
|
2517
|
+
const members = await (async () => {
|
|
2518
|
+
if (options.query) {
|
|
2519
|
+
return (await rest.get(Routes.guildMembersSearch(guildId), {
|
|
2520
|
+
query: new URLSearchParams({ query: options.query, limit: '20' }),
|
|
2521
|
+
}));
|
|
2522
|
+
}
|
|
2523
|
+
return (await rest.get(Routes.guildMembers(guildId), {
|
|
2524
|
+
query: new URLSearchParams({ limit: '20' }),
|
|
2525
|
+
}));
|
|
2526
|
+
})();
|
|
2527
|
+
if (members.length === 0) {
|
|
2528
|
+
const msg = options.query
|
|
2529
|
+
? `No users found matching "${options.query}"`
|
|
2530
|
+
: 'No users found in guild';
|
|
2531
|
+
cliLogger.log(msg);
|
|
2532
|
+
process.exit(0);
|
|
2533
|
+
}
|
|
2534
|
+
const userList = members
|
|
2535
|
+
.map((m) => {
|
|
2536
|
+
const displayName = m.nick || m.user.global_name || m.user.username;
|
|
2537
|
+
return `- ${displayName} (ID: ${m.user.id}) - mention: <@${m.user.id}>`;
|
|
2538
|
+
})
|
|
2539
|
+
.join('\n');
|
|
2540
|
+
const header = options.query
|
|
2541
|
+
? `Found ${members.length} users matching "${options.query}":`
|
|
2542
|
+
: `Found ${members.length} users:`;
|
|
2543
|
+
console.log(`${header}\n${userList}`);
|
|
2544
|
+
process.exit(0);
|
|
2545
|
+
}
|
|
2546
|
+
catch (error) {
|
|
2547
|
+
cliLogger.error('Error:', error instanceof Error ? error.message : String(error));
|
|
2548
|
+
process.exit(EXIT_NO_RESTART);
|
|
2549
|
+
}
|
|
2550
|
+
});
|
|
2498
2551
|
cli
|
|
2499
2552
|
.command('tunnel', 'Expose a local port via tunnel')
|
|
2500
2553
|
.option('-p, --port <port>', 'Local port to expose (required)')
|
|
@@ -2888,18 +2941,39 @@ cli
|
|
|
2888
2941
|
process.exit(0);
|
|
2889
2942
|
});
|
|
2890
2943
|
cli
|
|
2891
|
-
.command('session archive
|
|
2892
|
-
.
|
|
2944
|
+
.command('session archive [threadId]', 'Archive a Discord thread and stop its mapped OpenCode session')
|
|
2945
|
+
.option('--session <sessionId>', 'Resolve thread from an OpenCode session ID')
|
|
2946
|
+
.action(async (threadIdArg, options) => {
|
|
2893
2947
|
try {
|
|
2894
2948
|
await initDatabase();
|
|
2949
|
+
// Resolve threadId from --session or positional arg
|
|
2950
|
+
if (threadIdArg && options.session) {
|
|
2951
|
+
cliLogger.error('Use either a thread ID or --session, not both');
|
|
2952
|
+
process.exit(EXIT_NO_RESTART);
|
|
2953
|
+
}
|
|
2954
|
+
const resolvedThreadId = await (async () => {
|
|
2955
|
+
if (threadIdArg) {
|
|
2956
|
+
return threadIdArg;
|
|
2957
|
+
}
|
|
2958
|
+
if (options.session) {
|
|
2959
|
+
const id = await getThreadIdBySessionId(options.session);
|
|
2960
|
+
if (!id) {
|
|
2961
|
+
cliLogger.error(`No Discord thread found for session: ${options.session}`);
|
|
2962
|
+
process.exit(EXIT_NO_RESTART);
|
|
2963
|
+
}
|
|
2964
|
+
return id;
|
|
2965
|
+
}
|
|
2966
|
+
cliLogger.error('Provide a thread ID or --session <sessionId>');
|
|
2967
|
+
process.exit(EXIT_NO_RESTART);
|
|
2968
|
+
})();
|
|
2895
2969
|
const { token: botToken } = await resolveBotCredentials();
|
|
2896
2970
|
const rest = createDiscordRest(botToken);
|
|
2897
|
-
const threadData = (await rest.get(Routes.channel(
|
|
2971
|
+
const threadData = (await rest.get(Routes.channel(resolvedThreadId)));
|
|
2898
2972
|
if (!isThreadChannelType(threadData.type)) {
|
|
2899
|
-
cliLogger.error(`Channel is not a thread: ${
|
|
2973
|
+
cliLogger.error(`Channel is not a thread: ${resolvedThreadId}`);
|
|
2900
2974
|
process.exit(EXIT_NO_RESTART);
|
|
2901
2975
|
}
|
|
2902
|
-
const sessionId = await getThreadSession(
|
|
2976
|
+
const sessionId = options.session || await getThreadSession(resolvedThreadId);
|
|
2903
2977
|
let client = null;
|
|
2904
2978
|
if (sessionId && threadData.parent_id) {
|
|
2905
2979
|
const channelConfig = await getChannelDirectory(threadData.parent_id);
|
|
@@ -2917,17 +2991,17 @@ cli
|
|
|
2917
2991
|
}
|
|
2918
2992
|
}
|
|
2919
2993
|
else {
|
|
2920
|
-
cliLogger.warn(`No mapped OpenCode session found for thread ${
|
|
2994
|
+
cliLogger.warn(`No mapped OpenCode session found for thread ${resolvedThreadId}`);
|
|
2921
2995
|
}
|
|
2922
2996
|
await archiveThread({
|
|
2923
2997
|
rest,
|
|
2924
|
-
threadId,
|
|
2998
|
+
threadId: resolvedThreadId,
|
|
2925
2999
|
parentChannelId: threadData.parent_id,
|
|
2926
3000
|
sessionId,
|
|
2927
3001
|
client,
|
|
2928
3002
|
});
|
|
2929
|
-
const threadLabel = threadData.name ||
|
|
2930
|
-
note(`Archived thread: ${threadLabel}\nThread ID: ${
|
|
3003
|
+
const threadLabel = threadData.name || resolvedThreadId;
|
|
3004
|
+
note(`Archived thread: ${threadLabel}\nThread ID: ${resolvedThreadId}`, '✅ Archived');
|
|
2931
3005
|
process.exit(0);
|
|
2932
3006
|
}
|
|
2933
3007
|
catch (error) {
|
|
@@ -101,6 +101,7 @@ export async function handleWorktreesCommand({ command, }) {
|
|
|
101
101
|
const prisma = await getPrisma();
|
|
102
102
|
const worktrees = await prisma.thread_worktrees.findMany({
|
|
103
103
|
orderBy: { created_at: 'desc' },
|
|
104
|
+
take: 10,
|
|
104
105
|
});
|
|
105
106
|
if (worktrees.length === 0) {
|
|
106
107
|
await command.editReply({ content: 'No worktrees found.' });
|
package/dist/database.js
CHANGED
|
@@ -788,11 +788,12 @@ export async function setPartMessagesBatch(partMappings) {
|
|
|
788
788
|
*
|
|
789
789
|
* Selection logic (when multiple bot rows exist):
|
|
790
790
|
* - If only one bot exists, use it regardless of mode.
|
|
791
|
-
* -
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
791
|
+
* - Picks the bot row with the most recent `last_used_at` timestamp, which is
|
|
792
|
+
* set by the `run()` command when the bot starts. This ensures subcommands
|
|
793
|
+
* in separate processes (send, project list, etc.) automatically use
|
|
794
|
+
* whichever bot mode (gateway or self-hosted) was last started.
|
|
795
|
+
* - Falls back to `created_at` ordering when no row has `last_used_at` set
|
|
796
|
+
* (backward compat for existing DBs before this column was added).
|
|
796
797
|
*
|
|
797
798
|
* For gateway mode, the token is derived from client_id:client_secret
|
|
798
799
|
* and REST routing is automatically enabled (idempotent env var set).
|
|
@@ -801,26 +802,16 @@ export async function setPartMessagesBatch(partMappings) {
|
|
|
801
802
|
*/
|
|
802
803
|
export async function getBotTokenWithMode() {
|
|
803
804
|
const prisma = await getPrisma();
|
|
805
|
+
// Pick the bot that was most recently started via run(). last_used_at is the
|
|
806
|
+
// cross-process source of truth — no in-memory flags needed.
|
|
807
|
+
// Fall back to created_at for DBs that predate the last_used_at column.
|
|
804
808
|
const allBots = await prisma.bot_tokens.findMany({
|
|
805
|
-
orderBy: { created_at: 'desc' },
|
|
809
|
+
orderBy: [{ last_used_at: 'desc' }, { created_at: 'desc' }],
|
|
806
810
|
});
|
|
807
|
-
const
|
|
808
|
-
if (!
|
|
811
|
+
const row = allBots[0];
|
|
812
|
+
if (!row) {
|
|
809
813
|
return undefined;
|
|
810
814
|
}
|
|
811
|
-
// If only one bot, use it. If multiple, prefer the mode matching store.preferGateway.
|
|
812
|
-
const row = (() => {
|
|
813
|
-
if (allBots.length === 1) {
|
|
814
|
-
return mostRecent;
|
|
815
|
-
}
|
|
816
|
-
const preferredMode = store.getState().preferGateway ? 'gateway' : 'self_hosted';
|
|
817
|
-
const match = allBots.find((b) => {
|
|
818
|
-
const m = b.bot_mode === 'gateway' ? 'gateway' : 'self_hosted';
|
|
819
|
-
return m === preferredMode;
|
|
820
|
-
});
|
|
821
|
-
// Fall back to most recent row if no row matches preferred mode
|
|
822
|
-
return match || mostRecent;
|
|
823
|
-
})();
|
|
824
815
|
const mode = row.bot_mode === 'gateway' ? 'gateway' : 'self_hosted';
|
|
825
816
|
const token = (mode === 'gateway' && row.client_id && row.client_secret)
|
|
826
817
|
? `${row.client_id}:${row.client_secret}`
|
|
@@ -870,19 +861,6 @@ export async function setBotMode({ appId, mode, clientId, clientSecret, proxyUrl
|
|
|
870
861
|
update: data,
|
|
871
862
|
});
|
|
872
863
|
}
|
|
873
|
-
/**
|
|
874
|
-
* Touch the bot_tokens row so it becomes the "most recent" via created_at.
|
|
875
|
-
* Called after credential resolution in run() so that subcommands in separate
|
|
876
|
-
* processes (send, upload-to-discord, project list, etc.) get the correct
|
|
877
|
-
* active bot from getBotTokenWithMode() which returns the most recent row.
|
|
878
|
-
*/
|
|
879
|
-
export async function touchBotTokenTimestamp(appId) {
|
|
880
|
-
const prisma = await getPrisma();
|
|
881
|
-
await prisma.bot_tokens.update({
|
|
882
|
-
where: { app_id: appId },
|
|
883
|
-
data: { created_at: new Date() },
|
|
884
|
-
});
|
|
885
|
-
}
|
|
886
864
|
// ============================================================================
|
|
887
865
|
// Bot API Keys Functions
|
|
888
866
|
// ============================================================================
|
package/dist/db.js
CHANGED
|
@@ -137,6 +137,7 @@ async function migrateSchema(prisma) {
|
|
|
137
137
|
'ALTER TABLE bot_tokens ADD COLUMN client_id TEXT',
|
|
138
138
|
'ALTER TABLE bot_tokens ADD COLUMN client_secret TEXT',
|
|
139
139
|
'ALTER TABLE bot_tokens ADD COLUMN proxy_url TEXT',
|
|
140
|
+
'ALTER TABLE bot_tokens ADD COLUMN last_used_at DATETIME',
|
|
140
141
|
];
|
|
141
142
|
for (const stmt of botTokenAlters) {
|
|
142
143
|
try {
|