@brianli/kimaki 0.4.72-brianli.5 → 0.4.73-brianli.2

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.
Files changed (119) hide show
  1. package/dist/agent-model.e2e.test.js +414 -0
  2. package/dist/bot-token.js +10 -1
  3. package/dist/cli.js +319 -92
  4. package/dist/commands/abort.js +27 -32
  5. package/dist/commands/action-buttons.js +11 -21
  6. package/dist/commands/create-new-project.js +11 -3
  7. package/dist/commands/login.js +50 -8
  8. package/dist/commands/merge-worktree.js +12 -25
  9. package/dist/commands/model.js +7 -9
  10. package/dist/commands/queue.js +62 -72
  11. package/dist/commands/restart-opencode-server.js +10 -47
  12. package/dist/commands/session.js +11 -3
  13. package/dist/commands/unset-model.js +6 -10
  14. package/dist/commands/user-command.js +27 -11
  15. package/dist/commands/verbosity.js +100 -35
  16. package/dist/config.js +16 -47
  17. package/dist/database.js +87 -9
  18. package/dist/db.js +18 -0
  19. package/dist/db.test.js +1 -40
  20. package/dist/discord-bot.js +127 -144
  21. package/dist/discord-urls.js +70 -0
  22. package/dist/discord-utils.js +2 -13
  23. package/dist/gateway-proxy.e2e.test.js +423 -0
  24. package/dist/generated/internal/class.js +2 -2
  25. package/dist/generated/internal/prismaNamespace.js +4 -0
  26. package/dist/generated/internal/prismaNamespaceBrowser.js +4 -0
  27. package/dist/interaction-handler.js +9 -7
  28. package/dist/kimaki-digital-twin.e2e.test.js +7 -11
  29. package/dist/logger.js +17 -2
  30. package/dist/markdown.test.js +215 -210
  31. package/dist/opencode-plugin.js +117 -8
  32. package/dist/opencode.js +17 -8
  33. package/dist/runtime-lifecycle.e2e.test.js +388 -0
  34. package/dist/session-handler/agent-utils.js +68 -0
  35. package/dist/session-handler/model-utils.js +125 -0
  36. package/dist/session-handler/opencode-session-event-log.js +86 -0
  37. package/dist/session-handler/state.js +43 -141
  38. package/dist/session-handler/state.test.js +52 -0
  39. package/dist/session-handler/thread-runtime-state.js +167 -0
  40. package/dist/session-handler/thread-session-runtime.js +2370 -0
  41. package/dist/session-handler.js +10 -1874
  42. package/dist/store.js +22 -0
  43. package/dist/system-message.js +7 -3
  44. package/dist/task-runner.js +2 -2
  45. package/dist/test-utils.js +214 -0
  46. package/dist/thread-message-queue.e2e.test.js +317 -325
  47. package/dist/thread-queue-advanced.e2e.test.js +671 -0
  48. package/dist/tools.js +2 -4
  49. package/dist/utils.js +10 -1
  50. package/dist/voice-handler.js +34 -6
  51. package/dist/voice-message.e2e.test.js +729 -0
  52. package/package.json +7 -6
  53. package/schema.prisma +7 -3
  54. package/skills/zustand-centralized-state/SKILL.md +426 -4
  55. package/src/agent-model.e2e.test.ts +542 -0
  56. package/src/cli.ts +388 -108
  57. package/src/commands/abort.ts +26 -38
  58. package/src/commands/action-buttons.ts +12 -27
  59. package/src/commands/create-new-project.ts +12 -4
  60. package/src/commands/login.ts +56 -10
  61. package/src/commands/merge-worktree.ts +14 -35
  62. package/src/commands/model.ts +7 -9
  63. package/src/commands/queue.ts +68 -92
  64. package/src/commands/restart-opencode-server.ts +10 -59
  65. package/src/commands/session.ts +11 -3
  66. package/src/commands/unset-model.ts +6 -10
  67. package/src/commands/user-command.ts +28 -12
  68. package/src/commands/verbosity.ts +123 -38
  69. package/src/config.ts +17 -72
  70. package/src/database.ts +122 -11
  71. package/src/db.test.ts +1 -51
  72. package/src/db.ts +18 -0
  73. package/src/discord-bot.ts +139 -160
  74. package/src/discord-urls.ts +76 -0
  75. package/src/discord-utils.ts +2 -18
  76. package/src/gateway-proxy.e2e.test.ts +567 -0
  77. package/src/generated/internal/class.ts +2 -2
  78. package/src/generated/internal/prismaNamespace.ts +4 -0
  79. package/src/generated/internal/prismaNamespaceBrowser.ts +4 -0
  80. package/src/generated/models/bot_tokens.ts +181 -1
  81. package/src/generated/models/channel_directories.ts +0 -4
  82. package/src/hrana-server.ts +1 -0
  83. package/src/interaction-handler.ts +15 -7
  84. package/src/kimaki-digital-twin.e2e.test.ts +8 -12
  85. package/src/logger.ts +20 -2
  86. package/src/markdown.test.ts +236 -287
  87. package/src/opencode-plugin.ts +134 -6
  88. package/src/opencode.ts +17 -8
  89. package/src/runtime-lifecycle.e2e.test.ts +485 -0
  90. package/src/schema.sql +4 -0
  91. package/src/session-handler/agent-utils.ts +98 -0
  92. package/src/session-handler/model-utils.ts +184 -0
  93. package/src/session-handler/opencode-session-event-log.ts +115 -0
  94. package/src/session-handler/state.test.ts +64 -0
  95. package/src/session-handler/state.ts +60 -209
  96. package/src/session-handler/thread-runtime-state.ts +383 -0
  97. package/src/session-handler/thread-session-runtime.ts +3083 -0
  98. package/src/session-handler.ts +16 -2668
  99. package/src/store.ts +117 -0
  100. package/src/system-message.ts +7 -3
  101. package/src/task-runner.ts +2 -2
  102. package/src/test-utils.ts +323 -0
  103. package/src/thread-message-queue.e2e.test.ts +381 -410
  104. package/src/thread-queue-advanced.e2e.test.ts +833 -0
  105. package/src/tools.ts +2 -4
  106. package/src/utils.ts +19 -1
  107. package/src/voice-handler.ts +40 -6
  108. package/src/voice-message.e2e.test.ts +907 -0
  109. package/src/__snapshots__/compact-session-context-no-system.md +0 -35
  110. package/src/__snapshots__/compact-session-context.md +0 -41
  111. package/src/__snapshots__/first-session-no-info.md +0 -17
  112. package/src/__snapshots__/first-session-with-info.md +0 -23
  113. package/src/__snapshots__/session-1.md +0 -17
  114. package/src/__snapshots__/session-2.md +0 -5871
  115. package/src/__snapshots__/session-3.md +0 -17
  116. package/src/__snapshots__/session-with-tools.md +0 -5871
  117. package/src/bot-token.test.ts +0 -171
  118. package/src/bot-token.ts +0 -159
  119. package/src/discord-api.ts +0 -35
@@ -0,0 +1,414 @@
1
+ // E2e test for agent model resolution in new threads.
2
+ // Reproduces a bug where /agent channel preference is ignored by the
3
+ // promptAsync path: submitViaOpencodeQueue only passes input.agent/input.model
4
+ // (undefined for normal Discord messages) instead of resolving channel agent
5
+ // preferences from DB like dispatchPrompt does.
6
+ //
7
+ // The test sets a channel agent with a custom model, sends a message,
8
+ // and verifies the footer contains the agent's model — not the default.
9
+ //
10
+ // Uses opencode-deterministic-provider (no real LLM calls).
11
+ // Poll timeouts: 4s max, 100ms interval.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import url from 'node:url';
15
+ import { describe, beforeAll, afterAll, beforeEach, onTestFailed, test, expect, } from 'vitest';
16
+ import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
17
+ import { DigitalDiscord } from 'discord-digital-twin/src';
18
+ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
19
+ import { setDataDir } from './config.js';
20
+ import { store } from './store.js';
21
+ import { startDiscordBot } from './discord-bot.js';
22
+ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, } from './database.js';
23
+ import { getPrisma } from './db.js';
24
+ import { startHranaServer, stopHranaServer } from './hrana-server.js';
25
+ import { initializeOpencodeForDirectory } from './opencode.js';
26
+ import { cleanupOpencodeServers, cleanupTestSessions, waitForBotMessageContaining, } from './test-utils.js';
27
+ import { getLogEntryCount, getLogEntriesSince } from './logger.js';
28
+ const TEST_USER_ID = '200000000000000920';
29
+ const TEXT_CHANNEL_ID = '200000000000000921';
30
+ const AGENT_MODEL = 'agent-model-v2';
31
+ const CHANNEL_MODEL = 'channel-model-v2';
32
+ const DEFAULT_MODEL = 'deterministic-v2';
33
+ const PROVIDER_NAME = 'deterministic-provider';
34
+ function createRunDirectories() {
35
+ const root = path.resolve(process.cwd(), 'tmp', 'agent-model-e2e');
36
+ fs.mkdirSync(root, { recursive: true });
37
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
38
+ const projectDirectory = path.join(root, 'project');
39
+ fs.mkdirSync(projectDirectory, { recursive: true });
40
+ return { root, dataDir, projectDirectory };
41
+ }
42
+ function chooseLockPort() {
43
+ return 53_000 + (Date.now() % 2_000);
44
+ }
45
+ function createDiscordJsClient({ restUrl }) {
46
+ return new Client({
47
+ intents: [
48
+ GatewayIntentBits.Guilds,
49
+ GatewayIntentBits.GuildMessages,
50
+ GatewayIntentBits.MessageContent,
51
+ GatewayIntentBits.GuildVoiceStates,
52
+ ],
53
+ partials: [
54
+ Partials.Channel,
55
+ Partials.Message,
56
+ Partials.User,
57
+ Partials.ThreadMember,
58
+ ],
59
+ rest: {
60
+ api: restUrl,
61
+ version: '10',
62
+ },
63
+ });
64
+ }
65
+ function createDeterministicMatchers() {
66
+ const systemContextMatcher = {
67
+ id: 'system-context-check',
68
+ priority: 20,
69
+ when: {
70
+ lastMessageRole: 'user',
71
+ latestUserTextIncludes: 'Reply with exactly: system-context-check',
72
+ rawPromptIncludes: `Current Discord user ID is: ${TEST_USER_ID}`,
73
+ },
74
+ then: {
75
+ parts: [
76
+ { type: 'stream-start', warnings: [] },
77
+ { type: 'text-start', id: 'system-context-reply' },
78
+ {
79
+ type: 'text-delta',
80
+ id: 'system-context-reply',
81
+ delta: 'system-context-ok',
82
+ },
83
+ { type: 'text-end', id: 'system-context-reply' },
84
+ {
85
+ type: 'finish',
86
+ finishReason: 'stop',
87
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
88
+ },
89
+ ],
90
+ partDelaysMs: [0, 100, 0, 0, 0],
91
+ },
92
+ };
93
+ const userReplyMatcher = {
94
+ id: 'user-reply',
95
+ priority: 10,
96
+ when: {
97
+ lastMessageRole: 'user',
98
+ latestUserTextIncludes: 'Reply with exactly:',
99
+ },
100
+ then: {
101
+ parts: [
102
+ { type: 'stream-start', warnings: [] },
103
+ { type: 'text-start', id: 'default-reply' },
104
+ { type: 'text-delta', id: 'default-reply', delta: 'ok' },
105
+ { type: 'text-end', id: 'default-reply' },
106
+ {
107
+ type: 'finish',
108
+ finishReason: 'stop',
109
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
110
+ },
111
+ ],
112
+ partDelaysMs: [0, 100, 0, 0, 0],
113
+ },
114
+ };
115
+ return [systemContextMatcher, userReplyMatcher];
116
+ }
117
+ /**
118
+ * Create an opencode agent .md file that uses a specific model.
119
+ * OpenCode discovers agents from .opencode/agent/*.md files.
120
+ */
121
+ function createAgentFile({ projectDirectory, agentName, model, }) {
122
+ const agentDir = path.join(projectDirectory, '.opencode', 'agent');
123
+ fs.mkdirSync(agentDir, { recursive: true });
124
+ const content = [
125
+ '---',
126
+ `model: ${model}`,
127
+ 'mode: primary',
128
+ `description: Test agent with custom model`,
129
+ '---',
130
+ '',
131
+ 'You are a test agent. Reply concisely.',
132
+ '',
133
+ ].join('\n');
134
+ fs.writeFileSync(path.join(agentDir, `${agentName}.md`), content);
135
+ }
136
+ describe('agent model resolution', () => {
137
+ let directories;
138
+ let discord;
139
+ let botClient;
140
+ let previousDefaultVerbosity = null;
141
+ let testStartTime = Date.now();
142
+ beforeAll(async () => {
143
+ testStartTime = Date.now();
144
+ directories = createRunDirectories();
145
+ const lockPort = chooseLockPort();
146
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
147
+ setDataDir(directories.dataDir);
148
+ previousDefaultVerbosity = store.getState().defaultVerbosity;
149
+ store.setState({ defaultVerbosity: 'tools-and-text' });
150
+ discord = new DigitalDiscord({
151
+ guild: {
152
+ name: 'Agent Model E2E Guild',
153
+ ownerId: TEST_USER_ID,
154
+ },
155
+ channels: [
156
+ {
157
+ id: TEXT_CHANNEL_ID,
158
+ name: 'agent-model-e2e',
159
+ type: ChannelType.GuildText,
160
+ },
161
+ ],
162
+ users: [
163
+ {
164
+ id: TEST_USER_ID,
165
+ username: 'agent-model-tester',
166
+ },
167
+ ],
168
+ });
169
+ await discord.start();
170
+ const providerNpm = url
171
+ .pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
172
+ .toString();
173
+ // Build base config with default model
174
+ const opencodeConfig = buildDeterministicOpencodeConfig({
175
+ providerName: PROVIDER_NAME,
176
+ providerNpm,
177
+ model: DEFAULT_MODEL,
178
+ smallModel: DEFAULT_MODEL,
179
+ settings: {
180
+ strict: false,
181
+ matchers: createDeterministicMatchers(),
182
+ },
183
+ });
184
+ // Add extra models to the provider so opencode accepts them
185
+ const providerConfig = opencodeConfig.provider[PROVIDER_NAME];
186
+ providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL };
187
+ providerConfig.models[CHANNEL_MODEL] = { name: CHANNEL_MODEL };
188
+ fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
189
+ // Create the agent .md file with custom model
190
+ createAgentFile({
191
+ projectDirectory: directories.projectDirectory,
192
+ agentName: 'test-agent',
193
+ model: `${PROVIDER_NAME}/${AGENT_MODEL}`,
194
+ });
195
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
196
+ const hranaResult = await startHranaServer({ dbPath });
197
+ if (hranaResult instanceof Error) {
198
+ throw hranaResult;
199
+ }
200
+ process.env['KIMAKI_DB_URL'] = hranaResult;
201
+ await initDatabase();
202
+ await setBotToken(discord.botUserId, discord.botToken);
203
+ await setChannelDirectory({
204
+ channelId: TEXT_CHANNEL_ID,
205
+ directory: directories.projectDirectory,
206
+ channelType: 'text',
207
+ appId: discord.botUserId,
208
+ });
209
+ await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools-and-text');
210
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl });
211
+ await startDiscordBot({
212
+ token: discord.botToken,
213
+ appId: discord.botUserId,
214
+ discordClient: botClient,
215
+ });
216
+ // Pre-warm the opencode server so agent discovery happens
217
+ const warmup = await initializeOpencodeForDirectory(directories.projectDirectory);
218
+ if (warmup instanceof Error) {
219
+ throw warmup;
220
+ }
221
+ }, 60_000);
222
+ afterAll(async () => {
223
+ if (directories) {
224
+ await cleanupTestSessions({
225
+ projectDirectory: directories.projectDirectory,
226
+ testStartTime,
227
+ });
228
+ }
229
+ if (botClient) {
230
+ botClient.destroy();
231
+ }
232
+ await cleanupOpencodeServers();
233
+ await Promise.all([
234
+ closeDatabase().catch(() => {
235
+ return;
236
+ }),
237
+ stopHranaServer().catch(() => {
238
+ return;
239
+ }),
240
+ discord?.stop().catch(() => {
241
+ return;
242
+ }),
243
+ ]);
244
+ delete process.env['KIMAKI_LOCK_PORT'];
245
+ delete process.env['KIMAKI_DB_URL'];
246
+ if (previousDefaultVerbosity) {
247
+ store.setState({ defaultVerbosity: previousDefaultVerbosity });
248
+ }
249
+ if (directories) {
250
+ fs.rmSync(directories.dataDir, { recursive: true, force: true });
251
+ }
252
+ }, 10_000);
253
+ let logStartIndex = 0;
254
+ beforeEach(() => {
255
+ logStartIndex = getLogEntryCount();
256
+ onTestFailed(() => {
257
+ const logs = getLogEntriesSince(logStartIndex);
258
+ if (logs.length > 0) {
259
+ console.error(`\n--- kimaki logs (${logs.length} lines) ---`);
260
+ console.error(logs.join(''));
261
+ console.error(`--- end ---\n`);
262
+ }
263
+ });
264
+ });
265
+ test('new thread uses agent model when channel agent is set', async () => {
266
+ // Set channel agent preference — this simulates /agent selecting test-agent
267
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
268
+ // Send a message to create a new thread
269
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
270
+ content: 'Reply with exactly: agent-model-check',
271
+ });
272
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
273
+ timeout: 4_000,
274
+ predicate: (t) => {
275
+ return t.name === 'Reply with exactly: agent-model-check';
276
+ },
277
+ });
278
+ // Wait for the footer (starts with *project) — proves run completed.
279
+ // Then assert which model ID appears in it.
280
+ await waitForBotMessageContaining({
281
+ discord,
282
+ threadId: thread.id,
283
+ userId: TEST_USER_ID,
284
+ text: '*project',
285
+ timeout: 4_000,
286
+ });
287
+ const messages = await discord.thread(thread.id).getMessages();
288
+ // Find the footer message (starts with * italic)
289
+ const footerMessage = messages.find((message) => {
290
+ return (message.author.id === discord.botUserId &&
291
+ message.content.startsWith('*'));
292
+ });
293
+ expect(footerMessage).toBeDefined();
294
+ if (!footerMessage) {
295
+ throw new Error(`Expected footer message but none found. Bot messages: ${messages
296
+ .filter((m) => m.author.id === discord.botUserId)
297
+ .map((m) => m.content.slice(0, 150))
298
+ .join(' | ')}`);
299
+ }
300
+ // The footer should contain the agent's model, not the default
301
+ expect(footerMessage.content).toContain(AGENT_MODEL);
302
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
303
+ }, 15_000);
304
+ test('promptAsync path includes rich system context', async () => {
305
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
306
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
307
+ content: 'Reply with exactly: system-context-check',
308
+ });
309
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
310
+ timeout: 4_000,
311
+ predicate: (t) => {
312
+ return t.name === 'Reply with exactly: system-context-check';
313
+ },
314
+ });
315
+ await waitForBotMessageContaining({
316
+ discord,
317
+ threadId: thread.id,
318
+ userId: TEST_USER_ID,
319
+ text: 'system-context-ok',
320
+ timeout: 4_000,
321
+ });
322
+ }, 15_000);
323
+ test('new thread uses channel model when channel model preference is set', async () => {
324
+ // Clear channel agent so model resolution falls through to channel model
325
+ const prisma = await getPrisma();
326
+ await prisma.channel_agents.deleteMany({
327
+ where: { channel_id: TEXT_CHANNEL_ID },
328
+ });
329
+ // Set channel model preference — simulates /model selecting a model at channel scope
330
+ await setChannelModel({
331
+ channelId: TEXT_CHANNEL_ID,
332
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
333
+ });
334
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
335
+ content: 'Reply with exactly: channel-model-check',
336
+ });
337
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
338
+ timeout: 4_000,
339
+ predicate: (t) => {
340
+ return t.name === 'Reply with exactly: channel-model-check';
341
+ },
342
+ });
343
+ await waitForBotMessageContaining({
344
+ discord,
345
+ threadId: thread.id,
346
+ userId: TEST_USER_ID,
347
+ text: '*project',
348
+ timeout: 4_000,
349
+ });
350
+ const messages = await discord.thread(thread.id).getMessages();
351
+ const footerMessage = messages.find((message) => {
352
+ return (message.author.id === discord.botUserId &&
353
+ message.content.startsWith('*'));
354
+ });
355
+ expect(footerMessage).toBeDefined();
356
+ if (!footerMessage) {
357
+ throw new Error(`Expected footer message but none found. Bot messages: ${messages
358
+ .filter((m) => m.author.id === discord.botUserId)
359
+ .map((m) => m.content.slice(0, 150))
360
+ .join(' | ')}`);
361
+ }
362
+ // Footer should contain the channel model, not the default
363
+ expect(footerMessage.content).toContain(CHANNEL_MODEL);
364
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
365
+ }, 15_000);
366
+ test('channel model with variant preference completes without error', async () => {
367
+ // Clear channel agent so model resolution falls through to channel model
368
+ const prisma = await getPrisma();
369
+ await prisma.channel_agents.deleteMany({
370
+ where: { channel_id: TEXT_CHANNEL_ID },
371
+ });
372
+ // Set channel model with a variant (thinking level)
373
+ // The deterministic provider doesn't support thinking, so the variant
374
+ // is resolved but silently dropped (no matching thinking values).
375
+ // This test verifies the variant cascade code path runs without crashing
376
+ // and the correct model still appears in the footer.
377
+ await setChannelModel({
378
+ channelId: TEXT_CHANNEL_ID,
379
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
380
+ variant: 'high',
381
+ });
382
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
383
+ content: 'Reply with exactly: variant-check',
384
+ });
385
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
386
+ timeout: 4_000,
387
+ predicate: (t) => {
388
+ return t.name === 'Reply with exactly: variant-check';
389
+ },
390
+ });
391
+ await waitForBotMessageContaining({
392
+ discord,
393
+ threadId: thread.id,
394
+ userId: TEST_USER_ID,
395
+ text: '*project',
396
+ timeout: 4_000,
397
+ });
398
+ const messages = await discord.thread(thread.id).getMessages();
399
+ const footerMessage = messages.find((message) => {
400
+ return (message.author.id === discord.botUserId &&
401
+ message.content.startsWith('*'));
402
+ });
403
+ expect(footerMessage).toBeDefined();
404
+ if (!footerMessage) {
405
+ throw new Error(`Expected footer message but none found. Bot messages: ${messages
406
+ .filter((m) => m.author.id === discord.botUserId)
407
+ .map((m) => m.content.slice(0, 150))
408
+ .join(' | ')}`);
409
+ }
410
+ // Footer should still contain the channel model (variant doesn't crash)
411
+ expect(footerMessage.content).toContain(CHANNEL_MODEL);
412
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
413
+ }, 15_000);
414
+ });
package/dist/bot-token.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
2
  let dbBotToken = null;
3
+ let cachedAuthKey = null;
3
4
  function toBase64(value) {
4
5
  const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
5
6
  const padding = '='.repeat((4 - (normalized.length % 4)) % 4);
@@ -53,9 +54,17 @@ function parsePrivateKey(privateKeyValue) {
53
54
  }
54
55
  throw new Error('Invalid KIMAKI_PRIVATE_KEY for auth mode');
55
56
  }
57
+ function getAuthModeKey(privateKeyValue) {
58
+ if (cachedAuthKey && cachedAuthKey.privateKey === privateKeyValue) {
59
+ return cachedAuthKey.key;
60
+ }
61
+ const key = parsePrivateKey(privateKeyValue);
62
+ cachedAuthKey = { privateKey: privateKeyValue, key };
63
+ return key;
64
+ }
56
65
  function createAuthModeToken(config) {
57
66
  const timestamp = Date.now();
58
- const key = parsePrivateKey(config.privateKey);
67
+ const key = getAuthModeKey(config.privateKey);
59
68
  const message = `${config.guildId}\n${timestamp}`;
60
69
  const signature = crypto
61
70
  .sign(null, Buffer.from(message, 'utf8'), key)