@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.
- package/bin.js +2 -0
- package/dist/ai-tool-to-genai.js +233 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/ai-tool.js +6 -0
- package/dist/bin.js +87 -0
- package/dist/bot-token.js +121 -0
- package/dist/bot-token.test.js +134 -0
- package/dist/channel-management.js +101 -0
- package/dist/cli-parsing.test.js +89 -0
- package/dist/cli.js +2529 -0
- package/dist/commands/abort.js +82 -0
- package/dist/commands/action-buttons.js +257 -0
- package/dist/commands/add-project.js +114 -0
- package/dist/commands/agent.js +291 -0
- package/dist/commands/ask-question.js +223 -0
- package/dist/commands/compact.js +120 -0
- package/dist/commands/context-usage.js +140 -0
- package/dist/commands/create-new-project.js +118 -0
- package/dist/commands/diff.js +128 -0
- package/dist/commands/file-upload.js +275 -0
- package/dist/commands/fork.js +217 -0
- package/dist/commands/gemini-apikey.js +70 -0
- package/dist/commands/login.js +490 -0
- package/dist/commands/mention-mode.js +51 -0
- package/dist/commands/merge-worktree.js +124 -0
- package/dist/commands/model.js +694 -0
- package/dist/commands/permissions.js +163 -0
- package/dist/commands/queue.js +217 -0
- package/dist/commands/remove-project.js +115 -0
- package/dist/commands/restart-opencode-server.js +116 -0
- package/dist/commands/resume.js +159 -0
- package/dist/commands/run-command.js +79 -0
- package/dist/commands/session-id.js +78 -0
- package/dist/commands/session.js +192 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +159 -0
- package/dist/commands/unset-model.js +152 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/user-command.js +148 -0
- package/dist/commands/verbosity.js +60 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +299 -0
- package/dist/condense-memory.js +33 -0
- package/dist/config.js +110 -0
- package/dist/database.js +1050 -0
- package/dist/db.js +159 -0
- package/dist/db.test.js +49 -0
- package/dist/discord-api.js +28 -0
- package/dist/discord-auth.js +231 -0
- package/dist/discord-auth.test.js +80 -0
- package/dist/discord-bot.js +997 -0
- package/dist/discord-utils.js +560 -0
- package/dist/discord-utils.test.js +115 -0
- package/dist/errors.js +167 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +122 -0
- package/dist/format-tables.test.js +199 -0
- package/dist/forum-sync/config.js +79 -0
- package/dist/forum-sync/discord-operations.js +154 -0
- package/dist/forum-sync/index.js +5 -0
- package/dist/forum-sync/markdown.js +117 -0
- package/dist/forum-sync/sync-to-discord.js +417 -0
- package/dist/forum-sync/sync-to-files.js +190 -0
- package/dist/forum-sync/types.js +53 -0
- package/dist/forum-sync/watchers.js +307 -0
- package/dist/gateway-consumer.js +232 -0
- package/dist/gateway-consumer.test.js +18 -0
- package/dist/genai-worker-wrapper.js +111 -0
- package/dist/genai-worker.js +311 -0
- package/dist/genai.js +232 -0
- package/dist/generated/browser.js +17 -0
- package/dist/generated/client.js +35 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.js +30 -0
- package/dist/generated/internal/class.js +41 -0
- package/dist/generated/internal/prismaNamespace.js +239 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
- package/dist/generated/models/bot_api_keys.js +1 -0
- package/dist/generated/models/bot_tokens.js +1 -0
- package/dist/generated/models/channel_agents.js +1 -0
- package/dist/generated/models/channel_directories.js +1 -0
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/channel_models.js +1 -0
- package/dist/generated/models/channel_verbosity.js +1 -0
- package/dist/generated/models/channel_worktrees.js +1 -0
- package/dist/generated/models/forum_sync_configs.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/generated/models/ipc_requests.js +1 -0
- package/dist/generated/models/part_messages.js +1 -0
- package/dist/generated/models/scheduled_tasks.js +1 -0
- package/dist/generated/models/session_agents.js +1 -0
- package/dist/generated/models/session_models.js +1 -0
- package/dist/generated/models/session_start_sources.js +1 -0
- package/dist/generated/models/thread_sessions.js +1 -0
- package/dist/generated/models/thread_worktrees.js +1 -0
- package/dist/generated/models.js +1 -0
- package/dist/heap-monitor.js +95 -0
- package/dist/hrana-server.js +416 -0
- package/dist/hrana-server.test.js +368 -0
- package/dist/image-utils.js +112 -0
- package/dist/interaction-handler.js +327 -0
- package/dist/ipc-polling.js +251 -0
- package/dist/kimaki-digital-twin.e2e.test.js +165 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +160 -0
- package/dist/markdown.js +342 -0
- package/dist/markdown.test.js +253 -0
- package/dist/message-formatting.js +433 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/opencode-plugin-loading.e2e.test.js +91 -0
- package/dist/opencode-plugin.js +536 -0
- package/dist/opencode-plugin.test.js +98 -0
- package/dist/opencode.js +409 -0
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/runtime-mode.js +51 -0
- package/dist/runtime-mode.test.js +115 -0
- package/dist/sentry.js +127 -0
- package/dist/session-handler/state.js +151 -0
- package/dist/session-handler.js +1874 -0
- package/dist/session-search.js +100 -0
- package/dist/session-search.test.js +40 -0
- package/dist/startup-service.js +153 -0
- package/dist/system-message.js +499 -0
- package/dist/task-runner.js +282 -0
- package/dist/task-schedule.js +191 -0
- package/dist/task-schedule.test.js +71 -0
- package/dist/thinking-utils.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +781 -0
- package/dist/tools.js +359 -0
- package/dist/unnest-code-blocks.js +136 -0
- package/dist/unnest-code-blocks.test.js +641 -0
- package/dist/upgrade.js +114 -0
- package/dist/utils.js +109 -0
- package/dist/voice-handler.js +606 -0
- package/dist/voice.js +304 -0
- package/dist/voice.test.js +187 -0
- package/dist/wait-session.js +94 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +727 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +82 -0
- package/schema.prisma +246 -0
- package/skills/batch/SKILL.md +87 -0
- package/skills/critique/SKILL.md +129 -0
- package/skills/errore/SKILL.md +589 -0
- package/skills/goke/.prettierrc +5 -0
- package/skills/goke/CHANGELOG.md +40 -0
- package/skills/goke/LICENSE +21 -0
- package/skills/goke/README.md +666 -0
- package/skills/goke/SKILL.md +458 -0
- package/skills/goke/package.json +43 -0
- package/skills/goke/src/__test__/coerce.test.ts +411 -0
- package/skills/goke/src/__test__/index.test.ts +1798 -0
- package/skills/goke/src/__test__/types.test-d.ts +111 -0
- package/skills/goke/src/coerce.ts +547 -0
- package/skills/goke/src/goke.ts +1362 -0
- package/skills/goke/src/index.ts +16 -0
- package/skills/goke/src/mri.ts +164 -0
- package/skills/goke/tsconfig.json +15 -0
- package/skills/jitter/EDITOR.md +219 -0
- package/skills/jitter/EXPORT-INTERNALS.md +309 -0
- package/skills/jitter/SKILL.md +158 -0
- package/skills/jitter/jitter-clipboard.json +1042 -0
- package/skills/jitter/package.json +14 -0
- package/skills/jitter/tsconfig.json +15 -0
- package/skills/jitter/utils/actions.ts +212 -0
- package/skills/jitter/utils/export.ts +114 -0
- package/skills/jitter/utils/index.ts +141 -0
- package/skills/jitter/utils/snapshot.ts +154 -0
- package/skills/jitter/utils/traverse.ts +246 -0
- package/skills/jitter/utils/types.ts +279 -0
- package/skills/jitter/utils/wait.ts +133 -0
- package/skills/playwriter/SKILL.md +31 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/skills/termcast/SKILL.md +945 -0
- package/skills/tuistory/SKILL.md +250 -0
- package/skills/zustand-centralized-state/SKILL.md +582 -0
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +41 -0
- package/src/__snapshots__/first-session-no-info.md +17 -0
- package/src/__snapshots__/first-session-with-info.md +23 -0
- package/src/__snapshots__/session-1.md +17 -0
- package/src/__snapshots__/session-2.md +5871 -0
- package/src/__snapshots__/session-3.md +17 -0
- package/src/__snapshots__/session-with-tools.md +5871 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +282 -0
- package/src/ai-tool.ts +39 -0
- package/src/bin.ts +108 -0
- package/src/bot-token.test.ts +171 -0
- package/src/bot-token.ts +159 -0
- package/src/channel-management.ts +172 -0
- package/src/cli-parsing.test.ts +132 -0
- package/src/cli.ts +3605 -0
- package/src/commands/abort.ts +112 -0
- package/src/commands/action-buttons.ts +376 -0
- package/src/commands/add-project.ts +152 -0
- package/src/commands/agent.ts +404 -0
- package/src/commands/ask-question.ts +330 -0
- package/src/commands/compact.ts +157 -0
- package/src/commands/context-usage.ts +199 -0
- package/src/commands/create-new-project.ts +179 -0
- package/src/commands/diff.ts +165 -0
- package/src/commands/file-upload.ts +389 -0
- package/src/commands/fork.ts +320 -0
- package/src/commands/gemini-apikey.ts +104 -0
- package/src/commands/login.ts +634 -0
- package/src/commands/mention-mode.ts +77 -0
- package/src/commands/merge-worktree.ts +177 -0
- package/src/commands/model.ts +961 -0
- package/src/commands/permissions.ts +261 -0
- package/src/commands/queue.ts +296 -0
- package/src/commands/remove-project.ts +155 -0
- package/src/commands/restart-opencode-server.ts +162 -0
- package/src/commands/resume.ts +242 -0
- package/src/commands/run-command.ts +123 -0
- package/src/commands/session-id.ts +109 -0
- package/src/commands/session.ts +250 -0
- package/src/commands/share.ts +106 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +221 -0
- package/src/commands/unset-model.ts +189 -0
- package/src/commands/upgrade.ts +52 -0
- package/src/commands/user-command.ts +193 -0
- package/src/commands/verbosity.ts +88 -0
- package/src/commands/worktree-settings.ts +79 -0
- package/src/commands/worktree.ts +431 -0
- package/src/condense-memory.ts +36 -0
- package/src/config.ts +148 -0
- package/src/database.ts +1530 -0
- package/src/db.test.ts +60 -0
- package/src/db.ts +190 -0
- package/src/discord-api.ts +35 -0
- package/src/discord-bot.ts +1316 -0
- package/src/discord-utils.test.ts +132 -0
- package/src/discord-utils.ts +767 -0
- package/src/errors.ts +213 -0
- package/src/escape-backticks.test.ts +469 -0
- package/src/format-tables.test.ts +223 -0
- package/src/format-tables.ts +145 -0
- package/src/forum-sync/config.ts +92 -0
- package/src/forum-sync/discord-operations.ts +241 -0
- package/src/forum-sync/index.ts +9 -0
- package/src/forum-sync/markdown.ts +176 -0
- package/src/forum-sync/sync-to-discord.ts +595 -0
- package/src/forum-sync/sync-to-files.ts +294 -0
- package/src/forum-sync/types.ts +175 -0
- package/src/forum-sync/watchers.ts +454 -0
- package/src/genai-worker-wrapper.ts +164 -0
- package/src/genai-worker.ts +386 -0
- package/src/genai.ts +321 -0
- package/src/generated/browser.ts +109 -0
- package/src/generated/client.ts +131 -0
- package/src/generated/commonInputTypes.ts +512 -0
- package/src/generated/enums.ts +46 -0
- package/src/generated/internal/class.ts +362 -0
- package/src/generated/internal/prismaNamespace.ts +2251 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
- package/src/generated/models/bot_api_keys.ts +1288 -0
- package/src/generated/models/bot_tokens.ts +1577 -0
- package/src/generated/models/channel_agents.ts +1256 -0
- package/src/generated/models/channel_directories.ts +2104 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/channel_models.ts +1288 -0
- package/src/generated/models/channel_verbosity.ts +1224 -0
- package/src/generated/models/channel_worktrees.ts +1308 -0
- package/src/generated/models/forum_sync_configs.ts +1452 -0
- package/src/generated/models/global_models.ts +1288 -0
- package/src/generated/models/ipc_requests.ts +1485 -0
- package/src/generated/models/part_messages.ts +1302 -0
- package/src/generated/models/scheduled_tasks.ts +2320 -0
- package/src/generated/models/session_agents.ts +1086 -0
- package/src/generated/models/session_models.ts +1114 -0
- package/src/generated/models/session_start_sources.ts +1408 -0
- package/src/generated/models/thread_sessions.ts +1599 -0
- package/src/generated/models/thread_worktrees.ts +1352 -0
- package/src/generated/models.ts +29 -0
- package/src/heap-monitor.ts +121 -0
- package/src/hrana-server.test.ts +428 -0
- package/src/hrana-server.ts +547 -0
- package/src/image-utils.ts +149 -0
- package/src/interaction-handler.ts +461 -0
- package/src/ipc-polling.ts +325 -0
- package/src/kimaki-digital-twin.e2e.test.ts +201 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +203 -0
- package/src/markdown.test.ts +360 -0
- package/src/markdown.ts +410 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +549 -0
- package/src/openai-realtime.ts +362 -0
- package/src/opencode-plugin-loading.e2e.test.ts +112 -0
- package/src/opencode-plugin.test.ts +108 -0
- package/src/opencode-plugin.ts +652 -0
- package/src/opencode.ts +554 -0
- package/src/privacy-sanitizer.ts +142 -0
- package/src/schema.sql +158 -0
- package/src/sentry.ts +137 -0
- package/src/session-handler/state.ts +232 -0
- package/src/session-handler.ts +2668 -0
- package/src/session-search.test.ts +50 -0
- package/src/session-search.ts +148 -0
- package/src/startup-service.ts +200 -0
- package/src/system-message.ts +568 -0
- package/src/task-runner.ts +425 -0
- package/src/task-schedule.test.ts +84 -0
- package/src/task-schedule.ts +287 -0
- package/src/thinking-utils.ts +61 -0
- package/src/thread-message-queue.e2e.test.ts +997 -0
- package/src/tools.ts +432 -0
- package/src/unnest-code-blocks.test.ts +679 -0
- package/src/unnest-code-blocks.ts +168 -0
- package/src/upgrade.ts +127 -0
- package/src/utils.ts +145 -0
- package/src/voice-handler.ts +852 -0
- package/src/voice.test.ts +219 -0
- package/src/voice.ts +444 -0
- package/src/wait-session.ts +147 -0
- package/src/worker-types.ts +64 -0
- package/src/worktree-utils.ts +988 -0
- package/src/xml.test.ts +38 -0
- 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
|
+
}
|