@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
@@ -0,0 +1,547 @@
1
+ // In-process HTTP server speaking the Hrana v2 protocol.
2
+ // Replaces the sqld child process (39MB Rust binary) with a lightweight
3
+ // server backed by the `libsql` npm package (better-sqlite3 API).
4
+ // Binds to the fixed lock port for single-instance enforcement.
5
+ //
6
+ // Serves POST /v2/pipeline (Hrana v2 JSON), GET /v2, and GET /health.
7
+ // The @libsql/client HTTP driver and @prisma/adapter-libsql connect here.
8
+ //
9
+ // Hrana v2 protocol spec ("Hrana over HTTP"):
10
+ // https://github.com/tursodatabase/libsql/blob/main/docs/HTTP_V2_SPEC.md
11
+ //
12
+ // The protocol exposes stateful streams over HTTP. Each stream corresponds
13
+ // to a SQLite connection. Requests on the same stream are tied together
14
+ // via a "baton" — the server returns a baton in every response, and the
15
+ // client includes it in the next request. Stream-scoped state includes
16
+ // SQL text cached via store_sql (referenced by sql_id in later stmts).
17
+ //
18
+ // Request types implemented:
19
+ // execute — run a single SQL statement, return cols/rows/changes
20
+ // batch — run multiple steps with conditional execution (ok/not/and/or)
21
+ // sequence — split raw SQL by semicolons, execute each (no results)
22
+ // store_sql — cache SQL text under a numeric sql_id for the stream
23
+ // close_sql — remove a cached sql_id
24
+ // close — close the stream (baton becomes null)
25
+ //
26
+ // Value encoding (SQLite → Hrana JSON):
27
+ // INTEGER → {"type":"integer","value":"42"} (string, not number)
28
+ // REAL → {"type":"float","value":3.14}
29
+ // TEXT → {"type":"text","value":"hello"}
30
+ // BLOB → {"type":"blob","base64":"..."}
31
+ // NULL → {"type":"null"}
32
+
33
+ import fs from 'node:fs'
34
+ import http from 'node:http'
35
+ import path from 'node:path'
36
+ import Database from 'libsql'
37
+ import * as errore from 'errore'
38
+ import { createLogger, LogPrefix } from './logger.js'
39
+ import { ServerStartError, FetchError } from './errors.js'
40
+ import { getLockPort } from './config.js'
41
+
42
+ const hranaLogger = createLogger(LogPrefix.DB)
43
+
44
+ let db: Database.Database | null = null
45
+ let server: http.Server | null = null
46
+ let hranaUrl: string | null = null
47
+
48
+ /**
49
+ * Get the Hrana HTTP URL for injecting into plugin child processes.
50
+ * Returns null if the server hasn't been started yet.
51
+ * Only used for KIMAKI_DB_URL env var in opencode.ts — the bot process
52
+ * itself always uses direct file: access via Prisma.
53
+ */
54
+ export function getHranaUrl(): string | null {
55
+ return hranaUrl
56
+ }
57
+
58
+ /**
59
+ * Start the in-process Hrana v2 server on the fixed lock port.
60
+ * Handles single-instance enforcement: if the port is occupied, kills the
61
+ * existing process first.
62
+ */
63
+ export async function startHranaServer({ dbPath }: { dbPath: string }) {
64
+ if (server && db && hranaUrl) return hranaUrl
65
+
66
+ const port = getLockPort()
67
+
68
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true })
69
+ await evictExistingInstance({ port })
70
+
71
+ hranaLogger.log(
72
+ `Starting hrana server on 127.0.0.1:${port} with db: ${dbPath}`,
73
+ )
74
+
75
+ const database = new Database(dbPath)
76
+ database.exec('PRAGMA journal_mode = WAL')
77
+ database.exec('PRAGMA busy_timeout = 5000')
78
+ db = database
79
+
80
+ const handler = createHranaHandler(database)
81
+
82
+ const started = await new Promise<ServerStartError | true>((resolve) => {
83
+ const srv = http.createServer(handler)
84
+ srv.on('error', (err: NodeJS.ErrnoException) => {
85
+ resolve(
86
+ new ServerStartError({
87
+ port,
88
+ reason:
89
+ err.code === 'EADDRINUSE'
90
+ ? `Port ${port} still in use after eviction`
91
+ : err.message,
92
+ }),
93
+ )
94
+ })
95
+ srv.listen(port, '127.0.0.1', () => {
96
+ server = srv
97
+ resolve(true)
98
+ })
99
+ })
100
+ if (started instanceof Error) {
101
+ database.close()
102
+ db = null
103
+ return started
104
+ }
105
+
106
+ hranaUrl = `http://127.0.0.1:${port}`
107
+ hranaLogger.log(`Hrana server ready at ${hranaUrl}`)
108
+ return hranaUrl
109
+ }
110
+
111
+ /**
112
+ * Stop the Hrana server and close the database.
113
+ */
114
+ export async function stopHranaServer() {
115
+ if (server) {
116
+ hranaLogger.log('Stopping hrana server...')
117
+ await new Promise<void>((resolve) => {
118
+ server!.close(() => {
119
+ resolve()
120
+ })
121
+ })
122
+ server = null
123
+ }
124
+ if (db) {
125
+ db.close()
126
+ db = null
127
+ }
128
+ hranaUrl = null
129
+ hranaLogger.log('Hrana server stopped')
130
+ }
131
+
132
+ // ── Hrana v2 protocol types ──────────────────────────────────────────────
133
+
134
+ type HranaValue =
135
+ | { type: 'null' }
136
+ | { type: 'integer'; value: string }
137
+ | { type: 'float'; value: number }
138
+ | { type: 'text'; value: string }
139
+ | { type: 'blob'; base64: string }
140
+
141
+ interface HranaStmt {
142
+ sql?: string
143
+ sql_id?: number
144
+ args?: HranaValue[]
145
+ named_args?: Array<{ name: string; value: HranaValue }>
146
+ want_rows?: boolean
147
+ }
148
+
149
+ interface HranaCondition {
150
+ type: 'ok' | 'not' | 'and' | 'or'
151
+ step?: number
152
+ cond?: HranaCondition
153
+ conds?: HranaCondition[]
154
+ }
155
+
156
+ interface HranaBatchStep {
157
+ stmt: HranaStmt
158
+ condition?: HranaCondition | null
159
+ }
160
+
161
+ interface HranaRequest {
162
+ type: string
163
+ stmt?: HranaStmt
164
+ batch?: { steps: HranaBatchStep[] }
165
+ sql?: string
166
+ sql_id?: number
167
+ }
168
+
169
+ interface HranaPipelineRequest {
170
+ baton: string | null
171
+ requests: HranaRequest[]
172
+ }
173
+
174
+ interface HranaColInfo {
175
+ name: string
176
+ decltype: string | null
177
+ }
178
+
179
+ interface HranaExecuteResult {
180
+ cols: HranaColInfo[]
181
+ rows: HranaValue[][]
182
+ affected_row_count: number
183
+ last_insert_rowid: string | null
184
+ }
185
+
186
+ // ── Value encoding/decoding ──────────────────────────────────────────────
187
+
188
+ function encodeValue(val: unknown): HranaValue {
189
+ if (val === null || val === undefined) return { type: 'null' }
190
+ if (typeof val === 'bigint') return { type: 'integer', value: val.toString() }
191
+ if (typeof val === 'number') {
192
+ if (Number.isInteger(val)) return { type: 'integer', value: val.toString() }
193
+ return { type: 'float', value: val }
194
+ }
195
+ if (typeof val === 'string') return { type: 'text', value: val }
196
+ if (Buffer.isBuffer(val))
197
+ return { type: 'blob', base64: val.toString('base64') }
198
+ if (val instanceof Uint8Array)
199
+ return { type: 'blob', base64: Buffer.from(val).toString('base64') }
200
+ return { type: 'text', value: String(val) }
201
+ }
202
+
203
+ function decodeValue(val: HranaValue): unknown {
204
+ if (val.type === 'null') return null
205
+ if (val.type === 'integer') {
206
+ const n = Number(val.value)
207
+ return Number.isSafeInteger(n) ? n : BigInt(val.value)
208
+ }
209
+ if (val.type === 'float') return val.value
210
+ if (val.type === 'text') return val.value
211
+ if (val.type === 'blob') return Buffer.from(val.base64, 'base64')
212
+ return null
213
+ }
214
+
215
+ // ── Statement execution ──────────────────────────────────────────────────
216
+
217
+ // SqliteError from libsql has a `code` property but catch gives Error.
218
+ function getSqliteErrorCode(err: Error): string {
219
+ return (err as unknown as { code?: string }).code ?? 'SQLITE_ERROR'
220
+ }
221
+
222
+ function resolveStmtSql(
223
+ stmt: HranaStmt,
224
+ sqlStore: Map<number, string>,
225
+ ): string {
226
+ if (stmt.sql != null) return stmt.sql
227
+ if (stmt.sql_id != null) return sqlStore.get(stmt.sql_id) ?? ''
228
+ return ''
229
+ }
230
+
231
+ function bindParams(stmt: HranaStmt): unknown[] {
232
+ if (stmt.named_args && stmt.named_args.length > 0) {
233
+ const named: Record<string, unknown> = {}
234
+ for (const na of stmt.named_args) {
235
+ named[na.name] = decodeValue(na.value)
236
+ }
237
+ return [named]
238
+ }
239
+ return (stmt.args ?? []).map(decodeValue)
240
+ }
241
+
242
+ function executeStmt(
243
+ database: Database.Database,
244
+ stmt: HranaStmt,
245
+ sqlStore: Map<number, string>,
246
+ ): HranaExecuteResult {
247
+ const sql = resolveStmtSql(stmt, sqlStore)
248
+ const prepared = database.prepare(sql)
249
+ const params = bindParams(stmt)
250
+
251
+ if (prepared.reader) {
252
+ const cols = prepared.columns()
253
+ const rows = prepared.all(...params) as Record<string, unknown>[]
254
+ return {
255
+ cols: cols.map((c) => ({ name: c.name, decltype: c.type })),
256
+ rows: rows.map((row) => cols.map((c) => encodeValue(row[c.name]))),
257
+ affected_row_count: 0,
258
+ last_insert_rowid: null,
259
+ }
260
+ }
261
+
262
+ const result = prepared.run(...params)
263
+ return {
264
+ cols: [],
265
+ rows: [],
266
+ affected_row_count: result.changes,
267
+ last_insert_rowid:
268
+ result.lastInsertRowid != null ? result.lastInsertRowid.toString() : null,
269
+ }
270
+ }
271
+
272
+ // ── Batch condition evaluation ───────────────────────────────────────────
273
+
274
+ function evaluateCondition(
275
+ cond: HranaCondition | null | undefined,
276
+ stepResults: Array<HranaExecuteResult | null>,
277
+ stepErrors: Array<{ message: string; code: string } | null>,
278
+ ): boolean {
279
+ if (!cond) return true
280
+ if (cond.type === 'ok')
281
+ return stepErrors[cond.step!] === null && stepResults[cond.step!] !== null
282
+ if (cond.type === 'not')
283
+ return !evaluateCondition(cond.cond, stepResults, stepErrors)
284
+ if (cond.type === 'and')
285
+ return (cond.conds ?? []).every((c) =>
286
+ evaluateCondition(c, stepResults, stepErrors),
287
+ )
288
+ if (cond.type === 'or')
289
+ return (cond.conds ?? []).some((c) =>
290
+ evaluateCondition(c, stepResults, stepErrors),
291
+ )
292
+ return true
293
+ }
294
+
295
+ // ── Request handlers ─────────────────────────────────────────────────────
296
+
297
+ function handleExecute(
298
+ database: Database.Database,
299
+ req: HranaRequest,
300
+ sqlStore: Map<number, string>,
301
+ ) {
302
+ if (!req.stmt)
303
+ return {
304
+ type: 'error' as const,
305
+ error: { message: 'Missing stmt', code: 'HRANA_PROTO_ERROR' },
306
+ }
307
+ const result = errore.try({
308
+ try: () => executeStmt(database, req.stmt!, sqlStore),
309
+ catch: (e) => e as Error,
310
+ })
311
+ if (result instanceof Error) {
312
+ return {
313
+ type: 'error' as const,
314
+ error: { message: result.message, code: getSqliteErrorCode(result) },
315
+ }
316
+ }
317
+ return { type: 'ok' as const, response: { type: 'execute', result } }
318
+ }
319
+
320
+ function handleBatch(
321
+ database: Database.Database,
322
+ req: HranaRequest,
323
+ sqlStore: Map<number, string>,
324
+ ) {
325
+ const steps = req.batch?.steps ?? []
326
+ const stepResults: Array<HranaExecuteResult | null> = []
327
+ const stepErrors: Array<{ message: string; code: string } | null> = []
328
+
329
+ for (const step of steps) {
330
+ if (!evaluateCondition(step.condition, stepResults, stepErrors)) {
331
+ stepResults.push(null)
332
+ stepErrors.push(null)
333
+ continue
334
+ }
335
+ const result = errore.try({
336
+ try: () => executeStmt(database, step.stmt, sqlStore),
337
+ catch: (e) => e as Error,
338
+ })
339
+ if (result instanceof Error) {
340
+ stepResults.push(null)
341
+ stepErrors.push({
342
+ message: result.message,
343
+ code: getSqliteErrorCode(result),
344
+ })
345
+ } else {
346
+ stepResults.push(result)
347
+ stepErrors.push(null)
348
+ }
349
+ }
350
+
351
+ return {
352
+ type: 'ok' as const,
353
+ response: {
354
+ type: 'batch',
355
+ result: { step_results: stepResults, step_errors: stepErrors },
356
+ },
357
+ }
358
+ }
359
+
360
+ function handleSequence(
361
+ database: Database.Database,
362
+ req: HranaRequest,
363
+ sqlStore: Map<number, string>,
364
+ ) {
365
+ const sql = req.sql ?? (req.sql_id != null ? sqlStore.get(req.sql_id) : null)
366
+ if (!sql) return { type: 'ok' as const, response: { type: 'sequence' } }
367
+ const result = errore.try({
368
+ try: () => {
369
+ database.exec(sql)
370
+ },
371
+ catch: (e) => e as Error,
372
+ })
373
+ if (result instanceof Error) {
374
+ return {
375
+ type: 'error' as const,
376
+ error: { message: result.message, code: getSqliteErrorCode(result) },
377
+ }
378
+ }
379
+ return { type: 'ok' as const, response: { type: 'sequence' } }
380
+ }
381
+
382
+ function processRequest(
383
+ database: Database.Database,
384
+ req: HranaRequest,
385
+ sqlStore: Map<number, string>,
386
+ ) {
387
+ if (req.type === 'execute') return handleExecute(database, req, sqlStore)
388
+ if (req.type === 'batch') return handleBatch(database, req, sqlStore)
389
+ if (req.type === 'sequence') return handleSequence(database, req, sqlStore)
390
+ if (req.type === 'close')
391
+ return { type: 'ok' as const, response: { type: 'close' } }
392
+ if (req.type === 'store_sql') {
393
+ if (req.sql_id != null && req.sql != null) sqlStore.set(req.sql_id, req.sql)
394
+ return { type: 'ok' as const, response: { type: 'store_sql' } }
395
+ }
396
+ if (req.type === 'close_sql') {
397
+ if (req.sql_id != null) sqlStore.delete(req.sql_id)
398
+ return { type: 'ok' as const, response: { type: 'close_sql' } }
399
+ }
400
+ return {
401
+ type: 'error' as const,
402
+ error: {
403
+ message: `Unknown request type: ${req.type}`,
404
+ code: 'HRANA_PROTO_ERROR',
405
+ },
406
+ }
407
+ }
408
+
409
+ // ── HTTP handler ─────────────────────────────────────────────────────────
410
+
411
+ // @libsql/client HTTP driver uses batons to keep streams alive across
412
+ // pipeline requests (needed for interactive transactions). Each stream has
413
+ // its own SQL store for store_sql/close_sql scoping.
414
+ let batonCounter = 0
415
+ const streamStores = new Map<string, Map<number, string>>()
416
+
417
+ export function createHranaHandler(
418
+ database: Database.Database,
419
+ ): http.RequestListener {
420
+ return (req, res) => {
421
+ if (req.method === 'GET' && req.url === '/health') {
422
+ res.writeHead(200, { 'content-type': 'application/json' })
423
+ res.end(JSON.stringify({ status: 'ok', pid: process.pid }))
424
+ return
425
+ }
426
+ if (req.method === 'GET' && req.url === '/v2') {
427
+ res.writeHead(200, { 'content-type': 'application/json' })
428
+ res.end('{"version":"hrana-v2"}')
429
+ return
430
+ }
431
+ if (req.method === 'POST' && req.url === '/v2/pipeline') {
432
+ const chunks: Buffer[] = []
433
+ let aborted = false
434
+ req.on('error', () => {
435
+ aborted = true
436
+ res.destroy()
437
+ })
438
+ req.on('data', (chunk: Buffer) => {
439
+ chunks.push(chunk)
440
+ })
441
+ req.on('end', () => {
442
+ if (aborted) return
443
+ const parseResult = errore.try({
444
+ try: () =>
445
+ JSON.parse(
446
+ Buffer.concat(chunks).toString(),
447
+ ) as HranaPipelineRequest,
448
+ catch: (e) => e as Error,
449
+ })
450
+ if (parseResult instanceof Error) {
451
+ res.writeHead(400, { 'content-type': 'application/json' })
452
+ res.end(
453
+ JSON.stringify({
454
+ error: {
455
+ message: parseResult.message,
456
+ code: 'HRANA_PROTO_ERROR',
457
+ },
458
+ }),
459
+ )
460
+ return
461
+ }
462
+
463
+ // Resolve or create per-stream SQL store keyed by baton
464
+ const incoming = parseResult.baton
465
+ const sqlStore =
466
+ (incoming ? streamStores.get(incoming) : undefined) ??
467
+ new Map<number, string>()
468
+ if (incoming) streamStores.delete(incoming)
469
+
470
+ const results = (parseResult.requests ?? []).map((r) =>
471
+ processRequest(database, r, sqlStore),
472
+ )
473
+ const hasClose = (parseResult.requests ?? []).some(
474
+ (r) => r.type === 'close',
475
+ )
476
+
477
+ const baton = hasClose ? null : `b${++batonCounter}`
478
+ if (baton) streamStores.set(baton, sqlStore)
479
+
480
+ res.writeHead(200, { 'content-type': 'application/json' })
481
+ res.end(JSON.stringify({ baton, base_url: null, results }))
482
+ })
483
+ return
484
+ }
485
+ res.writeHead(404)
486
+ res.end()
487
+ }
488
+ }
489
+
490
+ // ── Single-instance enforcement ──────────────────────────────────────────
491
+
492
+ /**
493
+ * Evict a previous kimaki instance on the lock port.
494
+ * Fetches /health to get the running process PID, then kills it directly.
495
+ * No lsof/netstat/spawnSync needed — the PID comes from the health response.
496
+ */
497
+ export async function evictExistingInstance({ port }: { port: number }) {
498
+ const url = `http://127.0.0.1:${port}/health`
499
+
500
+ const probe = await fetch(url, { signal: AbortSignal.timeout(1000) }).catch(
501
+ (e) => new FetchError({ url, cause: e }),
502
+ )
503
+ if (probe instanceof Error) return
504
+
505
+ const body = await (probe.json() as Promise<{ pid?: number }>).catch(
506
+ (e) => new FetchError({ url, cause: e }),
507
+ )
508
+ if (body instanceof Error) return
509
+
510
+ const targetPid = body.pid
511
+ if (!targetPid || targetPid === process.pid) return
512
+
513
+ hranaLogger.log(
514
+ `Evicting existing kimaki process (PID: ${targetPid}) on port ${port}`,
515
+ )
516
+ const killResult = errore.try({
517
+ try: () => {
518
+ process.kill(targetPid, 'SIGTERM')
519
+ },
520
+ catch: (e) => e as Error,
521
+ })
522
+ if (killResult instanceof Error) {
523
+ hranaLogger.log(`Failed to kill PID ${targetPid}: ${killResult.message}`)
524
+ return
525
+ }
526
+
527
+ await new Promise((resolve) => {
528
+ setTimeout(resolve, 1000)
529
+ })
530
+
531
+ // Verify it's gone — if still alive, escalate to SIGKILL
532
+ const secondProbe = await fetch(url, {
533
+ signal: AbortSignal.timeout(500),
534
+ }).catch((e) => new FetchError({ url, cause: e }))
535
+ if (secondProbe instanceof Error) return
536
+
537
+ hranaLogger.log(`PID ${targetPid} still alive after SIGTERM, sending SIGKILL`)
538
+ errore.try({
539
+ try: () => {
540
+ process.kill(targetPid, 'SIGKILL')
541
+ },
542
+ catch: (e) => e as Error,
543
+ })
544
+ await new Promise((resolve) => {
545
+ setTimeout(resolve, 1000)
546
+ })
547
+ }
@@ -0,0 +1,149 @@
1
+ // Image processing utilities for Discord attachments.
2
+ // Uses sharp (optional) to resize large images and heic-convert (optional) for HEIC support.
3
+ // Falls back gracefully if dependencies are not available.
4
+
5
+ import { createLogger, LogPrefix } from './logger.js'
6
+
7
+ const logger = createLogger(LogPrefix.FORMATTING)
8
+
9
+ const MAX_DIMENSION = 1500
10
+ const HEIC_MIME_TYPES = [
11
+ 'image/heic',
12
+ 'image/heif',
13
+ 'image/heic-sequence',
14
+ 'image/heif-sequence',
15
+ ]
16
+
17
+ type SharpModule = typeof import('sharp')
18
+ type HeicConvertFn = (options: {
19
+ buffer: ArrayBufferLike
20
+ format: 'JPEG' | 'PNG'
21
+ quality?: number
22
+ }) => Promise<ArrayBuffer>
23
+
24
+ let sharpModule: SharpModule | null | undefined = undefined
25
+ let heicConvertModule: HeicConvertFn | null | undefined = undefined
26
+
27
+ async function tryLoadSharp(): Promise<SharpModule | null> {
28
+ if (sharpModule !== undefined) {
29
+ return sharpModule
30
+ }
31
+ try {
32
+ sharpModule = (await import('sharp')).default as unknown as SharpModule
33
+ logger.log('sharp loaded successfully')
34
+ return sharpModule
35
+ } catch {
36
+ logger.log('sharp not available, images will be sent at original size')
37
+ sharpModule = null
38
+ return null
39
+ }
40
+ }
41
+
42
+ async function tryLoadHeicConvert(): Promise<HeicConvertFn | null> {
43
+ if (heicConvertModule !== undefined) {
44
+ return heicConvertModule
45
+ }
46
+ try {
47
+ const mod = await import('heic-convert')
48
+ heicConvertModule = mod.default as HeicConvertFn
49
+ logger.log('heic-convert loaded successfully')
50
+ return heicConvertModule
51
+ } catch {
52
+ logger.log('heic-convert not available, HEIC images will be sent as-is')
53
+ heicConvertModule = null
54
+ return null
55
+ }
56
+ }
57
+
58
+ function isHeicMime(mime: string): boolean {
59
+ return HEIC_MIME_TYPES.includes(mime.toLowerCase())
60
+ }
61
+
62
+ export async function processImage(
63
+ buffer: Buffer,
64
+ mime: string,
65
+ ): Promise<{ buffer: Buffer; mime: string }> {
66
+ // Skip non-images (PDFs, etc.)
67
+ if (!mime.startsWith('image/')) {
68
+ return { buffer, mime }
69
+ }
70
+
71
+ let workingBuffer = buffer
72
+ let workingMime = mime
73
+
74
+ // Handle HEIC conversion first (before sharp, since sharp doesn't support HEIC)
75
+ if (isHeicMime(mime)) {
76
+ const heicConvert = await tryLoadHeicConvert()
77
+ if (heicConvert) {
78
+ try {
79
+ const outputArrayBuffer = await heicConvert({
80
+ buffer: workingBuffer.buffer.slice(
81
+ workingBuffer.byteOffset,
82
+ workingBuffer.byteOffset + workingBuffer.byteLength,
83
+ ),
84
+ format: 'JPEG',
85
+ quality: 0.85,
86
+ })
87
+ workingBuffer = Buffer.from(outputArrayBuffer)
88
+ workingMime = 'image/jpeg'
89
+ logger.log(
90
+ `Converted HEIC to JPEG (${buffer.length} → ${workingBuffer.length} bytes)`,
91
+ )
92
+ } catch (error) {
93
+ logger.error('Failed to convert HEIC, sending original:', error)
94
+ return { buffer, mime }
95
+ }
96
+ } else {
97
+ // No heic-convert available, return original (LLM might not support it)
98
+ logger.log(
99
+ 'HEIC image detected but heic-convert not available, sending as-is',
100
+ )
101
+ return { buffer, mime }
102
+ }
103
+ }
104
+
105
+ // Now process with sharp (resize + ensure JPEG output)
106
+ const sharp = await tryLoadSharp()
107
+ if (!sharp) {
108
+ return { buffer: workingBuffer, mime: workingMime }
109
+ }
110
+
111
+ try {
112
+ const image = sharp(workingBuffer)
113
+ const metadata = await image.metadata()
114
+ const { width, height } = metadata
115
+
116
+ const needsResize =
117
+ width && height && (width > MAX_DIMENSION || height > MAX_DIMENSION)
118
+
119
+ if (!needsResize) {
120
+ // Still convert to JPEG for consistency (unless already JPEG from HEIC conversion)
121
+ const outputBuffer = await image.jpeg({ quality: 85 }).toBuffer()
122
+ logger.log(
123
+ `Converted image to JPEG: ${width}x${height} (${outputBuffer.length} bytes)`,
124
+ )
125
+ return { buffer: outputBuffer, mime: 'image/jpeg' }
126
+ }
127
+
128
+ // Resize and convert to JPEG
129
+ const outputBuffer = await image
130
+ .resize(MAX_DIMENSION, MAX_DIMENSION, {
131
+ fit: 'inside',
132
+ withoutEnlargement: true,
133
+ })
134
+ .jpeg({ quality: 85 })
135
+ .toBuffer()
136
+
137
+ logger.log(
138
+ `Resized image: ${width}x${height} → max ${MAX_DIMENSION}px (${outputBuffer.length} bytes)`,
139
+ )
140
+
141
+ return { buffer: outputBuffer, mime: 'image/jpeg' }
142
+ } catch (error) {
143
+ logger.error(
144
+ 'Failed to process image with sharp, using working buffer:',
145
+ error,
146
+ )
147
+ return { buffer: workingBuffer, mime: workingMime }
148
+ }
149
+ }