kimaki 0.4.78 → 0.4.80

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 (90) hide show
  1. package/dist/anthropic-auth-plugin.js +628 -0
  2. package/dist/channel-management.js +2 -2
  3. package/dist/cli.js +316 -129
  4. package/dist/commands/action-buttons.js +1 -1
  5. package/dist/commands/login.js +634 -277
  6. package/dist/commands/model.js +91 -6
  7. package/dist/commands/paginated-select.js +57 -0
  8. package/dist/commands/resume.js +2 -2
  9. package/dist/commands/tasks.js +205 -0
  10. package/dist/commands/undo-redo.js +80 -18
  11. package/dist/context-awareness-plugin.js +347 -0
  12. package/dist/database.js +103 -7
  13. package/dist/db.js +39 -1
  14. package/dist/discord-bot.js +42 -19
  15. package/dist/discord-urls.js +11 -0
  16. package/dist/discord-ws-proxy.js +350 -0
  17. package/dist/discord-ws-proxy.test.js +500 -0
  18. package/dist/errors.js +1 -1
  19. package/dist/gateway-session.js +163 -0
  20. package/dist/hrana-server.js +114 -4
  21. package/dist/interaction-handler.js +30 -7
  22. package/dist/ipc-tools-plugin.js +186 -0
  23. package/dist/message-preprocessing.js +56 -11
  24. package/dist/onboarding-welcome.js +1 -1
  25. package/dist/opencode-interrupt-plugin.js +133 -75
  26. package/dist/opencode-plugin.js +12 -389
  27. package/dist/opencode.js +59 -5
  28. package/dist/parse-permission-rules.test.js +117 -0
  29. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  30. package/dist/session-handler/thread-session-runtime.js +68 -29
  31. package/dist/startup-time.e2e.test.js +295 -0
  32. package/dist/store.js +1 -0
  33. package/dist/system-message.js +3 -1
  34. package/dist/task-runner.js +7 -3
  35. package/dist/task-schedule.js +12 -0
  36. package/dist/thread-message-queue.e2e.test.js +13 -1
  37. package/dist/undo-redo.e2e.test.js +166 -0
  38. package/dist/utils.js +4 -1
  39. package/dist/voice-attachment.js +34 -0
  40. package/dist/voice-handler.js +11 -9
  41. package/dist/voice-message.e2e.test.js +78 -0
  42. package/dist/voice.test.js +31 -0
  43. package/package.json +12 -7
  44. package/skills/egaki/SKILL.md +80 -15
  45. package/skills/errore/SKILL.md +13 -0
  46. package/skills/lintcn/SKILL.md +749 -0
  47. package/skills/npm-package/SKILL.md +17 -3
  48. package/skills/spiceflow/SKILL.md +14 -0
  49. package/skills/zele/SKILL.md +9 -0
  50. package/src/anthropic-auth-plugin.ts +732 -0
  51. package/src/channel-management.ts +2 -2
  52. package/src/cli.ts +354 -132
  53. package/src/commands/action-buttons.ts +1 -0
  54. package/src/commands/login.ts +836 -337
  55. package/src/commands/model.ts +102 -7
  56. package/src/commands/paginated-select.ts +81 -0
  57. package/src/commands/resume.ts +6 -1
  58. package/src/commands/tasks.ts +293 -0
  59. package/src/commands/undo-redo.ts +87 -20
  60. package/src/context-awareness-plugin.ts +469 -0
  61. package/src/database.ts +138 -7
  62. package/src/db.ts +40 -1
  63. package/src/discord-bot.ts +46 -19
  64. package/src/discord-urls.ts +12 -0
  65. package/src/errors.ts +1 -1
  66. package/src/hrana-server.ts +124 -3
  67. package/src/interaction-handler.ts +41 -9
  68. package/src/ipc-tools-plugin.ts +228 -0
  69. package/src/message-preprocessing.ts +82 -11
  70. package/src/onboarding-welcome.ts +1 -1
  71. package/src/opencode-interrupt-plugin.ts +164 -91
  72. package/src/opencode-plugin.ts +13 -483
  73. package/src/opencode.ts +60 -5
  74. package/src/parse-permission-rules.test.ts +127 -0
  75. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  76. package/src/session-handler/thread-runtime-state.ts +4 -1
  77. package/src/session-handler/thread-session-runtime.ts +82 -20
  78. package/src/startup-time.e2e.test.ts +372 -0
  79. package/src/store.ts +8 -0
  80. package/src/system-message.ts +10 -1
  81. package/src/task-runner.ts +9 -22
  82. package/src/task-schedule.ts +15 -0
  83. package/src/thread-message-queue.e2e.test.ts +14 -1
  84. package/src/undo-redo.e2e.test.ts +207 -0
  85. package/src/utils.ts +7 -0
  86. package/src/voice-attachment.ts +51 -0
  87. package/src/voice-handler.ts +15 -7
  88. package/src/voice-message.e2e.test.ts +95 -0
  89. package/src/voice.test.ts +36 -0
  90. package/src/onboarding-tutorial-plugin.ts +0 -93
@@ -0,0 +1,347 @@
1
+ // OpenCode plugin that injects synthetic message parts for context awareness:
2
+ // - Git branch / detached HEAD changes
3
+ // - Working directory (pwd) changes (e.g. after /new-worktree mid-session)
4
+ // - MEMORY.md table of contents on first message
5
+ // - Idle time gap detection with timestamps
6
+ // - Onboarding tutorial instructions (when TUTORIAL_WELCOME_TEXT detected)
7
+ //
8
+ // Synthetic parts are hidden from the TUI but sent to the model, keeping it
9
+ // aware of context changes without cluttering the UI.
10
+ //
11
+ // State design: all per-session mutable state is encapsulated in a single
12
+ // SessionState object per session ID. One Map, one delete() on cleanup.
13
+ // Decision logic is extracted into pure functions that take state + input
14
+ // and return whether to inject — making them testable without mocking.
15
+ //
16
+ // Exported from opencode-plugin.ts — each export is treated as a separate
17
+ // plugin by OpenCode's plugin loader.
18
+ import crypto from 'node:crypto';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import * as errore from 'errore';
22
+ import { createLogger, formatErrorWithStack, LogPrefix, setLogFilePath, } from './logger.js';
23
+ import { setDataDir } from './config.js';
24
+ import { initSentry, notifyError } from './sentry.js';
25
+ import { execAsync } from './worktrees.js';
26
+ import { condenseMemoryMd } from './condense-memory.js';
27
+ import { ONBOARDING_TUTORIAL_INSTRUCTIONS, TUTORIAL_WELCOME_TEXT, } from './onboarding-tutorial.js';
28
+ const logger = createLogger(LogPrefix.OPENCODE);
29
+ function createSessionState() {
30
+ return {
31
+ gitState: undefined,
32
+ lastMessageTime: undefined,
33
+ memoryInjected: false,
34
+ tutorialInjected: false,
35
+ resolvedDirectory: undefined,
36
+ announcedDirectory: undefined,
37
+ };
38
+ }
39
+ // ── Pure derivation functions ────────────────────────────────────
40
+ // These take state + fresh input and return whether to inject.
41
+ // No side effects, no mutations — easy to test with fixtures.
42
+ export function shouldInjectBranch({ previousGitState, currentGitState, }) {
43
+ if (!currentGitState) {
44
+ return { inject: false };
45
+ }
46
+ if (previousGitState && previousGitState.key === currentGitState.key) {
47
+ return { inject: false };
48
+ }
49
+ const text = currentGitState.warning || `\n[current git branch is ${currentGitState.label}]`;
50
+ return { inject: true, text };
51
+ }
52
+ export function shouldInjectPwd({ sessionDir, projectDir, announcedDir, }) {
53
+ if (!sessionDir || sessionDir === projectDir) {
54
+ return { inject: false };
55
+ }
56
+ if (announcedDir === sessionDir) {
57
+ return { inject: false };
58
+ }
59
+ return {
60
+ inject: true,
61
+ text: `\n[working directory is ${sessionDir} (git worktree of ${projectDir}). ` +
62
+ `All file reads, writes, and edits must use paths under ${sessionDir}, ` +
63
+ `not ${projectDir}.]`,
64
+ };
65
+ }
66
+ const TEN_MINUTES = 10 * 60 * 1000;
67
+ export function shouldInjectTimeGap({ lastMessageTime, now, }) {
68
+ if (!lastMessageTime) {
69
+ return { inject: false };
70
+ }
71
+ const elapsed = now - lastMessageTime;
72
+ if (elapsed < TEN_MINUTES) {
73
+ return { inject: false };
74
+ }
75
+ const totalMinutes = Math.floor(elapsed / 60_000);
76
+ const hours = Math.floor(totalMinutes / 60);
77
+ const minutes = totalMinutes % 60;
78
+ const elapsedStr = hours > 0 ? `${hours}h ${minutes}m` : `${totalMinutes}m`;
79
+ const utcStr = new Date(now)
80
+ .toISOString()
81
+ .replace('T', ' ')
82
+ .replace(/\.\d+Z$/, ' UTC');
83
+ const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
84
+ const localStr = new Date(now).toLocaleString('en-US', {
85
+ timeZone: localTz,
86
+ year: 'numeric',
87
+ month: '2-digit',
88
+ day: '2-digit',
89
+ hour: '2-digit',
90
+ minute: '2-digit',
91
+ hour12: false,
92
+ });
93
+ return { inject: true, elapsedStr, utcStr, localStr, localTz };
94
+ }
95
+ export function shouldInjectTutorial({ alreadyInjected, parts, }) {
96
+ if (alreadyInjected) {
97
+ return false;
98
+ }
99
+ return parts.some((part) => {
100
+ return part.type === 'text' && part.text?.includes(TUTORIAL_WELCOME_TEXT);
101
+ });
102
+ }
103
+ // ── Impure helpers (I/O) ─────────────────────────────────────────
104
+ async function resolveGitState({ directory, }) {
105
+ const branchResult = await errore.tryAsync(() => {
106
+ return execAsync('git symbolic-ref --short HEAD', { cwd: directory });
107
+ });
108
+ if (!(branchResult instanceof Error)) {
109
+ const branch = branchResult.stdout.trim();
110
+ if (branch) {
111
+ return {
112
+ key: `branch:${branch}`,
113
+ kind: 'branch',
114
+ label: branch,
115
+ warning: null,
116
+ };
117
+ }
118
+ }
119
+ const shaResult = await errore.tryAsync(() => {
120
+ return execAsync('git rev-parse --short HEAD', { cwd: directory });
121
+ });
122
+ if (shaResult instanceof Error) {
123
+ return null;
124
+ }
125
+ const shortSha = shaResult.stdout.trim();
126
+ if (!shortSha) {
127
+ return null;
128
+ }
129
+ const superprojectResult = await errore.tryAsync(() => {
130
+ return execAsync('git rev-parse --show-superproject-working-tree', {
131
+ cwd: directory,
132
+ });
133
+ });
134
+ const superproject = superprojectResult instanceof Error ? '' : superprojectResult.stdout.trim();
135
+ if (superproject) {
136
+ return {
137
+ key: `detached-submodule:${shortSha}`,
138
+ kind: 'detached-submodule',
139
+ label: `detached submodule @ ${shortSha}`,
140
+ warning: `\n[warning: submodule is in detached HEAD at ${shortSha}. ` +
141
+ 'create or switch to a branch before committing.]',
142
+ };
143
+ }
144
+ return {
145
+ key: `detached-head:${shortSha}`,
146
+ kind: 'detached-head',
147
+ label: `detached HEAD @ ${shortSha}`,
148
+ warning: `\n[warning: repository is in detached HEAD at ${shortSha}. ` +
149
+ 'create or switch to a branch before committing.]',
150
+ };
151
+ }
152
+ // Resolve the session's actual working directory via the SDK.
153
+ // Cached in SessionState.resolvedDirectory to avoid repeated HTTP calls.
154
+ async function resolveSessionDirectory({ client, sessionID, state, }) {
155
+ if (state.resolvedDirectory) {
156
+ return state.resolvedDirectory;
157
+ }
158
+ const result = await errore.tryAsync(() => {
159
+ return client.session.get({ path: { id: sessionID } });
160
+ });
161
+ if (result instanceof Error || !result.data?.directory) {
162
+ return null;
163
+ }
164
+ state.resolvedDirectory = result.data.directory;
165
+ return result.data.directory;
166
+ }
167
+ // ── Plugin ───────────────────────────────────────────────────────
168
+ const contextAwarenessPlugin = async ({ directory, client }) => {
169
+ initSentry();
170
+ const dataDir = process.env.KIMAKI_DATA_DIR;
171
+ if (dataDir) {
172
+ setDataDir(dataDir);
173
+ setLogFilePath(dataDir);
174
+ }
175
+ // Single Map for all per-session state. One entry per session, one
176
+ // delete on cleanup — no parallel Maps that can drift out of sync.
177
+ const sessions = new Map();
178
+ function getOrCreateSession(sessionID) {
179
+ const existing = sessions.get(sessionID);
180
+ if (existing) {
181
+ return existing;
182
+ }
183
+ const state = createSessionState();
184
+ sessions.set(sessionID, state);
185
+ return state;
186
+ }
187
+ return {
188
+ 'chat.message': async (input, output) => {
189
+ const hookResult = await errore.tryAsync({
190
+ try: async () => {
191
+ const { sessionID } = input;
192
+ const state = getOrCreateSession(sessionID);
193
+ // -- Onboarding tutorial injection --
194
+ // Runs before the non-synthetic text guard because the tutorial
195
+ // marker (TUTORIAL_WELCOME_TEXT) can appear in synthetic/system
196
+ // parts prepended by message-preprocessing.ts. The old separate
197
+ // plugin had no such guard, so this preserves that behavior.
198
+ const firstTextPart = output.parts.find((part) => {
199
+ return part.type === 'text';
200
+ });
201
+ if (firstTextPart && shouldInjectTutorial({ alreadyInjected: state.tutorialInjected, parts: output.parts })) {
202
+ state.tutorialInjected = true;
203
+ output.parts.push({
204
+ id: `prt_${crypto.randomUUID()}`,
205
+ sessionID,
206
+ messageID: firstTextPart.messageID,
207
+ type: 'text',
208
+ text: `<system-reminder>\n${ONBOARDING_TUTORIAL_INSTRUCTIONS}\n</system-reminder>`,
209
+ synthetic: true,
210
+ });
211
+ }
212
+ // -- Find first non-synthetic user text part --
213
+ // All remaining injections (branch, pwd, memory, time gap) only
214
+ // apply to real user messages, not empty or synthetic-only messages.
215
+ const now = Date.now();
216
+ const first = output.parts.find((part) => {
217
+ if (part.type !== 'text') {
218
+ return true;
219
+ }
220
+ return part.synthetic !== true;
221
+ });
222
+ if (!first || first.type !== 'text' || first.text.trim().length === 0) {
223
+ return;
224
+ }
225
+ const messageID = first.messageID;
226
+ // -- Resolve session working directory --
227
+ const sessionDir = await resolveSessionDirectory({
228
+ client,
229
+ sessionID,
230
+ state,
231
+ });
232
+ const effectiveDirectory = sessionDir || directory;
233
+ // -- Branch / detached HEAD detection --
234
+ // Resolved early but injected last so it appears at the end of parts.
235
+ const gitState = await resolveGitState({ directory: effectiveDirectory });
236
+ // -- Working directory change detection --
237
+ const pwdResult = shouldInjectPwd({
238
+ sessionDir,
239
+ projectDir: directory,
240
+ announcedDir: state.announcedDirectory,
241
+ });
242
+ if (pwdResult.inject) {
243
+ state.announcedDirectory = sessionDir;
244
+ output.parts.push({
245
+ id: `prt_${crypto.randomUUID()}`,
246
+ sessionID,
247
+ messageID,
248
+ type: 'text',
249
+ text: pwdResult.text,
250
+ synthetic: true,
251
+ });
252
+ }
253
+ // -- MEMORY.md injection --
254
+ if (!state.memoryInjected) {
255
+ state.memoryInjected = true;
256
+ const memoryPath = path.join(effectiveDirectory, 'MEMORY.md');
257
+ const memoryContent = await fs.promises
258
+ .readFile(memoryPath, 'utf-8')
259
+ .catch(() => null);
260
+ if (memoryContent) {
261
+ const condensed = condenseMemoryMd(memoryContent);
262
+ output.parts.push({
263
+ id: `prt_${crypto.randomUUID()}`,
264
+ sessionID,
265
+ messageID,
266
+ type: 'text',
267
+ text: `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, make headings detailed and descriptive since they are the only thing visible in this prompt. You can update MEMORY.md to store learnings, tips, insights that will help prevent same mistakes, and context worth preserving across sessions.</system-reminder>`,
268
+ synthetic: true,
269
+ });
270
+ }
271
+ }
272
+ // -- Time since last message --
273
+ const timeGapResult = shouldInjectTimeGap({
274
+ lastMessageTime: state.lastMessageTime,
275
+ now,
276
+ });
277
+ state.lastMessageTime = now;
278
+ if (timeGapResult.inject) {
279
+ output.parts.push({
280
+ id: `prt_${crypto.randomUUID()}`,
281
+ sessionID,
282
+ messageID,
283
+ type: 'text',
284
+ text: `[${timeGapResult.elapsedStr} since last message | UTC: ${timeGapResult.utcStr} | Local (${timeGapResult.localTz}): ${timeGapResult.localStr}]`,
285
+ synthetic: true,
286
+ });
287
+ output.parts.push({
288
+ id: `prt_${crypto.randomUUID()}`,
289
+ sessionID,
290
+ messageID,
291
+ type: 'text',
292
+ text: '<system-reminder>Long gap since last message. If the previous conversation had important learnings, tips, insights that will help prevent same mistakes, or context worth preserving, update MEMORY.md before starting the new task.</system-reminder>',
293
+ synthetic: true,
294
+ });
295
+ }
296
+ // -- Branch injection (last synthetic part) --
297
+ const branchResult = shouldInjectBranch({
298
+ previousGitState: state.gitState,
299
+ currentGitState: gitState,
300
+ });
301
+ if (branchResult.inject) {
302
+ state.gitState = gitState;
303
+ output.parts.push({
304
+ id: `prt_${crypto.randomUUID()}`,
305
+ sessionID,
306
+ messageID,
307
+ type: 'text',
308
+ text: branchResult.text,
309
+ synthetic: true,
310
+ });
311
+ }
312
+ },
313
+ catch: (error) => {
314
+ return new Error('context-awareness chat.message hook failed', { cause: error });
315
+ },
316
+ });
317
+ if (hookResult instanceof Error) {
318
+ logger.warn(`[context-awareness-plugin] ${formatErrorWithStack(hookResult)}`);
319
+ void notifyError(hookResult, 'context-awareness plugin chat.message hook failed');
320
+ }
321
+ },
322
+ // Clean up per-session state when sessions are deleted.
323
+ // Single delete instead of 5 parallel Map/Set deletes.
324
+ event: async ({ event }) => {
325
+ const cleanupResult = await errore.tryAsync({
326
+ try: async () => {
327
+ if (event.type !== 'session.deleted') {
328
+ return;
329
+ }
330
+ const id = event.properties?.info?.id;
331
+ if (!id) {
332
+ return;
333
+ }
334
+ sessions.delete(id);
335
+ },
336
+ catch: (error) => {
337
+ return new Error('context-awareness event hook failed', { cause: error });
338
+ },
339
+ });
340
+ if (cleanupResult instanceof Error) {
341
+ logger.warn(`[context-awareness-plugin] ${formatErrorWithStack(cleanupResult)}`);
342
+ void notifyError(cleanupResult, 'context-awareness plugin event hook failed');
343
+ }
344
+ },
345
+ };
346
+ };
347
+ export { contextAwarenessPlugin };
package/dist/database.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Stores thread-session mappings, bot tokens, channel directories,
3
3
  // API keys, and model preferences in <dataDir>/discord-sessions.db.
4
4
  import { getPrisma, closePrisma } from './db.js';
5
+ import crypto from 'node:crypto';
5
6
  import { store } from './store.js';
6
7
  import { createLogger, LogPrefix } from './logger.js';
7
8
  const dbLogger = createLogger(LogPrefix.DB);
@@ -88,6 +89,43 @@ export async function listScheduledTasks({ statuses, } = {}) {
88
89
  });
89
90
  return rows.map((row) => toScheduledTask(row));
90
91
  }
92
+ export async function getScheduledTask(taskId) {
93
+ const prisma = await getPrisma();
94
+ const row = await prisma.scheduled_tasks.findUnique({
95
+ where: { id: taskId },
96
+ });
97
+ return row ? toScheduledTask(row) : null;
98
+ }
99
+ export async function updateScheduledTask({ taskId, payloadJson, promptPreview, scheduleKind, runAt, cronExpr, timezone, nextRunAt, }) {
100
+ const prisma = await getPrisma();
101
+ const data = {
102
+ payload_json: payloadJson,
103
+ prompt_preview: promptPreview,
104
+ };
105
+ if (scheduleKind !== undefined) {
106
+ data.schedule_kind = scheduleKind;
107
+ }
108
+ if (runAt !== undefined) {
109
+ data.run_at = runAt;
110
+ }
111
+ if (cronExpr !== undefined) {
112
+ data.cron_expr = cronExpr;
113
+ }
114
+ if (timezone !== undefined) {
115
+ data.timezone = timezone;
116
+ }
117
+ if (nextRunAt !== undefined) {
118
+ data.next_run_at = nextRunAt;
119
+ }
120
+ const result = await prisma.scheduled_tasks.updateMany({
121
+ where: {
122
+ id: taskId,
123
+ status: 'planned',
124
+ },
125
+ data,
126
+ });
127
+ return result.count > 0;
128
+ }
91
129
  export async function cancelScheduledTask(taskId) {
92
130
  const prisma = await getPrisma();
93
131
  const result = await prisma.scheduled_tasks.updateMany({
@@ -812,9 +850,11 @@ export async function getBotTokenWithMode() {
812
850
  if (!row) {
813
851
  return undefined;
814
852
  }
853
+ const gatewayToken = await ensureServiceAuthToken({ appId: row.app_id });
854
+ const serviceParts = splitServiceAuthToken({ token: gatewayToken });
815
855
  const mode = row.bot_mode === 'gateway' ? 'gateway' : 'self_hosted';
816
- const token = (mode === 'gateway' && row.client_id && row.client_secret)
817
- ? `${row.client_id}:${row.client_secret}`
856
+ const token = (mode === 'gateway' && serviceParts)
857
+ ? gatewayToken
818
858
  : row.token;
819
859
  // Always reset discordBaseUrl on every read so a mode switch within
820
860
  // the same process (e.g. DB has gateway row but user proceeds self-hosted)
@@ -822,26 +862,77 @@ export async function getBotTokenWithMode() {
822
862
  const discordBaseUrl = (mode === 'gateway' && row.proxy_url)
823
863
  ? row.proxy_url
824
864
  : 'https://discord.com';
825
- store.setState({ discordBaseUrl });
865
+ store.setState({ discordBaseUrl, gatewayToken });
826
866
  return {
827
867
  appId: row.app_id,
828
868
  token,
869
+ gatewayToken,
829
870
  mode,
830
- clientId: row.client_id,
831
- clientSecret: row.client_secret,
871
+ clientId: serviceParts?.clientId || row.client_id,
872
+ clientSecret: serviceParts?.clientSecret || row.client_secret,
832
873
  proxyUrl: row.proxy_url,
833
874
  };
834
875
  }
876
+ function splitServiceAuthToken({ token }) {
877
+ const separatorIndex = token.indexOf(':');
878
+ if (separatorIndex <= 0 || separatorIndex >= token.length - 1) {
879
+ return null;
880
+ }
881
+ return {
882
+ clientId: token.slice(0, separatorIndex),
883
+ clientSecret: token.slice(separatorIndex + 1),
884
+ };
885
+ }
886
+ function createServiceCredentials() {
887
+ return {
888
+ clientId: crypto.randomUUID(),
889
+ clientSecret: crypto.randomBytes(32).toString('hex'),
890
+ };
891
+ }
892
+ export async function ensureServiceAuthToken({ appId, preferredGatewayToken, }) {
893
+ const prisma = await getPrisma();
894
+ const row = await prisma.bot_tokens.findUnique({
895
+ where: { app_id: appId },
896
+ });
897
+ if (!row) {
898
+ throw new Error(`Bot token row not found for app_id ${appId}`);
899
+ }
900
+ const preferred = preferredGatewayToken
901
+ ? splitServiceAuthToken({ token: preferredGatewayToken })
902
+ : null;
903
+ const existing = (row.client_id && row.client_secret)
904
+ ? { clientId: row.client_id, clientSecret: row.client_secret }
905
+ : null;
906
+ const fromStoredToken = splitServiceAuthToken({ token: row.token });
907
+ const resolved = preferred || existing || fromStoredToken || createServiceCredentials();
908
+ if (row.client_id !== resolved.clientId || row.client_secret !== resolved.clientSecret) {
909
+ await prisma.bot_tokens.update({
910
+ where: { app_id: appId },
911
+ data: {
912
+ client_id: resolved.clientId,
913
+ client_secret: resolved.clientSecret,
914
+ },
915
+ });
916
+ }
917
+ return `${resolved.clientId}:${resolved.clientSecret}`;
918
+ }
835
919
  /**
836
920
  * Store a bot token.
837
921
  */
838
922
  export async function setBotToken(appId, token) {
839
923
  const prisma = await getPrisma();
924
+ const generated = createServiceCredentials();
840
925
  await prisma.bot_tokens.upsert({
841
926
  where: { app_id: appId },
842
- create: { app_id: appId, token },
927
+ create: {
928
+ app_id: appId,
929
+ token,
930
+ client_id: generated.clientId,
931
+ client_secret: generated.clientSecret,
932
+ },
843
933
  update: { token },
844
934
  });
935
+ await ensureServiceAuthToken({ appId });
845
936
  }
846
937
  /**
847
938
  * Persist gateway bot mode credentials.
@@ -855,11 +946,16 @@ export async function setBotMode({ appId, mode, clientId, clientSecret, proxyUrl
855
946
  client_secret: clientSecret ?? null,
856
947
  proxy_url: proxyUrl ?? null,
857
948
  };
949
+ const createToken = (clientId && clientSecret) ? `${clientId}:${clientSecret}` : '';
858
950
  await prisma.bot_tokens.upsert({
859
951
  where: { app_id: appId },
860
- create: { app_id: appId, token: `${clientId}:${clientSecret}`, ...data },
952
+ create: { app_id: appId, token: createToken, ...data },
861
953
  update: data,
862
954
  });
955
+ await ensureServiceAuthToken({
956
+ appId,
957
+ preferredGatewayToken: (clientId && clientSecret) ? `${clientId}:${clientSecret}` : undefined,
958
+ });
863
959
  }
864
960
  // ============================================================================
865
961
  // Bot API Keys Functions
package/dist/db.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // otherwise falls back to direct file: access (bot process, CLI subcommands).
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import crypto from 'node:crypto';
6
7
  import { PrismaLibSql } from '@prisma/adapter-libsql';
7
8
  import { PrismaClient, Prisma } from './generated/client.js';
8
9
  import { getDataDir } from './config.js';
@@ -50,6 +51,13 @@ function getDbUrl() {
50
51
  const dbPath = path.join(dataDir, 'discord-sessions.db');
51
52
  return `file:${dbPath}`;
52
53
  }
54
+ function getDbAuthToken() {
55
+ const token = process.env.KIMAKI_DB_AUTH_TOKEN;
56
+ if (!token) {
57
+ return undefined;
58
+ }
59
+ return token;
60
+ }
53
61
  async function initializePrisma() {
54
62
  const dbUrl = getDbUrl();
55
63
  const isFileMode = dbUrl.startsWith('file:');
@@ -63,7 +71,11 @@ async function initializePrisma() {
63
71
  }
64
72
  }
65
73
  dbLogger.log(`Opening database via: ${dbUrl}`);
66
- const adapter = new PrismaLibSql({ url: dbUrl });
74
+ const dbAuthToken = getDbAuthToken();
75
+ const adapter = new PrismaLibSql({
76
+ url: dbUrl,
77
+ ...(dbAuthToken && { authToken: dbAuthToken }),
78
+ });
67
79
  const prisma = new PrismaClient({ adapter });
68
80
  try {
69
81
  if (isFileMode) {
@@ -193,6 +205,32 @@ async function migrateSchema(prisma) {
193
205
  // Table may not exist on first run
194
206
  }
195
207
  }
208
+ // Migration: ensure every bot row has service auth credentials.
209
+ // These credentials are used for local/internet control-plane auth.
210
+ try {
211
+ const botRows = await prisma.bot_tokens.findMany({
212
+ select: {
213
+ app_id: true,
214
+ client_id: true,
215
+ client_secret: true,
216
+ },
217
+ });
218
+ for (const botRow of botRows) {
219
+ if (botRow.client_id && botRow.client_secret) {
220
+ continue;
221
+ }
222
+ await prisma.bot_tokens.update({
223
+ where: { app_id: botRow.app_id },
224
+ data: {
225
+ client_id: crypto.randomUUID(),
226
+ client_secret: crypto.randomBytes(32).toString('hex'),
227
+ },
228
+ });
229
+ }
230
+ }
231
+ catch {
232
+ // Defensive migration only; ignore if table shape is not ready yet.
233
+ }
196
234
  }
197
235
  /**
198
236
  * Close the Prisma connection.