diff --git a/assets/ABOUT.md b/assets/ABOUT.md new file mode 100644 index 0000000..5aa0b74 --- /dev/null +++ b/assets/ABOUT.md @@ -0,0 +1,28 @@ +# Recap Relay + +Operator-side credit-metered proxy for Recap clients. Fronts Google Gemini (and optionally a local Parakeet+Gemma setup) so Recap users on Core/Pro/Max tiers can summarize videos without bringing their own API keys. + +## What it does + +- Receives `POST /relay/transcribe` and `POST /relay/analyze` from Recap installs +- Validates `X-Recap-Install-Id` and optional `Authorization: Bearer LIC1-...` against a Keysat license server (cached online check) +- Tracks per-install credit balances in `/data/credits.json` with calendar-month rollover for paid tiers +- Routes to Gemini first; falls back to the operator's Parakeet+Gemma above the per-user-per-month Gemini cap + +## Setup + +After installing, set: +1. **Gemini API Key** — required, the relay's primary backend +2. **Keysat URL** — defaults to `https://keysat.xyz`; override to the internal hostname if Keysat is co-located on this Start9 server +3. **Admin Password** — gates the `/admin/*` dashboard +4. (Optional) **Parakeet URL** + **Gemma URL** — operator-hardware fallback for Pro/Max overflow + +Then forward a public hostname (e.g. `relay.yourdomain.com`) to this service via StartTunnel, and point your Recap install's "Set Relay URL" action at that hostname. + +## Tier defaults + +- **Core** (unlicensed): 5 lifetime credits +- **Pro** (`relay_pro` entitlement): 50 monthly credits, max 25 via Gemini +- **Max** (`relay_max` entitlement): unlimited monthly, max 50 via Gemini + +Adjust via the "Adjust Tier Quotas" action without redeploying. diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..d326778 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/issuer.pub b/assets/issuer.pub new file mode 100644 index 0000000..05e5c9f --- /dev/null +++ b/assets/issuer.pub @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAgsromMy4osMJplX1rY0fd4ouS6wfkm/vfeY2gXEQHkA= +-----END PUBLIC KEY----- diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e34aceb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,359 @@ +{ + "name": "recap-relay-startos", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "recap-relay-startos", + "dependencies": { + "@start9labs/start-sdk": "1.0.0" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + } + }, + "node_modules/@iarna/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==", + "license": "ISC" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.0.0.tgz", + "integrity": "sha512-rtAfumVbMy90iw2WRbWH7fGcuwAvvuFfR4YwgSsh5R2Bz9MXtcEfmznwhnrp+ntQ6BOUSQ0wLzePbfsS6kUagg==", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.8.2", + "@noble/hashes": "^1.7.2", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "fast-xml-parser": "^5.5.6", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.0.7", + "yaml": "^2.7.1", + "zod": "^4.3.6", + "zod-deep-partial": "^1.2.0" + } + }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.4", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", + "integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==", + "dev": true, + "license": "MIT", + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/deep-equality-data-structures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", + "integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==", + "license": "MIT", + "dependencies": { + "object-hash": "^3.0.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-deep-partial": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz", + "integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==", + "license": "MIT", + "peerDependencies": { + "zod": "^4.1.13" + } + } + } +} diff --git a/server/backends/hardware.js b/server/backends/hardware.js index b9278f6..51339b7 100644 --- a/server/backends/hardware.js +++ b/server/backends/hardware.js @@ -1,55 +1,219 @@ // Operator-hardware fallback backend. Forwards transcribe requests to -// the operator's Parakeet (or any Whisper-API-compatible) endpoint and -// analyze requests to their Gemma (or any OpenAI-API-compatible) endpoint. +// a Parakeet endpoint (or any Whisper-API-compatible server — same wire +// format) and analyze requests to a Gemma endpoint (or any +// OpenAI-compatible chat-completions server). // -// v0.1 is a stub — the endpoints are wired up, but no operator has -// pointed a real Parakeet/Gemma at the relay yet. Returns a 503 -// "hardware fallback not yet wired" so the credits.js routing logic -// still applies but users get a clear message instead of a silent -// failure. +// Used when a Pro/Max user has exceeded their monthly Gemini cap. +// Returns the same shape gemini.js produces so route handlers don't +// need a backend-specific branch downstream: +// transcribeAudio → { text, segments, duration_seconds } +// analyzeText → { text } +// +// Both endpoints are reached via plain fetch — no SDK dependency keeps +// the relay container slim and the upstream wire format is dead-simple +// for these two well-known shapes. + +const ANALYZE_MAX_TOKENS = 16000; +// Gemma served locally tends to live on the host's LAN, not the public +// internet, so generous timeouts. Same scale as Recap's defaults. +const DEFAULT_TIMEOUT_MS = 900_000; + +// Pull the model identifier out of the prompt if the operator wants a +// specific Gemma SKU. We default to "gemma3:27b" which is the typical +// Ollama tag for the analysis-capable Gemma model. Operators with a +// different deployment can update this via a future StartOS action; +// for v0.2 it's hardcoded. +const HARDWARE_ANALYZE_MODEL = process.env.RELAY_GEMMA_MODEL || "gemma3:27b"; + +// Parakeet's typical model identifier. Mirrors what Recap's whisper.js +// sends when the operator points the relay at a NeMo Parakeet HTTP +// wrapper. Configurable via env var for non-default deployments. +const HARDWARE_TRANSCRIBE_MODEL = + process.env.RELAY_PARAKEET_MODEL || "parakeet-tdt-0.6b-v3"; export function createHardwareBackend({ parakeetBaseURL = "", gemmaBaseURL = "", + timeoutMs = DEFAULT_TIMEOUT_MS, } = {}) { - const hasParakeet = !!parakeetBaseURL; - const hasGemma = !!gemmaBaseURL; + const parakeet = parakeetBaseURL ? parakeetBaseURL.replace(/\/$/, "") : ""; + const gemma = gemmaBaseURL ? gemmaBaseURL.replace(/\/$/, "") : ""; return { - hasTranscribe: hasParakeet, - hasAnalyze: hasGemma, + hasTranscribe: !!parakeet, + hasAnalyze: !!gemma, - async transcribeAudio() { - if (!hasParakeet) { + // POST /v1/audio/transcriptions with the OpenAI Whisper + // multipart shape. Parakeet wrappers (NeMo + the patched one Recap + // already talks to) honor this format and return segments with + // per-segment timestamps when timestamp_granularities=segment is + // requested. Falls back to a bare request if the rich shape 4xx/5xxs. + async transcribeAudio({ + audio, + mimeType = "application/octet-stream", + offsetSeconds = 0, + }) { + if (!parakeet) { const err = new Error( - "operator-hardware transcribe path is not configured (relay_parakeet_base_url is empty)" + "operator-hardware transcribe is not configured (relay_parakeet_base_url is empty)" ); err.status = 503; throw err; } - // TODO v0.2: POST audio to parakeetBaseURL using the OpenAI - // audio-transcriptions wire format Recap already speaks. Return - // { text, segments, duration_seconds } in the same shape as - // gemini.js's transcribeAudio. - const err = new Error("operator-hardware transcribe path not yet implemented in relay v0.1"); - err.status = 503; - throw err; + + // Try the rich request first (verbose_json + segment timestamps). + // FormData/Blob globals are available in Node 20+. Wrap the + // received Buffer in a Blob so the multipart body is properly + // chunked instead of falling back to base64. + const buildForm = (richMode) => { + const form = new FormData(); + const blob = new Blob([audio], { type: mimeType }); + form.append("file", blob, "audio.bin"); + form.append("model", HARDWARE_TRANSCRIBE_MODEL); + if (richMode) { + form.append("response_format", "verbose_json"); + form.append("timestamp_granularities[]", "segment"); + } + return form; + }; + + const url = `${parakeet}/v1/audio/transcriptions`; + let res; + try { + res = await fetch(url, { + method: "POST", + body: buildForm(true), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + const e = new Error( + `Parakeet transcribe network error: ${err?.message || err}` + ); + e.status = 502; + throw e; + } + + // If the wrapper rejects the rich params, retry with bare-bones. + if (!res.ok && res.status >= 400 && res.status < 600) { + const richBody = await safeBody(res); + console.warn( + `[hardware] rich Parakeet request returned ${res.status}: ${richBody.slice(0, 200)} — retrying bare` + ); + try { + res = await fetch(url, { + method: "POST", + body: buildForm(false), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + const e = new Error( + `Parakeet transcribe network error (fallback): ${err?.message || err}` + ); + e.status = 502; + throw e; + } + } + + if (!res.ok) { + const body = await safeBody(res); + const e = new Error( + `Parakeet transcribe ${res.status}: ${body.slice(0, 300)}` + ); + e.status = res.status; + throw e; + } + + const data = await res.json(); + const segments = Array.isArray(data.segments) ? data.segments : []; + + // Offset support: when the relay caller is processing a chunked + // audio file, it asks for transcripts at a non-zero base time. + // Parakeet returns timestamps relative to the chunk; shift them + // up by offsetSeconds so the combined transcript downstream + // lines up with the real video timeline. + const shifted = segments.map((s) => ({ + start: (s.start || 0) + offsetSeconds, + end: (s.end || 0) + offsetSeconds, + text: (s.text || "").trim(), + })); + + // Build the [MM:SS] text format Recap's parseTimestampedTranscript + // already speaks. The route handler will pass this straight back + // to Recap, which parses it on the client side. + const lines = shifted.length + ? shifted.map((s) => `[${formatMmSs(s.start)}] ${s.text}`) + : [`[0:00] ${(data.text || "").trim()}`]; + + return { + text: lines.join("\n"), + segments: shifted, + duration_seconds: data.duration || 0, + }; }, - async analyzeText() { - if (!hasGemma) { + // POST /v1/chat/completions with the OpenAI shape. Ollama's + // server, vLLM, llama.cpp's HTTP server, and most other OSS LLM + // runners support this wire format — so we don't lock the relay + // to one specific Gemma deployment. + async analyzeText({ prompt }) { + if (!gemma) { const err = new Error( - "operator-hardware analyze path is not configured (relay_gemma_base_url is empty)" + "operator-hardware analyze is not configured (relay_gemma_base_url is empty)" ); err.status = 503; throw err; } - // TODO v0.2: POST prompt to gemmaBaseURL using either /api/generate - // (Ollama native) or /v1/chat/completions (OpenAI-compatible). - // Return { text } matching gemini.js's analyzeText. - const err = new Error("operator-hardware analyze path not yet implemented in relay v0.1"); - err.status = 503; - throw err; + + const url = `${gemma}/v1/chat/completions`; + let res; + try { + res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: HARDWARE_ANALYZE_MODEL, + max_tokens: ANALYZE_MAX_TOKENS, + messages: [{ role: "user", content: prompt }], + stream: false, + }), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + const e = new Error( + `Gemma analyze network error: ${err?.message || err}` + ); + e.status = 502; + throw e; + } + + if (!res.ok) { + const body = await safeBody(res); + const e = new Error(`Gemma analyze ${res.status}: ${body.slice(0, 300)}`); + e.status = res.status; + throw e; + } + + const data = await res.json(); + const text = data?.choices?.[0]?.message?.content || ""; + return { text }; }, }; } + +function formatMmSs(seconds) { + const s = Math.max(0, Math.floor(seconds)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) + return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; + return `${m}:${String(sec).padStart(2, "0")}`; +} + +async function safeBody(res) { + try { + return await res.text(); + } catch { + return ""; + } +} diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index 4cf71d8..094636e 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -2,14 +2,14 @@ export const DEFAULT_LANG = 'en_US' const dict = { // main.ts - 'Starting Recap...': 0, - 'Web Interface': 1, - 'Recap is ready': 2, - 'Recap is not responding': 3, + 'Starting Recap Relay...': 0, + 'Relay Endpoint': 1, + 'Relay is accepting connections': 2, + 'Relay is not responding': 3, // interfaces.ts - 'Web UI': 4, - 'The web interface for Recap — browse, search, and manage your transcript library': 5, + 'HTTP endpoint for Recap clients to relay transcribe + analyze calls. Also serves the operator admin dashboard at /admin/.': + 4, } as const /** diff --git a/startos/interfaces.ts b/startos/interfaces.ts index f591cca..233ee27 100644 --- a/startos/interfaces.ts +++ b/startos/interfaces.ts @@ -17,8 +17,7 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { name: i18n('Relay Endpoint'), id: 'api', description: i18n( - 'HTTP endpoint for Recap clients to relay transcribe + analyze ' + - 'calls. Also serves the operator admin dashboard at /admin/.', + 'HTTP endpoint for Recap clients to relay transcribe + analyze calls. Also serves the operator admin dashboard at /admin/.', ), type: 'ui', masked: false, diff --git a/startos/versions/index.ts b/startos/versions/index.ts index ca23373..2bd08ba 100644 --- a/startos/versions/index.ts +++ b/startos/versions/index.ts @@ -1,7 +1,8 @@ import { VersionGraph } from '@start9labs/start-sdk' import { v_0_1_0 } from './v0.1.0' +import { v_0_2_0 } from './v0.2.0' export const versionGraph = VersionGraph.of({ - current: v_0_1_0, - other: [], + current: v_0_2_0, + other: [v_0_1_0], }) diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts new file mode 100644 index 0000000..0ac10a8 --- /dev/null +++ b/startos/versions/v0.2.0.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_0 = VersionInfo.of({ + version: '0.2.0:0', + releaseNotes: { + en_US: + 'Implements the operator-hardware fallback path. Parakeet transcribe forwarding speaks the OpenAI Whisper API wire format (with verbose_json + segment timestamps, and a bare-shape fallback for wrappers that reject the rich params); Gemma analyze forwarding uses /v1/chat/completions for broad compatibility with Ollama / vLLM / llama.cpp servers.', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +})