kimaki 0.4.73 → 0.4.75
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 +247 -5
- package/dist/channel-management.js +130 -1
- package/dist/cli.js +836 -440
- package/dist/commands/agent.js +6 -2
- package/dist/commands/ask-question.js +25 -1
- package/dist/commands/create-new-project.js +9 -3
- package/dist/commands/login.js +6 -1
- package/dist/commands/mcp.js +239 -0
- package/dist/commands/merge-worktree.js +60 -3
- package/dist/commands/model-variant.js +364 -0
- package/dist/commands/model.js +16 -7
- package/dist/commands/new-worktree.js +340 -0
- package/dist/commands/permissions.js +50 -5
- package/dist/commands/restart-opencode-server.js +7 -5
- package/dist/commands/stop-opencode-server.js +19 -18
- package/dist/commands/user-command.js +1 -1
- package/dist/commands/worktrees.js +254 -36
- package/dist/database.js +12 -34
- package/dist/db.js +1 -0
- package/dist/debounce-timeout.js +28 -0
- package/dist/discord-bot.js +19 -16
- package/dist/errors.js +1 -6
- package/dist/event-stream-real-capture.e2e.test.js +3 -2
- package/dist/format-tables.js +207 -27
- package/dist/format-tables.test.js +113 -4
- package/dist/gateway-proxy.e2e.test.js +3 -2
- 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/heap-monitor.js +38 -11
- package/dist/hrana-server.js +1 -2
- package/dist/html-actions.js +123 -0
- package/dist/html-actions.test.js +70 -0
- package/dist/html-components.js +117 -0
- package/dist/html-components.test.js +34 -0
- package/dist/interaction-handler.js +36 -11
- package/dist/kimaki-digital-twin.e2e.test.js +3 -2
- package/dist/logger.js +2 -0
- package/dist/markdown.test.js +9 -5
- package/dist/message-preprocessing.js +22 -9
- package/dist/onboarding-tutorial-plugin.js +73 -0
- package/dist/onboarding-tutorial.js +172 -0
- package/dist/onboarding-welcome.js +37 -0
- package/dist/opencode-interrupt-plugin.js +70 -24
- package/dist/opencode-interrupt-plugin.test.js +150 -5
- package/dist/opencode-plugin.js +39 -182
- package/dist/opencode.js +224 -167
- package/dist/queue-advanced-abort.e2e.test.js +11 -15
- package/dist/queue-advanced-e2e-setup.js +27 -3
- package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -0
- package/dist/queue-advanced-typing.e2e.test.js +97 -0
- package/dist/runtime-idle-sweeper.js +3 -12
- package/dist/runtime-lifecycle.e2e.test.js +61 -3
- package/dist/session-handler/event-stream-state.js +103 -163
- package/dist/session-handler/event-stream-state.test.js +320 -194
- package/dist/session-handler/thread-session-runtime.js +266 -208
- package/dist/store.js +0 -1
- package/dist/system-message.js +25 -6
- package/dist/test-utils.js +1 -14
- package/dist/thread-message-queue.e2e.test.js +4 -4
- package/dist/utils.js +4 -1
- package/dist/voice-message.e2e.test.js +114 -4
- package/dist/voice.js +2 -2
- package/dist/worktrees.js +110 -17
- package/package.json +7 -6
- package/schema.prisma +1 -0
- package/skills/errore/SKILL.md +18 -0
- package/skills/goke/SKILL.md +179 -8
- package/skills/npm-package/SKILL.md +92 -2
- package/skills/playwriter/SKILL.md +5 -1
- package/skills/zele/SKILL.md +103 -0
- package/src/agent-model.e2e.test.ts +316 -5
- package/src/channel-management.ts +169 -1
- package/src/cli.ts +1078 -556
- package/src/commands/agent.ts +6 -2
- package/src/commands/ask-question.ts +25 -1
- package/src/commands/create-new-project.ts +10 -3
- package/src/commands/login.ts +6 -1
- package/src/commands/mcp.ts +307 -0
- package/src/commands/merge-worktree.ts +83 -5
- package/src/commands/model-variant.ts +483 -0
- package/src/commands/model.ts +33 -23
- package/src/commands/{worktree.ts → new-worktree.ts} +84 -2
- package/src/commands/permissions.ts +55 -4
- package/src/commands/restart-opencode-server.ts +7 -9
- package/src/commands/user-command.ts +1 -1
- package/src/commands/worktrees.ts +355 -57
- package/src/database.ts +13 -34
- package/src/db.ts +1 -0
- package/src/debounce-timeout.ts +43 -0
- package/src/discord-bot.ts +20 -15
- package/src/errors.ts +1 -7
- package/src/event-stream-real-capture.e2e.test.ts +3 -2
- package/src/format-tables.test.ts +116 -4
- package/src/format-tables.ts +327 -27
- package/src/gateway-proxy.e2e.test.ts +2 -2
- 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/heap-monitor.ts +42 -11
- package/src/hrana-server.ts +1 -2
- package/src/html-actions.test.ts +87 -0
- package/src/html-actions.ts +174 -0
- package/src/html-components.test.ts +38 -0
- package/src/html-components.ts +181 -0
- package/src/interaction-handler.ts +55 -12
- package/src/kimaki-digital-twin.e2e.test.ts +3 -2
- package/src/logger.ts +2 -0
- package/src/markdown.test.ts +9 -5
- package/src/message-preprocessing.ts +24 -9
- package/src/onboarding-tutorial-plugin.ts +93 -0
- package/src/onboarding-tutorial.ts +176 -0
- package/src/onboarding-welcome.ts +49 -0
- package/src/opencode-interrupt-plugin.test.ts +197 -3
- package/src/opencode-interrupt-plugin.ts +83 -26
- package/src/opencode-plugin.ts +47 -220
- package/src/opencode.ts +289 -216
- package/src/queue-advanced-abort.e2e.test.ts +12 -15
- package/src/queue-advanced-e2e-setup.ts +27 -3
- package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -0
- package/src/queue-advanced-typing.e2e.test.ts +118 -0
- package/src/runtime-idle-sweeper.ts +2 -14
- package/src/runtime-lifecycle.e2e.test.ts +79 -3
- package/src/schema.sql +2 -1
- package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
- package/src/session-handler/event-stream-state.test.ts +350 -218
- package/src/session-handler/event-stream-state.ts +158 -215
- package/src/session-handler/thread-session-runtime.ts +321 -255
- package/src/store.ts +0 -7
- package/src/system-message.ts +27 -5
- package/src/test-utils.ts +0 -15
- package/src/thread-message-queue.e2e.test.ts +3 -4
- package/src/utils.ts +7 -0
- package/src/voice-message.e2e.test.ts +133 -4
- package/src/voice.ts +2 -2
- package/src/worktrees.ts +164 -18
- package/skills/goke/.prettierrc +0 -5
- package/skills/goke/CHANGELOG.md +0 -40
- package/skills/goke/LICENSE +0 -21
- package/skills/goke/README.md +0 -666
- package/skills/goke/package.json +0 -43
- package/skills/goke/src/__test__/coerce.test.ts +0 -411
- package/skills/goke/src/__test__/index.test.ts +0 -1805
- package/skills/goke/src/__test__/types.test-d.ts +0 -111
- package/skills/goke/src/coerce.ts +0 -547
- package/skills/goke/src/goke.ts +0 -1362
- package/skills/goke/src/index.ts +0 -16
- package/skills/goke/src/mri.ts +0 -164
- package/skills/goke/tsconfig.json +0 -15
- package/src/commands/stop-opencode-server.ts +0 -113
|
@@ -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';
|
|
@@ -23,11 +23,13 @@ import { startDiscordBot } from './discord-bot.js';
|
|
|
23
23
|
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, } from './database.js';
|
|
24
24
|
import { getPrisma } from './db.js';
|
|
25
25
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
26
|
-
import { initializeOpencodeForDirectory } from './opencode.js';
|
|
27
|
-
import {
|
|
26
|
+
import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
|
|
27
|
+
import { 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) {
|
|
@@ -244,7 +266,7 @@ describe('agent model resolution', () => {
|
|
|
244
266
|
if (botClient) {
|
|
245
267
|
botClient.destroy();
|
|
246
268
|
}
|
|
247
|
-
await
|
|
269
|
+
await stopOpencodeServer();
|
|
248
270
|
await Promise.all([
|
|
249
271
|
closeDatabase().catch(() => {
|
|
250
272
|
return;
|
|
@@ -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 next messages (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
|
});
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
// Creates and manages Kimaki project channels (text + voice pairs),
|
|
3
3
|
// extracts channel metadata from topic tags, and ensures category structure.
|
|
4
4
|
import { ChannelType, } from 'discord.js';
|
|
5
|
+
import fs from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
6
|
-
import { getChannelDirectory, setChannelDirectory } from './database.js';
|
|
7
|
+
import { getChannelDirectory, setChannelDirectory, findChannelsByDirectory, } from './database.js';
|
|
8
|
+
import { getProjectsDir } from './config.js';
|
|
9
|
+
import { execAsync } from './worktrees.js';
|
|
10
|
+
import { createLogger, LogPrefix } from './logger.js';
|
|
11
|
+
const logger = createLogger(LogPrefix.CHANNEL);
|
|
7
12
|
export async function ensureKimakiCategory(guild, botName) {
|
|
8
13
|
// Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
|
|
9
14
|
const isKimakiBot = botName?.toLowerCase() === 'kimaki';
|
|
@@ -96,3 +101,127 @@ export async function getChannelsWithDescriptions(guild) {
|
|
|
96
101
|
}
|
|
97
102
|
return channels;
|
|
98
103
|
}
|
|
104
|
+
const DEFAULT_GITIGNORE = `node_modules/
|
|
105
|
+
dist/
|
|
106
|
+
.env
|
|
107
|
+
.env.*
|
|
108
|
+
!.env.example
|
|
109
|
+
.DS_Store
|
|
110
|
+
tmp/
|
|
111
|
+
*.log
|
|
112
|
+
__pycache__/
|
|
113
|
+
*.pyc
|
|
114
|
+
.venv/
|
|
115
|
+
*.egg-info/
|
|
116
|
+
`;
|
|
117
|
+
const DEFAULT_CHANNEL_TOPIC = 'General channel for misc tasks with Kimaki. Not connected to a specific OpenCode project or repository.';
|
|
118
|
+
/**
|
|
119
|
+
* Create (or find) the default "kimaki" channel for general-purpose tasks.
|
|
120
|
+
* Channel name is "kimaki-{botName}" for self-hosted bots, "kimaki" for gateway.
|
|
121
|
+
* Directory is ~/.kimaki/projects/kimaki, git-initialized with a .gitignore.
|
|
122
|
+
*
|
|
123
|
+
* Idempotency: checks the database for an existing channel mapped to the
|
|
124
|
+
* kimaki projects directory. Also scans guild channels by name+category
|
|
125
|
+
* as a fallback for channels created before DB mapping existed.
|
|
126
|
+
*/
|
|
127
|
+
export async function createDefaultKimakiChannel({ guild, botName, appId, isGatewayMode, }) {
|
|
128
|
+
const projectDirectory = path.join(getProjectsDir(), 'kimaki');
|
|
129
|
+
// Ensure the default kimaki project directory exists before any DB mapping
|
|
130
|
+
// restoration or git setup. Custom data dirs may not have <dataDir>/projects
|
|
131
|
+
// created yet, and later writes assume the full path is present.
|
|
132
|
+
if (!fs.existsSync(projectDirectory)) {
|
|
133
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
134
|
+
logger.log(`Created default kimaki directory: ${projectDirectory}`);
|
|
135
|
+
}
|
|
136
|
+
// Hydrate guild channels from API so the cache scan is complete
|
|
137
|
+
try {
|
|
138
|
+
await guild.channels.fetch();
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
logger.warn(`Could not fetch guild channels for ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
142
|
+
}
|
|
143
|
+
// 1. Check database for existing channel mapped to this directory.
|
|
144
|
+
// Check ALL mappings (not just the first) since the same directory could
|
|
145
|
+
// have stale rows from deleted channels or other guilds.
|
|
146
|
+
const existingMappings = await findChannelsByDirectory({
|
|
147
|
+
directory: projectDirectory,
|
|
148
|
+
channelType: 'text',
|
|
149
|
+
});
|
|
150
|
+
const mappedChannelInGuild = existingMappings
|
|
151
|
+
.map((row) => guild.channels.cache.get(row.channel_id))
|
|
152
|
+
.find((ch) => ch?.type === ChannelType.GuildText);
|
|
153
|
+
if (mappedChannelInGuild) {
|
|
154
|
+
logger.log(`Default kimaki channel already exists: ${mappedChannelInGuild.id}`);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
// 2. Fallback: detect existing channel by name+category
|
|
158
|
+
const kimakiCategory = await ensureKimakiCategory(guild, botName);
|
|
159
|
+
const existingByName = guild.channels.cache.find((ch) => {
|
|
160
|
+
if (ch.type !== ChannelType.GuildText) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
if (ch.parentId !== kimakiCategory.id) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
return ch.name === 'kimaki' || ch.name.startsWith('kimaki-');
|
|
167
|
+
});
|
|
168
|
+
if (existingByName) {
|
|
169
|
+
logger.log(`Found existing default kimaki channel by name: ${existingByName.id}, restoring DB mapping`);
|
|
170
|
+
await setChannelDirectory({
|
|
171
|
+
channelId: existingByName.id,
|
|
172
|
+
directory: projectDirectory,
|
|
173
|
+
channelType: 'text',
|
|
174
|
+
skipIfExists: true,
|
|
175
|
+
});
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
// Git init — gracefully skip if git is not installed
|
|
179
|
+
const gitDir = path.join(projectDirectory, '.git');
|
|
180
|
+
if (!fs.existsSync(gitDir)) {
|
|
181
|
+
try {
|
|
182
|
+
await execAsync('git init', { cwd: projectDirectory, timeout: 10_000 });
|
|
183
|
+
logger.log(`Initialized git in: ${projectDirectory}`);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
logger.warn(`Could not initialize git in ${projectDirectory}: ${error instanceof Error ? error.message : String(error)}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Write .gitignore if it doesn't exist
|
|
190
|
+
const gitignorePath = path.join(projectDirectory, '.gitignore');
|
|
191
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
192
|
+
fs.writeFileSync(gitignorePath, DEFAULT_GITIGNORE);
|
|
193
|
+
}
|
|
194
|
+
// Channel name: "kimaki-{botName}" for self-hosted, "kimaki" for gateway
|
|
195
|
+
const channelName = (() => {
|
|
196
|
+
if (isGatewayMode || !botName) {
|
|
197
|
+
return 'kimaki';
|
|
198
|
+
}
|
|
199
|
+
const sanitized = botName
|
|
200
|
+
.toLowerCase()
|
|
201
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
202
|
+
.replace(/-+/g, '-')
|
|
203
|
+
.replace(/^-|-$/g, '');
|
|
204
|
+
if (!sanitized || sanitized === 'kimaki') {
|
|
205
|
+
return 'kimaki';
|
|
206
|
+
}
|
|
207
|
+
return `kimaki-${sanitized}`.slice(0, 100);
|
|
208
|
+
})();
|
|
209
|
+
const textChannel = await guild.channels.create({
|
|
210
|
+
name: channelName,
|
|
211
|
+
type: ChannelType.GuildText,
|
|
212
|
+
parent: kimakiCategory,
|
|
213
|
+
topic: DEFAULT_CHANNEL_TOPIC,
|
|
214
|
+
});
|
|
215
|
+
await setChannelDirectory({
|
|
216
|
+
channelId: textChannel.id,
|
|
217
|
+
directory: projectDirectory,
|
|
218
|
+
channelType: 'text',
|
|
219
|
+
});
|
|
220
|
+
logger.log(`Created default kimaki channel: #${channelName} (${textChannel.id})`);
|
|
221
|
+
return {
|
|
222
|
+
textChannel,
|
|
223
|
+
textChannelId: textChannel.id,
|
|
224
|
+
channelName,
|
|
225
|
+
projectDirectory,
|
|
226
|
+
};
|
|
227
|
+
}
|