@brianli/kimaki 0.4.72-brianli.1

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 (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
package/src/bin.ts ADDED
@@ -0,0 +1,108 @@
1
+ // Respawn wrapper for the kimaki bot process.
2
+ // When running the default command (no subcommand) with --auto-restart,
3
+ // spawns cli.js as a child process and restarts it on non-zero exit codes
4
+ // (crash, OOM kill, etc). Intentional exits (code 0 or EXIT_NO_RESTART=64)
5
+ // are not restarted.
6
+ //
7
+ // Subcommands (send, tunnel, project, etc.) run directly without the wrapper
8
+ // since they are short-lived and don't need crash recovery.
9
+ //
10
+ // When __KIMAKI_CHILD is set, we're the child process -- just run cli.js directly.
11
+ //
12
+ // V8 heap snapshot flags:
13
+ // Injects --heapsnapshot-near-heap-limit=3 and --diagnostic-dir so V8 writes
14
+ // heap snapshots internally as it approaches the heap limit. This catches OOM
15
+ // situations where SIGKILL (exit 137) would kill the process before our
16
+ // heap-monitor.ts polling can react. The polling monitor is kept as an early
17
+ // warning system at 85% usage; the V8 flag is the last-resort safety net.
18
+
19
+ import { spawn } from 'node:child_process'
20
+ import fs from 'node:fs'
21
+ import os from 'node:os'
22
+ import path from 'node:path'
23
+
24
+ const HEAP_SNAPSHOT_DIR = path.join(os.homedir(), '.kimaki', 'heap-snapshots')
25
+
26
+ // First arg after node + script is either a subcommand or a flag.
27
+ // If it doesn't start with '-', it's a subcommand (e.g. "send", "tunnel", "project").
28
+ const firstArg = process.argv[2]
29
+ const isSubcommand = firstArg && !firstArg.startsWith('-')
30
+ const hasAutoRestart = process.argv.includes('--auto-restart')
31
+
32
+ if (process.env.__KIMAKI_CHILD || isSubcommand || !hasAutoRestart) {
33
+ await import('./cli.js')
34
+ } else {
35
+ const EXIT_NO_RESTART = 64
36
+ const MAX_RAPID_RESTARTS = 5
37
+ const RAPID_RESTART_WINDOW_MS = 60_000
38
+ const RESTART_DELAY_MS = 2_000
39
+
40
+ const restartTimestamps: number[] = []
41
+ let child: ReturnType<typeof spawn> | null = null
42
+ // Track when we forwarded a termination signal so we don't restart after graceful shutdown
43
+ let shutdownRequested = false
44
+
45
+ function start() {
46
+ if (!fs.existsSync(HEAP_SNAPSHOT_DIR)) {
47
+ fs.mkdirSync(HEAP_SNAPSHOT_DIR, { recursive: true })
48
+ }
49
+ const heapArgs = [
50
+ `--heapsnapshot-near-heap-limit=3`,
51
+ `--diagnostic-dir=${HEAP_SNAPSHOT_DIR}`,
52
+ ]
53
+ child = spawn(
54
+ process.argv[0]!,
55
+ [...heapArgs, ...process.execArgv, ...process.argv.slice(1)],
56
+ {
57
+ stdio: 'inherit',
58
+ env: { ...process.env, __KIMAKI_CHILD: '1' },
59
+ },
60
+ )
61
+
62
+ child.on('exit', (code, signal) => {
63
+ if (code === 0 || code === EXIT_NO_RESTART || shutdownRequested) {
64
+ process.exit(code ?? 0)
65
+ return
66
+ }
67
+
68
+ const now = Date.now()
69
+ restartTimestamps.push(now)
70
+ while (
71
+ restartTimestamps.length > 0 &&
72
+ restartTimestamps[0]! < now - RAPID_RESTART_WINDOW_MS
73
+ ) {
74
+ restartTimestamps.shift()
75
+ }
76
+
77
+ if (restartTimestamps.length > MAX_RAPID_RESTARTS) {
78
+ console.error(
79
+ `[kimaki] Crash loop detected (${MAX_RAPID_RESTARTS} crashes in ${RAPID_RESTART_WINDOW_MS / 1000}s), exiting`,
80
+ )
81
+ process.exit(1)
82
+ return
83
+ }
84
+
85
+ const reason = signal ? `signal ${signal}` : `code ${code}`
86
+ console.error(
87
+ `[kimaki] Process exited with ${reason}, restarting in ${RESTART_DELAY_MS / 1000}s...`,
88
+ )
89
+ setTimeout(start, RESTART_DELAY_MS)
90
+ })
91
+ }
92
+
93
+ // Forward signals to child so graceful shutdown and heap snapshots work.
94
+ // SIGTERM/SIGINT mark shutdownRequested so we don't restart after graceful exit.
95
+ for (const sig of ['SIGTERM', 'SIGINT'] as const) {
96
+ process.on(sig, () => {
97
+ shutdownRequested = true
98
+ child?.kill(sig)
99
+ })
100
+ }
101
+ for (const sig of ['SIGUSR1', 'SIGUSR2'] as const) {
102
+ process.on(sig, () => {
103
+ child?.kill(sig)
104
+ })
105
+ }
106
+
107
+ start()
108
+ }
@@ -0,0 +1,171 @@
1
+ import crypto from 'node:crypto'
2
+ import { afterAll, beforeEach, describe, expect, test } from 'vitest'
3
+ import {
4
+ appIdFromToken,
5
+ getBotToken,
6
+ hydrateBotTokenCache,
7
+ isAuthModeEnabled,
8
+ } from './bot-token.js'
9
+
10
+ const ORIGINAL_BOT_TOKEN = process.env.KIMAKI_BOT_TOKEN
11
+ const ORIGINAL_GUILD_ID = process.env.KIMAKI_GUILD_ID
12
+ const ORIGINAL_PRIVATE_KEY = process.env.KIMAKI_PRIVATE_KEY
13
+ const ORIGINAL_APP_ID = process.env.KIMAKI_APP_ID
14
+
15
+ beforeEach(() => {
16
+ delete process.env.KIMAKI_BOT_TOKEN
17
+ delete process.env.KIMAKI_GUILD_ID
18
+ delete process.env.KIMAKI_PRIVATE_KEY
19
+ delete process.env.KIMAKI_APP_ID
20
+ hydrateBotTokenCache(null)
21
+ })
22
+
23
+ afterAll(() => {
24
+ process.env.KIMAKI_BOT_TOKEN = ORIGINAL_BOT_TOKEN
25
+ process.env.KIMAKI_GUILD_ID = ORIGINAL_GUILD_ID
26
+ process.env.KIMAKI_PRIVATE_KEY = ORIGINAL_PRIVATE_KEY
27
+ process.env.KIMAKI_APP_ID = ORIGINAL_APP_ID
28
+ })
29
+
30
+ describe('appIdFromToken', () => {
31
+ test('derives app id from valid token format', () => {
32
+ const token = 'MTQ3Njc0NTc2MzAwOTU5MzM2NQ.anything.anything'
33
+ expect(appIdFromToken(token)).toBe('1476745763009593365')
34
+ })
35
+
36
+ test('returns undefined for malformed tokens', () => {
37
+ expect(appIdFromToken('not-a-token')).toBeUndefined()
38
+ expect(appIdFromToken('')).toBeUndefined()
39
+ })
40
+ })
41
+
42
+ describe('getBotToken', () => {
43
+ test('prefers env token over db by default', () => {
44
+ process.env.KIMAKI_BOT_TOKEN =
45
+ 'MTQ3Njc0NTc2MzAwOTU5MzM2NQ.env.payload'
46
+ hydrateBotTokenCache({ app_id: 'db-app', token: 'db-token' })
47
+
48
+ const resolved = getBotToken()
49
+
50
+ expect(resolved).toEqual({
51
+ token: 'MTQ3Njc0NTc2MzAwOTU5MzM2NQ.env.payload',
52
+ appId: '1476745763009593365',
53
+ source: 'env',
54
+ })
55
+ })
56
+
57
+ test('uses db token when env is absent', () => {
58
+ delete process.env.KIMAKI_BOT_TOKEN
59
+ hydrateBotTokenCache({
60
+ app_id: '1476745763009593365',
61
+ token: 'db-token',
62
+ })
63
+
64
+ const resolved = getBotToken()
65
+
66
+ expect(resolved).toEqual({
67
+ token: 'db-token',
68
+ appId: '1476745763009593365',
69
+ source: 'db',
70
+ })
71
+ })
72
+
73
+ test('supports db-only lookup when preferEnv is false', () => {
74
+ process.env.KIMAKI_BOT_TOKEN = 'env-token'
75
+ hydrateBotTokenCache({
76
+ app_id: '1476745763009593365',
77
+ token: 'db-token',
78
+ })
79
+
80
+ const resolved = getBotToken({ preferEnv: false })
81
+
82
+ expect(resolved).toEqual({
83
+ token: 'db-token',
84
+ appId: '1476745763009593365',
85
+ source: 'db',
86
+ })
87
+ })
88
+
89
+ test('can skip db lookup entirely', () => {
90
+ delete process.env.KIMAKI_BOT_TOKEN
91
+ hydrateBotTokenCache({ app_id: 'x', token: 'y' })
92
+
93
+ const resolved = getBotToken({ allowDatabase: false })
94
+
95
+ expect(resolved).toBeUndefined()
96
+ })
97
+
98
+ test('applies appId override consistently', () => {
99
+ process.env.KIMAKI_BOT_TOKEN = 'env-token'
100
+ const fromEnv = getBotToken({
101
+ appIdOverride: 'override-app-id',
102
+ allowDatabase: false,
103
+ })
104
+ expect(fromEnv?.appId).toBe('override-app-id')
105
+
106
+ delete process.env.KIMAKI_BOT_TOKEN
107
+ hydrateBotTokenCache({
108
+ app_id: 'db-app',
109
+ token: 'db-token',
110
+ })
111
+ const fromDb = getBotToken({
112
+ appIdOverride: 'override-app-id',
113
+ })
114
+ expect(fromDb?.appId).toBe('override-app-id')
115
+ })
116
+
117
+ test('auth mode takes precedence over env and db token', () => {
118
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
119
+ process.env.KIMAKI_GUILD_ID = '1477130736841658398'
120
+ process.env.KIMAKI_APP_ID = '1476745763009593365'
121
+ process.env.KIMAKI_PRIVATE_KEY = privateKey
122
+ .export({ format: 'pem', type: 'pkcs8' })
123
+ .toString()
124
+ process.env.KIMAKI_BOT_TOKEN = 'env-token'
125
+ hydrateBotTokenCache({
126
+ app_id: 'db-app',
127
+ token: 'db-token',
128
+ })
129
+
130
+ const resolved = getBotToken()
131
+
132
+ expect(isAuthModeEnabled()).toBe(true)
133
+ expect(resolved?.source).toBe('auth')
134
+ expect(resolved?.appId).toBe('1476745763009593365')
135
+ expect(resolved?.token.split('.')).toHaveLength(3)
136
+
137
+ const tokenParts = resolved!.token.split('.')
138
+ expect(tokenParts).toHaveLength(3)
139
+ const guildPart = tokenParts[0]!
140
+ const timestamp = tokenParts[1]!
141
+ const signaturePart = tokenParts[2]!
142
+ const decodedGuildId = Buffer.from(guildPart, 'base64').toString('utf8')
143
+ expect(decodedGuildId).toBe('1477130736841658398')
144
+ expect(Number.isNaN(Number.parseInt(timestamp, 10))).toBe(false)
145
+
146
+ const signature = Buffer.from(signaturePart, 'base64url')
147
+ const verified = crypto.verify(
148
+ null,
149
+ Buffer.from(`${decodedGuildId}\n${timestamp}`, 'utf8'),
150
+ publicKey,
151
+ signature,
152
+ )
153
+ expect(verified).toBe(true)
154
+ })
155
+
156
+ test('auth mode requires all env vars', () => {
157
+ process.env.KIMAKI_GUILD_ID = '1477130736841658398'
158
+ delete process.env.KIMAKI_PRIVATE_KEY
159
+ process.env.KIMAKI_APP_ID = '1476745763009593365'
160
+ process.env.KIMAKI_BOT_TOKEN = 'env-token'
161
+
162
+ const resolved = getBotToken()
163
+
164
+ expect(isAuthModeEnabled()).toBe(false)
165
+ expect(resolved).toEqual({
166
+ token: 'env-token',
167
+ appId: undefined,
168
+ source: 'env',
169
+ })
170
+ })
171
+ })
@@ -0,0 +1,159 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ export type ResolvedBotToken = {
4
+ token: string
5
+ appId: string | undefined
6
+ source: 'auth' | 'env' | 'db'
7
+ }
8
+
9
+ export type StoredBotToken = { app_id: string; token: string }
10
+
11
+ type GetBotTokenOptions = {
12
+ appIdOverride?: string
13
+ preferEnv?: boolean
14
+ allowDatabase?: boolean
15
+ }
16
+
17
+ let dbBotToken: StoredBotToken | null = null
18
+
19
+ type AuthModeConfig = {
20
+ guildId: string
21
+ privateKey: string
22
+ appId: string
23
+ }
24
+
25
+ function toBase64(value: string): string {
26
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
27
+ const padding = '='.repeat((4 - (normalized.length % 4)) % 4)
28
+ return `${normalized}${padding}`
29
+ }
30
+
31
+ function resolveAuthModeConfig(): AuthModeConfig | null {
32
+ const guildId = process.env.KIMAKI_GUILD_ID?.trim()
33
+ const privateKey = process.env.KIMAKI_PRIVATE_KEY?.trim()
34
+ const appId = process.env.KIMAKI_APP_ID?.trim()
35
+ if (!guildId || !privateKey || !appId) {
36
+ return null
37
+ }
38
+ return {
39
+ guildId,
40
+ privateKey,
41
+ appId,
42
+ }
43
+ }
44
+
45
+ function parsePrivateKey(privateKeyValue: string): crypto.KeyObject {
46
+ if (privateKeyValue.includes('BEGIN PRIVATE KEY')) {
47
+ return crypto.createPrivateKey(privateKeyValue)
48
+ }
49
+
50
+ const candidates: Array<{ key: Buffer; format: 'der'; type: 'pkcs8' }> = []
51
+
52
+ try {
53
+ candidates.push({
54
+ key: Buffer.from(toBase64(privateKeyValue), 'base64'),
55
+ format: 'der',
56
+ type: 'pkcs8',
57
+ })
58
+ } catch {
59
+ // Ignore and continue to hex fallback.
60
+ }
61
+
62
+ try {
63
+ candidates.push({
64
+ key: Buffer.from(privateKeyValue, 'hex'),
65
+ format: 'der',
66
+ type: 'pkcs8',
67
+ })
68
+ } catch {
69
+ // Ignore and continue.
70
+ }
71
+
72
+ for (const candidate of candidates) {
73
+ try {
74
+ return crypto.createPrivateKey(candidate)
75
+ } catch {
76
+ // Try next candidate.
77
+ }
78
+ }
79
+
80
+ throw new Error('Invalid KIMAKI_PRIVATE_KEY for auth mode')
81
+ }
82
+
83
+ function createAuthModeToken(config: AuthModeConfig): string {
84
+ const timestamp = Date.now()
85
+ const key = parsePrivateKey(config.privateKey)
86
+ const message = `${config.guildId}\n${timestamp}`
87
+ const signature = crypto
88
+ .sign(null, Buffer.from(message, 'utf8'), key)
89
+ .toString('base64url')
90
+ const guildPart = Buffer.from(config.guildId, 'utf8').toString('base64')
91
+ return `${guildPart}.${timestamp}.${signature}`
92
+ }
93
+
94
+ // Derive the Discord Application ID from a bot token.
95
+ // Discord bot tokens have the format: base64(userId).timestamp.hmac
96
+ // The first segment is the bot's user ID (= Application ID) base64-encoded.
97
+ export function appIdFromToken(token: string): string | undefined {
98
+ const segment = token.split('.')[0]
99
+ if (!segment) {
100
+ return undefined
101
+ }
102
+ try {
103
+ const decoded = Buffer.from(segment, 'base64').toString('utf8')
104
+ if (/^\d{17,20}$/.test(decoded)) {
105
+ return decoded
106
+ }
107
+ return undefined
108
+ } catch {
109
+ return undefined
110
+ }
111
+ }
112
+
113
+ export function hydrateBotTokenCache(botToken: StoredBotToken | null): void {
114
+ dbBotToken = botToken
115
+ }
116
+
117
+ export function isAuthModeEnabled(): boolean {
118
+ return resolveAuthModeConfig() !== null
119
+ }
120
+
121
+ export function getBotToken(
122
+ options: GetBotTokenOptions = {},
123
+ ): ResolvedBotToken | undefined {
124
+ const { appIdOverride, preferEnv = true, allowDatabase = true } = options
125
+ const authMode = resolveAuthModeConfig()
126
+
127
+ if (authMode) {
128
+ return {
129
+ token: createAuthModeToken(authMode),
130
+ appId: appIdOverride || authMode.appId,
131
+ source: 'auth',
132
+ }
133
+ }
134
+
135
+ const envToken = process.env.KIMAKI_BOT_TOKEN
136
+
137
+ if (preferEnv && envToken) {
138
+ return {
139
+ token: envToken,
140
+ appId: appIdOverride || appIdFromToken(envToken),
141
+ source: 'env',
142
+ }
143
+ }
144
+
145
+ if (!allowDatabase) {
146
+ return undefined
147
+ }
148
+
149
+ const botRow = dbBotToken
150
+ if (!botRow) {
151
+ return undefined
152
+ }
153
+
154
+ return {
155
+ token: botRow.token,
156
+ appId: appIdOverride || botRow.app_id,
157
+ source: 'db',
158
+ }
159
+ }
@@ -0,0 +1,172 @@
1
+ // Discord channel and category management.
2
+ // Creates and manages Kimaki project channels (text + voice pairs),
3
+ // extracts channel metadata from topic tags, and ensures category structure.
4
+
5
+ import {
6
+ ChannelType,
7
+ type CategoryChannel,
8
+ type Guild,
9
+ type TextChannel,
10
+ } from 'discord.js'
11
+ import path from 'node:path'
12
+ import { getChannelDirectory, setChannelDirectory } from './database.js'
13
+
14
+ export async function ensureKimakiCategory(
15
+ guild: Guild,
16
+ botName?: string,
17
+ ): Promise<CategoryChannel> {
18
+ // Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
19
+ const isKimakiBot = botName?.toLowerCase() === 'kimaki'
20
+ const categoryName = botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki'
21
+
22
+ const existingCategory = guild.channels.cache.find(
23
+ (channel): channel is CategoryChannel => {
24
+ if (channel.type !== ChannelType.GuildCategory) {
25
+ return false
26
+ }
27
+
28
+ return channel.name.toLowerCase() === categoryName.toLowerCase()
29
+ },
30
+ )
31
+
32
+ if (existingCategory) {
33
+ return existingCategory
34
+ }
35
+
36
+ return guild.channels.create({
37
+ name: categoryName,
38
+ type: ChannelType.GuildCategory,
39
+ })
40
+ }
41
+
42
+ export async function ensureKimakiAudioCategory(
43
+ guild: Guild,
44
+ botName?: string,
45
+ ): Promise<CategoryChannel> {
46
+ // Skip appending bot name if it's already "kimaki" to avoid "Kimaki Audio kimaki"
47
+ const isKimakiBot = botName?.toLowerCase() === 'kimaki'
48
+ const categoryName =
49
+ botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio'
50
+
51
+ const existingCategory = guild.channels.cache.find(
52
+ (channel): channel is CategoryChannel => {
53
+ if (channel.type !== ChannelType.GuildCategory) {
54
+ return false
55
+ }
56
+
57
+ return channel.name.toLowerCase() === categoryName.toLowerCase()
58
+ },
59
+ )
60
+
61
+ if (existingCategory) {
62
+ return existingCategory
63
+ }
64
+
65
+ return guild.channels.create({
66
+ name: categoryName,
67
+ type: ChannelType.GuildCategory,
68
+ })
69
+ }
70
+
71
+ export async function createProjectChannels({
72
+ guild,
73
+ projectDirectory,
74
+ appId,
75
+ botName,
76
+ enableVoiceChannels = false,
77
+ }: {
78
+ guild: Guild
79
+ projectDirectory: string
80
+ appId: string
81
+ botName?: string
82
+ enableVoiceChannels?: boolean
83
+ }): Promise<{
84
+ textChannelId: string
85
+ voiceChannelId: string | null
86
+ channelName: string
87
+ }> {
88
+ const baseName = path.basename(projectDirectory)
89
+ const channelName = `${baseName}`
90
+ .toLowerCase()
91
+ .replace(/[^a-z0-9-]/g, '-')
92
+ .slice(0, 100)
93
+
94
+ const kimakiCategory = await ensureKimakiCategory(guild, botName)
95
+
96
+ const textChannel = await guild.channels.create({
97
+ name: channelName,
98
+ type: ChannelType.GuildText,
99
+ parent: kimakiCategory,
100
+ // Channel configuration is stored in SQLite, not in the topic
101
+ })
102
+
103
+ await setChannelDirectory({
104
+ channelId: textChannel.id,
105
+ directory: projectDirectory,
106
+ channelType: 'text',
107
+ appId,
108
+ })
109
+
110
+ let voiceChannelId: string | null = null
111
+
112
+ if (enableVoiceChannels) {
113
+ const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName)
114
+
115
+ const voiceChannel = await guild.channels.create({
116
+ name: channelName,
117
+ type: ChannelType.GuildVoice,
118
+ parent: kimakiAudioCategory,
119
+ })
120
+
121
+ await setChannelDirectory({
122
+ channelId: voiceChannel.id,
123
+ directory: projectDirectory,
124
+ channelType: 'voice',
125
+ appId,
126
+ })
127
+
128
+ voiceChannelId = voiceChannel.id
129
+ }
130
+
131
+ return {
132
+ textChannelId: textChannel.id,
133
+ voiceChannelId,
134
+ channelName,
135
+ }
136
+ }
137
+
138
+ export type ChannelWithTags = {
139
+ id: string
140
+ name: string
141
+ description: string | null
142
+ kimakiDirectory?: string
143
+ kimakiApp?: string
144
+ }
145
+
146
+ export async function getChannelsWithDescriptions(
147
+ guild: Guild,
148
+ ): Promise<ChannelWithTags[]> {
149
+ const channels: ChannelWithTags[] = []
150
+
151
+ const textChannels = guild.channels.cache.filter((channel) =>
152
+ channel.isTextBased(),
153
+ )
154
+
155
+ for (const channel of textChannels.values()) {
156
+ const textChannel = channel as TextChannel
157
+ const description = textChannel.topic || null
158
+
159
+ // Get channel config from database instead of parsing XML from topic
160
+ const channelConfig = await getChannelDirectory(textChannel.id)
161
+
162
+ channels.push({
163
+ id: textChannel.id,
164
+ name: textChannel.name,
165
+ description,
166
+ kimakiDirectory: channelConfig?.directory,
167
+ kimakiApp: channelConfig?.appId || undefined,
168
+ })
169
+ }
170
+
171
+ return channels
172
+ }