• 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.
• 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.
• 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).
• 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).
• 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.
• 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.
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.
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.
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).
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)
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.
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).
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).
The cost breakdowns (token counts + dollar figures) are tied to the
hardcoded Gemini PRICING table, which won't make sense once we add
OpenAI/Claude/local providers — different APIs report tokens
differently, some are free, and the pricing table can't keep up. Drop
the cost and token-count lines from the activity log so the per-step
timing is the durable signal we keep.
Removed:
• "Total cost: <in> in / <out> out — cost: $X" (chunked transcription)
• "Transcription tokens: <in>/<out> — cost: $X" (single-shot)
• "Analysis tokens: <in>/<out>/<thinking> — cost: $X"
• "Pipeline finished in Ns — total cost: $X (Y tokens)" → now just
"Pipeline finished in Ns"
Cost calculation helpers (calcCost, PRICING) stay in the codebase for
now — they may come back via a per-provider plugin layer later. They
just no longer write to the activity-log stream.
Two bugs reported in 0.1.16 testing:
1. Library button shows a toast "Library is a paid feature ... Tap
upgrade to unlock" but no Upgrade button is visible anywhere on the
page. The free-tier banner only rendered when isLicensed() was false,
so a licensed-but-missing-entitlements user got the toast directing
them at an invisible button.
2. Clicking the gear (Settings) icon does nothing — the modal never
appears. The Activity Log button works on the same page.
Fix 1: replace the unlicensed-only renderFreeBanner with renderUpgrade
Banner driven by shouldShowUpgradeBanner() = !isProTier(). Copy
adapts by tier:
• Free: "Free mode · one video at a time · no library, no subs"
• Limited license (missing core entitlements): "Limited license
— your license is missing some Core features"
• Core tier: "Core tier — upgrade to Pro for subs, auto-queue,
clips"
Pro tier shows nothing.
Fix 1b: rewordtoasts on Library / Subscribe clicks to drop the
"tap Upgrade" instruction — the persistent banner is the action
path now, no need to direct users at a maybe-not-there button.
Fix 2 (diagnostic): wrap the main app.innerHTML build in try/catch.
A thrown exception in any of the ${...} template substitutions
silently aborts the innerHTML assignment, leaving the previous
DOM in place — which looks exactly like a button doing nothing
when it actually fired and called render(). Now an exception
lands in a visible error-box with the message + console trace,
so the next reproduction tells us what's actually throwing.
Release notes:
Add free mode for unlicensed users (one video at a time, no library).
Online license revocation check via Keysat /v1/validate.
Fix blank-screen bug after processing long videos.
Ship deploy-script polish (no double-bump prompt).
The previous free-tier commit (c0975fe) blocked USE_SERVER_KEY for
unlicensed users on the theory that this protected a "bundled key."
That conflated two different things:
• USE_SERVER_KEY = the user's OWN Gemini key, just stored server-side
via the StartOS configuration action (vs. browser localStorage).
Both paths are BYO — the user pays Google directly either way.
• Bundled key = a future relay where paid users' /api/process requests
are proxied through the operator's service and the operator absorbs
the API cost. Sketched in UPGRADE-DESIGN.md (deleted, in git history)
but NOT YET BUILT.
Blocking USE_SERVER_KEY broke a legitimate flow: a free user installs
the app on their own StartOS, sets their Gemini key via the config
action, then summarizes from the web UI without re-entering it.
This commit:
• Drops the BYO/USE_SERVER_KEY rejection in /api/process. Free users
can use a key from either path; the existing `if (!apiKey)` check
still catches the no-key-anywhere case with a helpful message.
• Reverts the frontend submit-button and handleSubmit checks to the
same key requirement for both tiers (state.apiKey OR state.hasServerKey).
• Drops "bundled API key" from the activation-screen subtitle and
"bring your own Gemini key" from the free-mode banner. Until the
relay is built, paid users still BYO too — promising otherwise in
upgrade copy is misleading.
• Keeps the parts that ARE legitimate free-vs-paid differentiators:
the one-at-a-time concurrency lock and skipping saveToHistory.
Also fixes the `make deploy` redundancy:
• bin/bump-version.sh accepts --from-deploy. When set, if there is no
.release-notes-pending.txt (consumed by a prior bump or never
written), exit 0 without prompting — the current version is already
fresh.
• Makefile passes --from-deploy from the deploy target. Standalone
`make bump` is unchanged (always prompts).
Result: `make bump` then `make deploy` no longer double-prompts. And
calling `make deploy` twice in a row (no new work) is idempotent on
the bump step.
Unlicensed users can now summarize a single video at a time using their
own Gemini API key. The result renders in the UI exactly like a paid
summary, but is not persisted — there's no library entry, no history,
and a second submission while one is in flight is rejected.
Server (server/index.js):
• /api/process is now in LICENSE_OPEN_PATHS. The route handler
distinguishes free users (state !== "licensed" || no "core") and:
- rejects USE_SERVER_KEY / empty key with 402 byo_key_required
(so the bundled Gemini key stays paid-only)
- rejects a second concurrent job with 409 processing_in_progress
via a module-level freeJobInFlight flag, released in finally
- skips saveToHistory so the host's library stays clean
• Pro feature gates (history/library/subscriptions) unchanged — still
return 402 feature_not_in_tier for unlicensed callers.
Frontend (public/index.html):
• New state.activationSkipped flag (persisted to localStorage). The
activation screen still appears on first launch, but now offers a
"Skip — use free mode" button alongside Activate / Buy a key.
Once skipped, the main app renders normally.
• Free-mode upgrade banner under the top bar with Upgrade and "I have
a key" buttons (the latter routes back to the activation screen).
• handleLibraryClick / handleSubscribeClick wrappers — for unlicensed
users, the library (clock) icon and the channel-URL Subscribe
submission show a toast explaining the upgrade rather than opening
an empty sidebar / hitting a 402.
• Submit button enforces BYO key for unlicensed users (the bundled
state.hasServerKey doesn't enable submit). handleSubmit shows a
toast when an unlicensed user tries to queue a second video.
Symptom: after a long video finishes processing, the screen goes blank
even though the video and chunks save correctly to history (visible
after a refresh + library click).
Cause: the manual SSE parser in processUrl declared `let eventType = ""`
inside the `while (await reader.read())` loop, so the variable reset on
every chunk. The server emits each event as a single write of
`event: X\ndata: Y\n\n`, but for a long video the result payload (entries
+ chunks + logs) is tens of KB and gets split across reader chunks by
the browser. When the split landed between the `event: result\n` line
and the `data: ...` line, the event type was lost and `handleSSE("",
data)` matched no branch — the result event was silently dropped, and
state.chunks stayed at the empty array set during processUrl reset.
Fix: hoist eventType outside the loop so it persists across chunks, and
reset it after each dispatch (per the SSE spec, event type returns to
default after an event is fired).
Short videos with small result payloads fit in a single chunk and so
were unaffected — which is why the bug looked intermittent.
Without this, a license revoked in the Keysat admin UI keeps unlocking
the app on the customer's machine — Ed25519 signatures are perpetually
valid, so the offline-only check never sees the revocation.
What this adds:
• license.js: validateOnline() calls licensing.keysat.xyz/v1/validate
via @keysat/licensing-client's Client. Hard rejections (revoked,
suspended, expired, not_found, product_mismatch, fingerprint_mismatch,
too_many_machines, invalid_state) immediately flip state to "invalid"
and persist the verdict to <license>.state.json so it survives
restarts. rate_limited and unknown reasons are treated as transient.
• Network errors keep the prior state for up to MAX_OFFLINE_DAYS
(default 7, env-overridable) since the last successful validate.
Past the ceiling, lock out with reason=validation_overdue. This
avoids breaking customers when Keysat is briefly down while still
catching revocations on machines that go offline forever.
• license.js: deactivate() helper that removes both license.txt and
its sidecar state file (idempotent). publicView() now exposes
lastValidatedAt, serverStatus, graceUntil for the UI.
• index.js: refreshLicenseOnline() runs on startup (async, non-
blocking), every 6h thereafter (env-overridable), and at activation
time with an 8s timeout cap so a slow Keysat doesn't hang the
activation UI. State changes are logged.
• index.js: /api/license/activate now awaits an online confirmation
after the offline signature check passes. A revoked key pasted into
the activation modal fails fast instead of working until the next
poll.
Snapshot of the working tree before cleanup. Captures:
- Keysat licensing: server/license.js, /api/license/* endpoints in
server/index.js, activation modal in public/index.html, embedded
Ed25519 issuer key (assets/issuer.pub).
- StartOS 0.4 expansion: setApiKey action, version files v0.1.1
through v0.1.15, file-models/config.json.ts, manifest updates.
- Self-hosted registry server (startos-registry/).
- Build/deploy scripts (bin/bump-version.sh, bin/deploy.sh, vendored
yt-dlp binary), .gitignore, .deploy.env.example.
- Recent design docs (KEYSAT_INTEGRATION.md, UPGRADE-DESIGN.md) —
retained here so they remain recoverable when removed in the
follow-up cleanup commit.