Commit Graph

62 Commits

Author SHA1 Message Date
Keysat c9ad731860 Add Gemini 3.5 model-selection idea to backlog
Triaged from the cross-project inbox. Needs a research pass to confirm
available stable Gemini versions before wiring; flag the server+client
model-list duplication and the matching relay-side capture.
2026-06-16 21:43:39 -05:00
Keysat 621af7ca14 Add self-contained shareable HTML export for YouTube recaps
New 'Share page (HTML)' entry in the Export menu generates a single
standalone .html file: the recap record inlined as JSON plus a small
baked-in renderer that reproduces the embedded YouTube player and the
expandable timestamped summaries. The recipient opens it with no account
and no calls back to the server. On mobile it hands the file to the
native share sheet (navigator.share with files); on desktop or where
unsupported it downloads.

YouTube only by design — podcasts have no portable audio and are
rejected with a toast. The generator runs inside index.html's own
<script>, so closing tags are split and inlined JSON escapes '<' to
avoid premature script termination.

Ship as 0.2.160.
2026-06-16 07:57:30 -05:00
Keysat f38ecc6c86 Handoff: record server/client URL-parser duplication; bump Current state to 0.2.159 2026-06-15 23:33:33 -05:00
Keysat cb961cd2d9 Accept YouTube /live/ and /shorts/ URLs in extractVideoId
The video-id regex only matched /watch?v=, youtu.be, /embed/, and /v/
forms, so youtube.com/live/<id> and youtube.com/shorts/<id> links were
rejected with "Invalid YouTube URL". Add both forms to the server and
frontend extractors (kept in sync) and cover them with tests.

Ship as 0.2.159.
2026-06-15 23:29:57 -05:00
Keysat f9367c2ae5 Handoff: record operator-absorbed relay convention; condense Current state 2026-06-15 20:10:45 -05:00
Keysat be9692daa7 Record 0.2.158 Daily Digest live on the box 2026-06-15 19:53:39 -05:00
Keysat b4fa5d7be8 Add opt-in Daily Digest (daily email of last 24h of library recaps)
Multi-mode, off by default. Each new recap is synthesized into a 1-2
paragraph overview via the relay (operator-absorbed) and cached onto the
session JSON; a daily 08:00 scan emails opted-in users their fresh
recaps, deduped by a per-user watermark that never skips a failed or
over-cap recap. One-click tokenized unsubscribe; settings-modal toggle;
admin test trigger. Bumps to 0.2.158.
2026-06-15 19:50:48 -05:00
Keysat 962423ca10 Add Daily Digest plan; record render-loop invariants + deploy model in AGENTS.md 2026-06-15 18:31:08 -05:00
Keysat 693bb981ff Fix mobile/UX bug cluster: video minimize, audio interrupt, scroll reset, redundant box
Four fixes in public/index.html, all reported against recaps.cc on mobile:

- Video minimize no longer shows a black frame on expand. toggleVideoMinimize()
  used to call render(), rebuilding the YouTube iframe inside the display:none
  minimized container, which wedged the IFrame API. Minimize now toggles the
  .results-left.minimized class in place; a !videoMinimized guard on render()'s
  needsMount plus a new ensureYtMounted() (called from the expand paths) keep the
  player from ever being created in a hidden container.

- Background processing no longer interrupts podcast audio or resets the
  transcript scroll. The ~60s relay-credit poll calls render(), which rebuilt the
  <audio> element and chunks-scroll. render() now preserves the live <audio> node
  across the innerHTML swap (replaceWith when the src matches) and restores
  chunks-scroll scrollTop; initPodcastPlayer() is idempotent so the preserved node
  doesn't get duplicate listeners.

- Removed the redundant centered "Processing..." box; the staged pizza-tracker
  breadcrumb already covers that window.

- Added -webkit-overflow-scrolling/overscroll-behavior to .chunks-scroll for the
  mobile can't-scroll-to-top report (best-effort, needs on-device verification).

Ships as 0.2.157. Reviewer pass clean; inline JS syntax checked with node --check.
2026-06-15 17:38:32 -05:00
Keysat 91af0b711e Harden iOS sign-in against stale-connection POST failures
iPad users hit a spurious "network error" on the first tap of
"Send sign-in link", with a second tap succeeding. Cause is iOS
Safari dispatching the POST onto a pooled keep-alive socket the
server/proxy already closed; unlike a GET it isn't transparently
re-sent, so it surfaces as a transport TypeError. The single 500ms
auto-retry was too quick and reused the same dead socket.

Both sign-in entry points (auth.html postWithRetry, index.html
fetchWithRetry) now retry 3x with growing backoff (0 -> +400ms ->
+1.6s) to outlast Safari evicting the socket. Frontend-only.

Ships as 0.2.156.
2026-06-15 16:35:13 -05:00
Keysat aca2ba9e2e Handoff: record P0/P1 fixes as done, move eval debt to ROADMAP
Refresh AGENTS.md conventions (client IP via req.ip; safeFilename exported),
rewrite Current state to a lean snapshot, move the P2 known-debt detail to
ROADMAP.md.
2026-06-15 13:48:20 -05:00
Keysat d0e98424c1 Fix five P0/P1 security & correctness findings from the full-eval
- Arbitrary file write (P0): validate import keys in /api/library/import via
  a now-exported safeFilename(); a ../../ key is skipped, not written out of
  the scope dir.
- SSRF (P0): guard downloadPodcastAudio — reject non-HTTP(S) schemes, block
  IP-literal and DNS-resolved private/link-local/loopback/reserved/multicast
  and embedded-IPv4 IPv6 targets (closes DNS rebinding), cap + resolve redirects.
- ESM require (P1): top-level import of randomBytes in license-purchase.js
  (the inner require threw on the anon purchase-settle path).
- Concurrency lock (P1): skip the process-global free-tier slot in multi-mode
  so it no longer serializes every cloud tenant onto one job.
- X-Forwarded-For bypass (P1): set Express trust proxy from
  RECAP_TRUSTED_PROXY_HOPS (default 1); getClientIp now reads req.ip instead
  of a client-spoofable XFF entry.

Tests added for safeFilename, the SSRF guard, and getClientIp (119 pass).
Registry blockers deferred (ROADMAP); leaked-key history purge queued.
2026-06-15 13:36:40 -05:00
Keysat 755b100f00 Triage P3 eval findings into deferred backlog
Defer the low-severity full-eval findings (request caps, invoice-ID claim,
root container, rate-limit buckets, repo hygiene, StartOS polish, bulk doc
reconciliation) for later batching. P0/P1 and P2 live in AGENTS.md.
2026-06-15 12:30:58 -05:00
Keysat 6bb7e69141 Add inbox-check line; align .gitignore with canonical .claude policy
Cross-repo git-hygiene audit remediation: surface ~/Projects/standards/INBOX.md items at session start, and switch .gitignore to the deny-by-default .claude/* block (shared wiring allow-listed) plus the canonical secrets/env lines — per standards/portability.md.
2026-06-14 12:17:16 -05:00
Keysat d4c742d6e7 Add full-evaluation report
Independent six-lens evaluation (evaluator, security-auditor, exerciser,
doc-auditor, start9-spec-checker). Surfaces three P0s on the cloud surface
and a registry-submission block; full priority queue in the file.
2026-06-14 09:39:46 -05:00
Keysat 982e1b0d66 Retrofit: fix stale command docs, extract relay-client guide
- Replace the Commands-table Lint/Type-check TODOs with the real, verified
  commands: `npm run check` (tsc --noEmit over startos/) and `npm run prettier`.
  There is no ESLint/linter; server/ JS is untooled.
- Move the client-side relay contract (env vars, /relay/* endpoints, X-Recap-*
  headers, file map) out of AGENTS.md into docs/guides/relay-client.md with
  paths: frontmatter, lazy-loaded via a .claude/rules symlink; AGENTS.md keeps
  a one-line pointer.
- Un-ignore .claude/rules/ so the guide auto-attaches in any clone, while
  .claude/ local state (worktrees, plans) stays ignored.
2026-06-13 14:58:05 -05:00
Keysat 0ae59f3550 Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
2026-06-13 14:25:05 -05:00
Keysat db580abad7 Add cross-repo change-impact convention 2026-06-13 12:18:40 -05:00
Keysat 5b7df2f073 Complete client-side relay contract in AGENTS.md
Add the /relay/* endpoints the app actually calls that were omitted (capabilities, policy, tts, jobs/:id, credits/*); fix the Files attribution (add relay-capabilities.js + credits-purchase.js; the /relay/policy proxy lives in index.js only).
2026-06-13 12:02:36 -05:00
Keysat 4bba466665 Tidy relay-internal detail; add client-side relay contract
- Add Client-side contract with the relay sub-section: env vars
  (RECAP_RELAY_BASE_URL, RECAP_RELAY_OPERATOR_KEY ↔ relay_cloud_operator_key),
  auth direction the client SENDS, the 12 /relay/* endpoints the consumer
  actually calls (verified against providers/relay.js + billing-routes.js +
  subscription-reminders.js).
- Drop two relay-internal references now canonical in ../recap-relay/AGENTS.md:
  the extendUserTier function name and the Adjacent-repo bullet's
  "Private; ships via make install only" sentence.
2026-06-13 11:13:21 -05:00
Keysat b906b8a5c4 Add agent docs (AGENTS.md, ROADMAP.md, CLAUDE.md symlink) 2026-06-13 10:38:51 -05:00
Keysat 373d10595b Pluggable AI providers, relay credit system, picker UX overhaul
Captures roughly forty version bumps (v0.2.6 → v0.2.47) of work that
accumulated without commits.

- Pluggable provider system under server/providers/: gemini, anthropic,
  openai, openai-compatible, ollama, whisper-compatible, relay. Mix and
  match transcription + analysis per request via the picker UI.
- Relay backend integration. Hardcoded relay URL in server/relay-default.js
  (operator-controlled at build time, not user-configurable). New
  /api/relay/{status,policy} endpoints proxy to the relay; balance pings
  populate a cached credit display.
- Per-install identity in server/install-id.js for relay credit accounting.
  Sent to the relay as X-Recap-Install-Id; persists across upgrades, lost
  on a full uninstall + reinstall. Not surfaced in the UI.
- Admin login gate (server/admin-auth.js + setAdminPassword action). Scrypt
  password hash + HMAC-signed session cookie.
- Entitlement scheme rename: pro / max (each paired with subscriptions and
  relay_pro / relay_max), replacing the misleading "core" entitlement
  that conflicted with the user-facing "Core" tier name.
- Activation screen: dynamic credit count pulled from /api/relay/policy,
  "Skip — use free mode" button, accurate paid-feature list.
- Top toolbar: inline credit-balance pill (or "BYO configured" fallback),
  Upgrade + "I have a key" buttons.
- Picker UI: per-provider sections with Save/Test/Delete buttons, sections
  collapsible by chevron, default-collapsed unless currently selected,
  "Use comped credits (reset to relay)" link when the user has strayed,
  green hint under inputs whose values are server-configured.
- Activity log: chevron-collapsible groups per video, refresh-survival via
  localStorage + a 500-entry server-side buffer, explicit Clear button.
- YouTube captions fast-path with user toggle (skips audio download + AI
  transcription when captions are available — uncheck for speaker labels).
- Cancel button: AbortController plumbed through every provider SDK call;
  retryAPI short-circuits on AbortError; cancellation events surface in
  the activity log instead of silent retries.
- Long-video analysis: auto-coalesce transcript entries before building the
  analysis prompt so local-model context windows (32k-ish) don't overflow.
  Original entries preserved for transcript display via an index map; the
  analyzer sees a coarser view but click-to-seek timestamps stay precise.
- StartOS action grouping (Setup / AI Providers) so the actions list is
  navigable.
- Manifest description rewritten to reflect multi-provider support and
  free-tier relay credits.
- Smaller fixes: summarize-button enablement no longer requires a Gemini
  key when other providers are configured; analysis fallback chain handles
  context-length and 503 capacity errors; single-segment expansion for
  providers that don't return per-segment timestamps (Parakeet et al.);
  many other UX polish items.
2026-05-11 23:46:20 -05:00
Keysat 2544cf7dde Repoint FileBrowser upload from /websites/packages to /websites/keysat-registry
Aligns the upload path with the keysat.xyz registry-domain rebrand —
the directory name now matches the registry it serves.
2026-05-09 20:03:19 -05:00
Keysat 068335f43c Repoint deploy script defaults from satsflows.com to keysat.xyz
Both REGISTRY_URL (registry index re-index endpoint) and
REGISTRY_PUBLIC_URL (the URL embedded in the registry entry pointing
at the .s9pk download) now default to keysat.xyz. The hosts route to
the same data via StarTunnel; this just stops embedding the legacy
satsflows.com hostname in the registered package metadata.
2026-05-09 19:52:18 -05:00
Keysat 25ec40f345 Bump version to 0.2.5
Tighter license-poll cadence + opportunistic online refresh.
2026-05-09 19:36:47 -05:00
Keysat 9439154c25 Tighten license-poll cadence; add opportunistic online refresh
Three changes that together make license state changes feel
near-instant in the UI without burning real I/O / network budget:

  1. File-poll interval: 30s → 5s
     Action-set keys (via "Set Recap License") get picked up almost
     immediately. Cost: a single stat per file every 5s, negligible.

  2. Online validation interval: 6h → 30min
     A license revoked on Keysat now flips to invalid within 30 min
     worst-case, instead of sitting unnoticed for hours. Bounded
     latency makes revocation usable in production.

  3. Opportunistic online refresh on /api/license-status
     If the cached LIC was last validated more than 10 min ago, fire
     validateOnline() in the background (non-blocking) when the web
     UI hits the status endpoint. Since the UI hits status on every
     page load, revocations get caught the next time anyone opens
     the app — usually well under the scheduled 30 min tick.

Three new env vars for tuning:
  RECAP_LICENSE_FILE_POLL_MS         (default 5000)
  RECAP_VALIDATE_INTERVAL_MS         (default 1800000)
  RECAP_OPPORTUNISTIC_REFRESH_MS     (default 600000)
2026-05-09 19:36:46 -05:00
Keysat e5a779ced2 Strip prepare/build scripts from vendored keysat-client package.json
When npm install runs in server/ and processes the file: dep at
vendor/keysat-licensing-client/, it fires the vendor's 'prepare'
script even with --ignore-scripts (npm bug for file/git deps).
The prepare script calls tsup which isn't installed in the build
container, so the build fails with 'sh: 1: tsup: not found'.

The vendored copy ships its prebuilt dist/ in the repo, so we don't
need tsup. Drop the scripts entirely.
2026-05-09 11:59:55 -05:00
Keysat 823b9e0375 Bump version to 0.2.4
Critical fix for v0.2.3 startup crash (missing modules in Docker
image). Also refreshes vendored Keysat client to v0.2.0 ahead of
in-app buy flow.
2026-05-09 11:57:58 -05:00
Keysat 495b4aef36 Fix Dockerfile to copy all server/*.js modules; refresh vendor to v0.2.0
The runtime crash on v0.2.3:

  Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/server/util.js'
  imported from /app/server/index.js

happened because the Dockerfile's stage-2 COPY only listed server/
index.js + server/license.js explicitly. When I started extracting
modules in v0.2.3 (util.js, gemini-helpers.js, audio.js, ytdlp.js,
cookies.js, config.js, license-middleware.js, history.js, library.js)
I forgot to update the COPY list, so those files were never copied
into the runner image. Local 'node' tests passed because the modules
exist on disk; the .s9pk container had only the two original files
and crashed on first import.

Fix:

  COPY server/*.js ./server/

Glob picks up all top-level .js files automatically, including any
future extractions, while still skipping server/test/ and server/
node_modules/. This is the simplest forward-compatible form.

Bonus: refresh the vendored @keysat/licensing-client from 0.1.0 to
0.2.0. The new SDK adds:

  • policySlug field on StartPurchaseOptions (so we can drive Core/
    Pro tier selection programmatically from our backend)
  • client.listPublicPolicies(productSlug) for fetching the tier
    cards' data without auth

Both are prerequisites for the in-app buy flow planned in
~/.claude/plans/in-app-buy-flow.md. The vendor's own node_modules
(@noble/ed25519, @noble/hashes) is gitignored as before — Docker
builds re-install via `npm install --omit=dev --ignore-scripts` in
the vendor dir during stage 1.

Also includes the license-middleware update from earlier in the day:
a 30s license-file poll so a key set via the "Set Recap License"
StartOS action is picked up within seconds (instead of waiting for
the 6h scheduled validateOnline tick).
2026-05-09 11:57:41 -05:00
Keysat c06ffbbdf4 Module split: library export/import → server/library.js
• setupLibraryRoutes(app) — registers GET /api/library/export and
                              POST /api/library/import

The library module reads through history.js helpers (getHistoryDir,
loadMeta, saveMeta) and reads/writes subscriptions.json directly.
Subscriptions integration is via raw fs because (a) the library merge
logic is library-specific (skip-if-already-exists semantics), and (b)
the subscriptions module hasn't been extracted yet — the only thing
the import path needs is to merge dedupe-by-URL into the file.

server/index.js: 2079 → 1971 lines.

Smoke tested: server boots; /api/license-status, /api/health respond;
/api/library/export still returns 402 license_required for unlicensed
(unchanged Pro-gate behavior). 69 unit tests still pass.
2026-05-09 10:39:09 -05:00
Keysat a09ad9c429 Add unit tests for util / gemini-helpers / license / history modules
69 tests across 16 suites, ~120 ms total. Uses node:test (built into
Node 20+) — no new dependency, no Docker rebuild churn. Run with:

  cd server && npm test

Coverage:
  • util.js               extractVideoId, formatTime,
                          parseTimestampedTranscript, safeText,
                          retryGemini (incl. 503 retry, network-error
                          retry, non-retryable passthrough), sendEvent
  • gemini-helpers.js     PRICING table integrity, calcCost (model-
                          specific rates, default fallback, missing
                          fields, sub-cent ¢ formatting, totalTokens
                          precedence), buildAnalysisPrompt
  • license.js            checkLicense (no key, malformed, fallback to
                          startos-config.json, license.txt priority),
                          activate (bad-format throw, file write),
                          deactivate (file removal, idempotent),
                          publicView (no raw key leak, sorted
                          entitlements, ISO dates), has()
  • history.js            initHistory + getHistoryDir, saveToHistory
                          (id shape, defaults, podcast guid encoding),
                          loadMeta + saveMeta round-trip, corrupt-file
                          tolerance

Tests that need module-private file paths (license, history) use a
mkdtemp'd tmp dir as DATA_DIR + dynamic import() so each suite starts
clean. No test mocks the filesystem — they read/write real files
inside the tmp dir, matching production behavior.

Deliberately not yet covered (need an Express app harness or external
binaries): license-middleware (gate behavior), config (live-reload
poll), audio (ffmpeg/ffprobe), ytdlp (yt-dlp + git), cookies (state
mutation routes), the /api/process pipeline. Worth a follow-up after
the current refactor settles.
2026-05-09 10:36:12 -05:00
Keysat fe07580a12 Bump version to 0.2.3
New 'Set Recap License' StartOS action; split server/index.js into
8 focused modules (util, gemini-helpers, audio, ytdlp, cookies,
config, license-middleware, history); 'Recap license' copy fix.
2026-05-09 07:06:36 -05:00
Keysat 29282f8dcc Add 'Set Recap License' StartOS action + s/Keysat license/Recap license/
Two related changes:

1. New StartOS action: 'Set Recap License'

   Symmetric with the existing 'Set Gemini API Key' action — paste a
   LIC1-... key into the StartOS Actions menu and it gets persisted.
   Added because some users prefer the StartOS form for credentials
   over the in-app activation modal.

   Implementation:
     • startos/file-models/config.json.ts: schema gains recap_license_key
     • startos/actions/setLicense.ts: input form (masked, regex-checks
       for the LIC1- prefix), persists via configFile.merge()
     • startos/actions/index.ts: registers the new action
     • server/license.js: readLicenseString() falls back to
       startos-config.json after the legacy license.txt path. Resolution
       order: env → license.txt → startos-config.json
     • server/license-middleware.js: faster license-file poll (30 s,
       env-overridable RECAP_LICENSE_FILE_POLL_MS) re-runs checkLicense
       so action-set keys take effect within seconds, not the 6 h online
       cycle. If the new key parses as 'licensed', kicks an immediate
       online check to confirm.

2. Copy fix: 'Keysat license' → 'Recap license' in user-facing text

   Keysat is the licensing system underneath, but customers buy a
   'Recap license'. Updated:
     • Activation screen subtitle (public/index.html)
     • 402 message in the activation gate (server/license-middleware.js)

   Internal references (PRODUCT_SLUG, KEYSAT_BASE_URL, the issuer.pub
   filename, the 'Issuer: licensing.keysat.xyz' display in the
   activation card) stay as Keysat — those are accurate.

Smoke tested locally: starting the server with no license, then
writing a fake LIC1-... key into startos-config.json, the
license-file poll picks it up within ~2 s and transitions state from
'unlicensed' to 'invalid' (since the fake key fails Ed25519
verification, as expected). With a real key, the same path would land
in 'licensed'.
2026-05-09 07:06:21 -05:00
Keysat 85cb641044 Module split: history storage + meta + 9 routes → server/history.js
• initHistory({ dataDir })          — boot setup; mkdir + path init
  • saveToHistory(...)                — write one summary file
  • loadMeta() / saveMeta(meta)       — _meta.json folder structure
  • setupHistoryRoutes(app, deps)     — registers GET /api/history,
                                        GET/PUT/DELETE /api/history/:id,
                                        PUT /api/history/:id/title,
                                        PUT /api/history/meta,
                                        POST/PUT/DELETE /api/history/folders[/:id],
                                        PUT /api/history/folders/:id/collapsed,
                                        PUT /api/history/move
  • getHistoryDir()                   — exposes the directory for code
                                        that hasn't been extracted yet
                                        (subscriptions / library / process
                                        pipeline)

The DELETE route needs to add the deleted videoId to the skip list so
subscriptions don't re-queue it. That's a cross-module concern, so
setupHistoryRoutes takes addToSkipList as a dependency. For now it's
late-bound to the still-local function in index.js (lambda captures
the scope, not the value); when subscriptions are extracted next, the
import flips cleanly.

server/index.js: 2300 → 2079 lines.

Smoke tested: /api/license-status, /api/health, and /api/history (402
license-gated for unlicensed) all respond as expected.
2026-05-08 17:09:10 -05:00
Keysat 5540b71446 Module split: license gate + Pro gates + license routes → server/license-middleware.js
• LIC                              — exported live binding (ESM)
  • setupLicenseMiddleware(app)      — registers activation gate + Pro
                                       feature gates (must run before any
                                       /api/* route)
  • setupLicenseRoutes(app)          — /api/license-status, /api/license/
                                       activate, /api/license/deactivate
  • startLicenseRefresh()            — startup + 6h periodic online check
  • refreshLicenseOnline(reason)     — ad-hoc refresh (e.g., during activate)
  • isFreeUser()                     — 'no license || no core entitlement'
  • tryAcquireFreeSlot() / releaseFreeSlot() — the free-tier concurrency
                                                 lock previously open-coded
                                                 in /api/process

Local 'const isFreeUser = ...' in /api/process renamed to 'isFree' to
avoid shadowing the imported helper. Open-coded freeJobInFlight reads/
writes replaced with the slot helpers.

server/index.js: 2461 → 2300 lines.

Smoke tested: server boots; /api/license-status, /api/health, /api/
process (rejects with 400 'No API key' as expected for unlicensed +
no key) all behave as before.
2026-05-08 17:05:35 -05:00
Keysat 7ab2a3249a Module split: extract API key + live reload to server/config.js
• initConfig({ dataDir })           — boot-time setup; mkdir's configDir,
                                        reads initial value, kicks off the
                                        3 s poll loop
  • serverApiKey                      — exported as a 'let' binding (ESM
                                        live binding) so importers see the
                                        current value
  • resolveApiKey(clientKey)          — picks per-request key (BYO vs
                                        server)
  • getEnvPath()                      — exposes /data/.env path so the
                                        cookies module can read its own
                                        legacy YT_COOKIES_FROM setting

Bug fix uncovered during this extraction: /api/health was directly
referencing ytCookiesFileExists / ytCookiesFilePath (module-scoped
vars I'd already moved to cookies.js). The route silently 500'd on the
first request after the cookies extraction. Now uses ytCookieMethod()
and getCookieFilePath() instead.

server/index.js: 2510 → 2461 lines.

Smoke tested: server boots; /api/health responds; updating
/data/config/startos-config.json flips hasServerKey from false → true
within 3 s ([config] server API key loaded log line confirms).
2026-05-08 17:01:45 -05:00
Keysat 2c655dc9ee Module split: extract cookies state + helpers + routes to server/cookies.js
• initCookies({ dataDir, envPath })  — boot-time setup; reads .env's
                                         YT_COOKIES_FROM and probes for
                                         cookies.txt
  • ytCookieArgs() / ytExtraArgs()     — yt-dlp arg builders
  • ytCookieMethod()                   — human-readable active method
  • setupCookieRoutes(app)             — registers the four /api/cookies/*
                                         routes (upload / delete / test /
                                         status)

Module owns its private state (browser-name, file-exists, file-path).
Upload and delete routes flip the file-exists flag inside the same
module, so subsequent yt-dlp calls reflect the change immediately
without callers re-reading.

server/index.js: 2614 → 2510 lines.

Smoke tested: server boots; /api/license-status and /api/health
respond. /api/cookies/status returns the existing license-gated 402
for unlicensed callers (unchanged behavior).
2026-05-08 16:57:03 -05:00
Keysat 9a82fede7a Module split: extract yt-dlp lifecycle helpers to server/ytdlp.js
• checkYtdlp()                  — version + GitHub-latest check (24h-cached)
  • autoUpdateYtdlp(dataDir)      — multi-strategy update (-U, pip, brew, binary)

Module-private memoization (ytdlpVersion, ytdlpLastCheck) now stays
inside ytdlp.js where it belongs. autoUpdateYtdlp gained an explicit
dataDir parameter — strategy 4 (StartOS binary download to /data/bin/)
needs it; passing it in keeps the module pure of caller-side state.

server/index.js: 2694 → 2614 lines.

Smoke tested: server boots; /api/license-status, /api/health respond.
No behavior change.
2026-05-08 16:54:51 -05:00
Keysat 4c3cb6a077 Module split: extract audio I/O helpers to server/audio.js
• getAudioDuration(path)         — ffprobe wrapper, returns seconds | null
  • splitAudioFile(in, dir, secs)  — ffmpeg -acodec copy chunking
  • downloadPodcastAudio(url, dst) — streams HTTP audio to disk

Also moved fetchUrl into util.js (alongside the other stateless
helpers) — it's a generic HTTP-GET-with-redirects used by RSS parsing
and channel discovery, not strictly audio.

server/index.js: 2758 → 2694 lines.

Smoke tested: server boots; /api/license-status, /api/health, /
respond. No behavior change.
2026-05-08 16:53:06 -05:00
Keysat 1c78e46ebd Module split: extract Gemini-specific helpers to server/gemini-helpers.js
• PRICING table             — per-1M-token rates by model
  • calcCost(model, usage)    — Gemini usage object → cost record
  • buildAnalysisPrompt(...)  — JSON-output topic-analysis prompt

These all share the Gemini contract — pricing schema, usage shape, and
prompt format. When we add other providers, each gets its own
provider-specific helpers file; this becomes the basis of the Gemini
provider implementation.

server/index.js: 2828 → 2758 lines.

Smoke tested: server boots; /api/license-status, /api/health, and /
(frontend) all respond. No behavior change.
2026-05-08 16:50:34 -05:00
Keysat ffc8c31130 Module split: extract pure helpers to server/util.js
First step of breaking up the 2914-line server/index.js. Pulled out the
zero-state, no-side-effects helpers:

  • sendEvent(res, event, data)         — writes one SSE frame
  • extractVideoId(url)                 — YouTube URL → 11-char id
  • formatTime(seconds)                 — seconds → 'M:SS' or 'H:MM:SS'
  • parseTimestampedTranscript(text)    — Gemini transcript text → entries[]
  • safeText(result)                    — robust .text getter for Gemini responses
  • retryGemini(fn, opts)               — 503/429/network retry with linear backoff

server/index.js: 2914 → 2828 lines.
server/util.js : new, 113 lines.

Smoke tested: server boots, /api/license-status responds. No behavior
change.
2026-05-08 16:48:40 -05:00
Keysat 3bd50f8429 Bump version to 0.2.2
Live-reload of the Gemini API key, and fix a startup-crash bug in
0.2.0 / 0.2.1 where the vendored keysat client could not resolve its
@noble/* deps inside the running container.
2026-05-08 16:38:44 -05:00
Keysat b5a066750a Live-reload Gemini API key config + fix vendor module resolution
Two related changes that ship together because the second was uncovered
while testing the first.

1. Live config reload (the ostensible feature):

   The "Set Gemini API Key" StartOS action writes to /data/config/
   startos-config.json. The server used to read that file once at
   startup (and via a separate Python read in docker_entrypoint.sh
   before that), which meant a key change required a service restart
   to take effect. Now the server polls the file every 3 s
   (RECAP_CONFIG_POLL_MS, env-overridable) and updates serverApiKey
   in place. fs.watch was tried first and dropped — it's flaky on
   macOS (FSEvents single-file quirks) and behaves inconsistently with
   atomic-rename writes the SDK file model uses. Polling is dead
   simple and a stat call every 3 s is free.

   Also dropped the Python config read from docker_entrypoint.sh; the
   server now handles it natively. Entrypoint still loads /data/.env
   for arbitrary env vars (RECAP_*, etc.).

2. Vendor module resolution (the silently-broken thing):

   The earlier vendor change (move @keysat/licensing-client from a
   git+https dep to a file: dep at vendor/) created a symlink in
   server/node_modules. That symlink to the vendor dir was getting
   resolved by Node, so the keysat client tried to import @noble/
   ed25519 from /app/vendor/keysat-licensing-client/dist/, walked up
   to /app/vendor/, then /app/, neither of which had node_modules.

   Result: v0.2.0 and v0.2.1 would crash at startup with
   ERR_MODULE_NOT_FOUND on @noble/ed25519. The Docker BUILD succeeded
   because npm install with file: deps doesn't pull transitive deps
   into the parent node_modules — but the runtime would have failed
   the moment server/license.js ran.

   Fix:
     • Dockerfile builder now `npm install`s inside vendor/keysat-
       licensing-client/ so @noble/* lands in its own node_modules,
       where Node's resolver finds it.
     • Dockerfile runner now COPYs vendor/ to the runner image
       (previously not copied — the symlink in server/node_modules
       would have pointed at nothing).
     • vendor/keysat-licensing-client/package-lock.json is committed
       so the in-Docker install is reproducible.
2026-05-08 16:38:33 -05:00
Keysat eb152cc97c Bump version to 0.2.1
Reword short description on the registry card.
2026-05-08 14:13:44 -05:00
Keysat 64a04cdd7f Reword short description to lead with the output, not the tech
Old: Download, transcribe, and summarize YouTube videos and podcasts
     with AI.
New: Turn videos and podcasts into structured topic summaries with
     clickable timestamps.

Updated in both the StartOS manifest (startos/manifest/i18n.ts) and
the registry card (startos-registry/packages/recap/package.json).
2026-05-08 14:13:44 -05:00
Keysat 8aaa405843 Vendor @keysat/licensing-client to avoid private-repo auth in Docker build
The keysat-client-ts repo is private. Previous builds were succeeding
purely because Docker layer caching reused a node_modules from when
the repo had been accessible — once anything invalidated the
server/package.json or server/package-lock.json hash (the rename did),
npm in a fresh container hit github with no credentials and 404'd.

Fix: copy the built dist/ from server/node_modules/@keysat/licensing-
client/ into vendor/keysat-licensing-client/, strip the prepare/build
scripts (we already have the compiled output), and switch the server
package.json dep to a file: path:

  "@keysat/licensing-client": "file:../vendor/keysat-licensing-client"

Dockerfile now COPY's vendor/ before npm ci. No git, no SSH, no
credentials needed in the build container — and the npm step is
pure-local so it's deterministic.

Side cleanup: dropped the apt-install-git + url.insteadOf gymnastics
that existed solely to work around the now-removed git+https resolution.
The image is slightly smaller (no git in the builder stage). Switched
the npm flag to the modern --omit=dev (the legacy --production printed
a warning).

If keysat-client-ts updates, regenerate vendor/ by:

  cp -r server/node_modules/@keysat/licensing-client/{dist,package.json,LICENSE,README.md} \
        vendor/keysat-licensing-client/
  # then strip prepare/build scripts and devDeps from the copied package.json
  # (or just hand-edit if the upstream package.json hasn't changed)
2026-05-08 13:45:12 -05:00
Keysat 2c2ccfae05 Bump version to 0.2.0
Renamed to Recap. New StartOS package id, new Keysat product slug,
new display name everywhere user-visible. StartOS will treat this as
a fresh install.
2026-05-08 13:35:47 -05:00
Keysat 9282440143 Rename project: youtube-summarizer → recap
The product was always more than YouTube — it handles podcast feeds
too, and the upcoming multi-provider work makes it less Gemini-
specific. New name: Recap.

This is a coordinated identity change across:

  • StartOS package id: youtube-summarizer → recap
    (manifest.id; the .s9pk filename, Docker image namespace, and
    install path under StartOS all derive from this automatically)
  • Display name: "YouTube Summarizer" → "Recap"
    (manifest title, activation screen heading, page <title>, console
    log on boot, i18n strings, ABOUT.md, Dockerfile header,
    docker_entrypoint banner)
  • Keysat product slug: youtube-summarizer → recap
    (server/license.js PRODUCT_SLUG; frontend fallback strings)
  • Daemon subscription id: youtube-summarizer-sub → recap-sub
  • Env var prefix: YT_SUMMARIZER_* → RECAP_*
    (LICENSE_KEY, LICENSE_KEY_PATH, MAX_OFFLINE_DAYS,
    VALIDATE_INTERVAL_MS)
  • localStorage keys: yt-summarizer-* → recap-*
    (gemini-key, activation-skipped, clips)
  • Library export filename: youtube-summarizer-library.json →
    recap-library.json
  • npm package names: youtube-summarizer-{startos,server} → recap-*
  • Deploy paths: youtube-summarizer_x86_64.s9pk → recap_x86_64.s9pk
    (default values in bin/deploy.sh; .deploy.env on dev machine
    needs the same update before next push)
  • Self-hosted registry directory: startos-registry/packages/
    youtube-summarizer → .../recap (with package.json + INSTRUCTIONS
    rewritten)

What does NOT change:
  • Filesystem repo path (still /Users/.../youtube-summarizer/)
  • Git history / commit messages
  • Existing version files in startos/versions/ (kept as-is — the
    version chain belongs to the package's own history regardless of
    its display name)

User-side follow-ups required:
  1. Create "recap" product in Keysat admin, set up Core/Pro tier
     policies (same entitlements as before), mint a fresh test
     license. Old "youtube-summarizer" licenses won't activate
     against the new slug.
  2. Update .deploy.env (gitignored) so FILEBROWSER_PATH and
     REGISTRY_PUBLIC_URL point at recap_x86_64.s9pk.

StartOS will treat this as a brand-new app on install — existing
youtube-summarizer installs will not auto-migrate (acknowledged
intentional given no real users yet).
2026-05-08 13:35:27 -05:00
Keysat 1aaa7a453a Bump version to 0.1.18
Fix Settings modal failing to open for licensed users.
2026-05-08 13:00:29 -05:00
Keysat 8a519ee25d Fix Settings modal crash: send licenseId as string, not Uint8Array
The Keysat client's payload exposes both:
  • licenseId    — raw 16-byte UUID as Uint8Array
  • licenseUuid  — same value, canonical string form

server/license.js was sending licenseId (the Uint8Array). After JSON
serialization that turned into an object like {"0":1,"1":2,...} on
the wire. The frontend's renderLicenseBlock() calls
`lic.licenseId.slice(0, 8)` to abbreviate the ID for display — .slice
doesn't exist on that object, so the template threw, the
app.innerHTML assignment silently aborted, and clicking the gear
looked like a no-op.

The render error guard added in 0.1.17 caught it on first repro:
  Render error: lic.licenseId.slice is not a function

Fix: switch to payload.licenseUuid (the string form).
2026-05-08 13:00:29 -05:00