From cccbee27e4174aafa867b9c64e324b989a4afea9 Mon Sep 17 00:00:00 2001 From: local Date: Mon, 11 May 2026 20:14:50 -0500 Subject: [PATCH] v0.2 hardware backend --- assets/ABOUT.md | 28 +++ assets/icon.png | Bin 0 -> 11039 bytes assets/issuer.pub | 3 + package-lock.json | 359 +++++++++++++++++++++++++++ server/backends/hardware.js | 224 ++++++++++++++--- startos/i18n/dictionaries/default.ts | 12 +- startos/interfaces.ts | 3 +- startos/versions/index.ts | 5 +- startos/versions/v0.2.0.ts | 13 + 9 files changed, 607 insertions(+), 40 deletions(-) create mode 100644 assets/ABOUT.md create mode 100644 assets/icon.png create mode 100644 assets/issuer.pub create mode 100644 package-lock.json create mode 100644 startos/versions/v0.2.0.ts 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 0000000000000000000000000000000000000000..d326778d4f60731e627959ea083d94bac06590a2 GIT binary patch literal 11039 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%0Da2FX+Hf2F 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" + }, + "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 }) => {}, + }, +})