Compare commits

...
Author SHA1 Message Date
Frank 18a29c40d2 docs(openwebui): TTS config reflects the live frank-tts wiring
The Text-to-speech section recorded audio.tts base_url host.containers.internal
and voice narrator1. Re-keyed the live config DB 2026-10-20: voice wilwheaton
(narrator1 retired), api_base_url the canonical host LAN IP 192.168.0.143:7772.
7772 is published so host.containers.internal also reaches it, but the LAN IP
matches this page's own on-host law. Browser-kokoro example voice updated to the
current pack; the dated 2026-10-18 verification is left as history.
2026-10-20 18:18:01 -07:00
Frank 130e9d739f chore(frank-tts): repoint narrator slots to the live voice packs
Frank replaced the narrator1/2/3 packs with cloned character voices
(wilwheaton, vaati, dr.kern, garrickhagon, jeffhays). The three narrator
slots are now carried by real packs: narrator1=wilwheaton, narrator2=vaati,
narrator3=dr.kern.

- frank-tts.env DEFAULT_VOICE narrator1 -> wilwheaton (a voice-less request
  400'd on the dead name; wilwheaton is narrator1's successor).
- Docs: correct the /health census and the podcast-speaker references in
  Frank TTS.md and Open Notebook.md to the live packs. Dated 2026-10-18/19
  measurement logs are left as history.

Open Notebook's live dank-duo speaker profile was repointed over REST
(Frank->wilwheaton, Comrade->vaati) - app-side, not in this commit.
Bats fixtures keep narrator1 as a self-contained sample pack; untouched.
2026-10-20 18:11:40 -07:00
dank 854890a155 Merge pull request 'chore(open-notebook): admin-side positioning + single-tenant note' (#49) from chore/open-notebook-admin-tenant into main 2026-10-20 17:44:45 -07:00
Frank d9f3d1be1e chore(open-notebook): position as admin-side; document single-tenant
open-notebook has no user accounts upstream (PasswordAuthMiddleware = one
shared password; SurrealDB has no user/account/owner table — verified in the
running image). One shared password = one shared workspace; the guard chain
gates access but cannot partition data, and Authentik identity is never
consumed. Multi-user is approved-but-unbuilt upstream (#712, post-v2); OIDC
declined (#607). So move the homepage card from the user grid to the admin
tier and add the single-tenant boundary to the vault page. Fix the card's
stale 'frank-zealot' -> frank-archon. Update the seam's incidental category
pin to the new intentional value. Password rotated on the host (not repo).
2026-10-20 17:38:22 -07:00
dank afa3a4521c Merge pull request 'docs(authentik): record 7-day login-session TTL + setter script' (#48) from chore/authentik-session-ttl into main 2026-10-20 16:57:29 -07:00
Frank 9cc8ca1c88 docs(authentik): record the 7-day login-session TTL and add a setter script
The login-session lifetime (raised to days=7 on 2026-10-20) is owned by the
default-authentication-login stage's session_duration, not a global setting and
not SESSION_COOKIE_AGE. Authentik exposes no SESSION_SAVE_EVERY_REQUEST, so the
window is absolute. session_duration is a STRING idiom; a timedelta stores a form
timedelta_from_string rejects, crashing login. Add scripts/authentik-set-login-session-duration.sh
that validates the form before writing (DB state, not repo-managed, re-run after
a blueprint re-import). Trim the page back under the OKF 500-line cap. tests/run.sh green.
2026-10-20 16:55:44 -07:00
dank 9c05c40aa0 Merge pull request 'docs(agent): scaffold agent-skills config in the vault' (#47) from chore/agent-skills-vault-setup into main 2026-10-20 16:11:47 -07:00
8 changed files with 84 additions and 23 deletions
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Set the authentik login-session TTL on Dankclaw.
#
# The login-session lifetime is governed by the `default-authentication-login`
# stage's `session_duration`, NOT a global setting and NOT SESSION_COOKIE_AGE
# (that is the pre-login session, sessions.unauthenticated_age). Authentik does
# not expose SESSION_SAVE_EVERY_REQUEST at all, so the session is an absolute
# window from login, not a sliding one.
#
# The field is a STRING in authentik's own idiom ("days=7", "seconds=30",
# "hours=12") — not a Python timedelta. Writing str(timedelta(days=7)) stores
# "7 days, 0:00:00", which authentik's timedelta_from_string parser REJECTS
# (ValueError), and login then crashes computing the session expiry. Always pass
# the "key=value" form; this script validates it before writing.
#
# Existing sessions keep their old expiry; the new TTL applies from the next
# login. This is DB state — it is NOT repo-managed and a blueprint re-import or
# rebuild-from-scratch reverts it. Run this again after such a rebuild.
#
# Usage: authentik-set-login-session-duration.sh ["days=7"] (default days=7)
set -euo pipefail
DUR="${1:-days=7}"
STAGE="default-authentication-login"
HOST="${AUTHENTIK_HOST:-dankclaw}"
# Refuse anything that is not the safe key=value duration idiom.
if ! printf '%s' "$DUR" | grep -Eq '^[A-Za-z]+=[0-9]+([ ]+[A-Za-z]+=[0-9]+)*$'; then
echo "invalid duration '$DUR' — use authentik form, e.g. 'days=7' or 'seconds=30'" >&2
exit 2
fi
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
from django.apps import apps
from authentik.lib.utils.time import timedelta_from_string
dur = "$DUR"
timedelta_from_string(dur) # validate the form before touching the row
ULS = apps.get_model("authentik_stages_user_login", "UserLoginStage")
s = ULS.objects.get(name="$STAGE")
s.session_duration = dur
s.save(update_fields=["session_duration"])
back = ULS.objects.get(name="$STAGE").session_duration
assert back == dur, "persisted value mismatch: %r" % back
print("session_duration set to", repr(back), "->", timedelta_from_string(back))
PYEOF
echo "Login-session TTL for '$STAGE' is now $DUR (applies on next login)."
+2 -2
View File
@@ -7,7 +7,7 @@
# whichever TTS engine wins the engine decision (dankclaw/voiceservice/app.py
# is a single stdlib-only file in a locally built image). Consumers — omp's
# TTS device, OpenWebUI, open-notebook — address a VOICE BY NAME
# (voice=narrator1, or the harness' voice_id=narrator1) and never see a path:
# (voice=wilwheaton, or the harness' voice_id=wilwheaton) and never see a path:
# a name is real exactly when /voices/<name>/reference.wav exists, and
# anything else is a 400 that lists the names that are.
#
@@ -65,7 +65,7 @@ TTS_UPSTREAM_MODEL=voxcpm2
# name still 400s with the list that exists — this covers the absent case only —
# and the journal marks such a line `(default)` so an unrequested voice never
# reads as a requested one. Any pack directory in /home/dank/voices is legal.
DEFAULT_VOICE=narrator1
DEFAULT_VOICE=wilwheaton
# CLI-backend knobs, inert while TTS_BACKEND=omni-http. Empty means "not
# configured": the endpoint answers a 500 that says so instead of running
+9 -8
View File
@@ -21,7 +21,7 @@ never learn a path.
**Deployed 2026-10-18** — `./scripts/deploy.sh dankclaw --service frank-tts`
both units `active`, no drop-ins, and the homepage card's `wip` gate came off in
the same change. Probes on the deployed endpoint: `/health` answers
`{"status":"ok","backend":"omni-http","voices":["narrator1","narrator2","narrator3"]}`
`{"status":"ok","backend":"omni-http","voices":["dr.kern","garrickhagon","jeffhays","vaati","wilwheaton"]}`
and two different voices rendered the same sentence into **different** bytes
(24 kHz mono PCM16, 4.24 s, healthy RMS) — so name resolution is real, not a
pass-through of one default. The operator harness routes its `speech` role here;
@@ -42,8 +42,8 @@ render needs the app installed, not this endpoint.
## Naming a voice
A voice **is** a directory name. `voice=narrator1` resolves to
`/voices/narrator1/reference.wav`, and that file — not a manifest, not a config
A voice **is** a directory name. `voice=wilwheaton` resolves to
`/voices/wilwheaton/reference.wav`, and that file — not a manifest, not a config
entry — is what makes the name real. omp's TTS device may send `voice_id`; the
shim accepts either. A call that sends **neither** — the harness's normal shape,
because no harness setting carries a voice for this transport — speaks
@@ -59,10 +59,11 @@ loudly instead of letting it half-work.
Names are user-visible: an unknown voice comes back as
`400 unknown voice 'x'; available voices: …`, and that list is the honest
census. The podcast speakers are `narrator1`, `narrator2`, `narrator3` —
[[Open Notebook]] is configured with exactly those three, so renaming them
breaks episode rendering, not just speech. A personal voice gets the person's
name, lowercase.
census. As of 2026-10-20 the live packs are `dr.kern`, `garrickhagon`,
`jeffhays`, `vaati`, `wilwheaton`. [[Open Notebook]]'s `dank-duo` profile speaks
`wilwheaton` (Frank) and `vaati` (Comrade), and `DEFAULT_VOICE` is `wilwheaton`.
Renaming a pack that a speaker profile names breaks episode rendering, not just
speech. A personal voice gets the person's name, lowercase.
## Voice packs
@@ -221,5 +222,5 @@ harness bundle and measured the day of the flip:
| Endpoint answers, synthesis fails | the engine: `podman healthcheck run frank-tts-engine`, then `systemctl --user status frank-tts-engine.service` |
| First request of the day is ~0.6 s slower than the rest — or every name returns 400 | the warm pass did not run; it carries `-`, so it cannot fail the unit. `journalctl --user -u frank-tts-engine` for the warmer's lines, then `podman exec frank-tts-engine /app/warm-voices.sh` — the registry is memory-only (law 1 of [[Frank TTS Engine]]) |
| The unit never appears after a deploy | the generator dropped it: `frank-tts-engine.network` is missing from `~/.config/containers/systemd/` |
| A consumer rejects `narrator1` by listing 29 names (`af_heart … bm_fable`) and no request ever reaches this endpoint | that consumer is synthesizing **in the browser** with Kokoro.js, whose voice pack is fixed — OpenWebUI 0.11.4 ships it as the `browser-kokoro` TTS engine; see [[OpenWebUI]] |
| A consumer rejects `wilwheaton` by listing 29 names (`af_heart … bm_fable`) and no request ever reaches this endpoint | that consumer is synthesizing **in the browser** with Kokoro.js, whose voice pack is fixed — OpenWebUI 0.11.4 ships it as the `browser-kokoro` TTS engine; see [[OpenWebUI]] |
---
@@ -45,6 +45,8 @@ open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook))
The app's own login stays on behind the chain. It is not redundant: publishing the API on loopback does **not** hide it from this host's other containers, which can dial `open-notebook:5055` by name on `dankclaw_default`. The chain decides *who from outside* gets in; `OPEN_NOTEBOOK_PASSWORD` decides what anything that already reached the port may do.
**Single tenant — there are no user accounts, so this is an admin-side tool.** Upstream ships one global password and nothing else: `PasswordAuthMiddleware` compares `Bearer <OPEN_NOTEBOOK_PASSWORD>`, and the SurrealDB schema has no user/account/owner table (verified in the running image, 2026-10-20). One shared password therefore means **one shared workspace** — the guard chain can gate the door but cannot partition the data, and Authentik identity is never consumed. Multi-user is *approved but unbuilt* upstream (issue **#712**, "design phase, targeted post-v2"); OIDC/SSO was declined for now (**#607** closed). Real per-person isolation means **one deployment per person**, not a flag to flip. For that reason the homepage lists open-notebook under **admin**, not as a user-facing app.
## What the image actually runs
One container, three processes under supervisord (`supervisord.conf` upstream): `uvicorn api.main:app --port 5055` (`:12`), the job worker (`:25`), and the Next.js frontend (`:39`). Two consequences the config depends on:
@@ -68,7 +70,7 @@ Credentials live in the database, encrypted with `OPEN_NOTEBOOK_ENCRYPTION_KEY`,
| Model row `voxcpm2` | type `text_to_speech` on the frank-tts credential. The name is decorative: frank-tts ignores the request's `model` field and synthesizes whatever `voice` names ([[Frank TTS]]) |
| Model row `frank-archon` | type `language`; `PUT /api/models/defaults` points chat, transformation and large-context at it |
| Model row `qwen3-embedding-0.6b` | type `embedding` on the NPU-lane credential; `default_embedding_model` points at it, so every note save embeds through the XDNA2 lane ([[frank-dragoon]]) |
| Speaker profile `dank-duo` | two speakers, Frank → `voice_id: narrator1`, Comrade → `narrator2`, `voice_model` = the TTS row |
| Speaker profile `dank-duo` | two speakers, Frank → `voice_id: wilwheaton`, Comrade → `vaati`, `voice_model` = the TTS row |
| Episode profile `dank-daily` | `num_segments: 4`, `speaker_config` = `dank-duo`, outline and transcript LLM = the archon row, briefing that ends "Never invent numbers." |
Order that works over REST: credentials → model rows (`POST /api/models` with `credential: <credential id>`) → `PUT /api/models/defaults` → speaker profile (`voice_model: <model id>`) → episode profile (`speaker_config` / `outline_llm` / `transcript_llm`). `POST /api/models/{id}/test` proves each row: the language row answered with a real completion from frank-archon; the TTS row reached frank-tts and came back `unknown voice 'alloy'; available voices: narrator1, narrator2, narrator3` — the app's own test path hard-codes an OpenAI voice, so that message *is* the proof the leg works.
@@ -106,13 +108,13 @@ A **speaker profile** carries one to four speakers (`docs/3-USER-GUIDE/creating-
| Speaker | Voice ID to type |
|---------|------------------|
| Speaker 1 | `narrator1` |
| Speaker 2 | `narrator2` |
| Speaker 3 | `narrator3` |
| Speaker 1 | `wilwheaton` |
| Speaker 2 | `vaati` |
| Speaker 3 | `dr.kern` |
Those are the pack names frank-tts resolves from `/home/dank/voices` (`scripts/frank-tts.env` header: a voice is real exactly when `/voices/<name>/reference.wav` exists). Two traps, both from upstream's shipped defaults:
- The three seeded speaker profiles carry **OpenAI voice names** (`nova`, `alloy`, …) and **no voice model at all** — they show a "Setup required" badge, and generation fails with a voice error until they are edited (`creating-podcasts.md:13`, `:33`; `local-tts.md:89`). Add a `narrator1`/`narrator2` pair (or a third/fourth) rather than editing the seeded ones in place.
- The three seeded speaker profiles carry **OpenAI voice names** (`nova`, `alloy`, …) and **no voice model at all** — they show a "Setup required" badge, and generation fails with a voice error until they are edited (`creating-podcasts.md:13`, `:33`; `local-tts.md:89`). Add a `wilwheaton`/`vaati` pair (or a third/fourth) rather than editing the seeded ones in place.
- Two speakers with the same voice are hard to follow (`creating-podcasts.md:89`). Distinct packs per speaker is the point of the 1–4 shape.
## Two buttons that fail against frank-tts, and why that is cosmetic
+8 -5
View File
@@ -290,9 +290,12 @@ on large sizes ⇒ check ComfyUI resources; 401 ⇒ the token is bad.
## Text-to-speech (frank-tts)
TTS is the fleet's one OpenAI-compatible speech endpoint —
`http://host.containers.internal:7772/v1` (`audio.tts.openai.api_base_url`
in the config DB, 2026-10-18), model `frank-tts`, voice `narrator1`. That
TTS is the fleet's one OpenAI-compatible speech endpoint. The config DB
`audio.tts.*` is set to engine `openai`, `audio.tts.openai.api_base_url`
`http://192.168.0.143:7772/v1`, `audio.tts.voice` `wilwheaton` (re-keyed
2026-10-20 from the retired `narrator1`; 7772 is a published port so
`host.containers.internal` also reaches it, but the LAN IP is the canonical
on-host form). That
port is the rootless quadlet `frank-tts` (this repo:
`dankclaw/voiceservice/quadlet/frank-tts.container`
`scripts/frank-tts.env`), and its shim resolves `voice=<name>` against
@@ -306,8 +309,8 @@ ships an in-page Kokoro implementation (chunk carrying a frozen voice table
`onnx-community/Kokoro-82M-v1.0-ONNX`, plus a personal *Kokoro.js dtype*
setting). Its engine select takes `""` (Default) or `browser-kokoro`; when
`browser-kokoro` is chosen the page synthesizes locally and rejects any
voice outside its fixed pack — so `narrator1` fails with
`Voice: "narrator1" not found. Should be one of: af_heart, …` **without a
voice outside its fixed pack — so `wilwheaton` fails with
`Voice: "wilwheaton" not found. Should be one of: af_heart, …` **without a
single request reaching the server**. Personal audio settings are
browser: no `user.settings` row and no container env carries an audio
override, yet the toast still appears ⇒ the choice lives in that browser's
@@ -267,6 +267,12 @@ The silent re-auth path was proven end-to-end: after deleting
`qa-e2e-admin`'s rows, the e2e admin spec ran against the stale storage
state and passed with no login prompt (2 tests, 2.9 s).
## Login-session lifetime (how often you re-authenticate)
How long a login stays valid is the **`default-authentication-login` stage's `session_duration`** (`UserLoginStage`) — not a global setting, and not `SESSION_COOKIE_AGE` (that is the *pre-login* session). Raised to **`days=7`** on 2026-10-20 (was stage value `0` = the 24 h framework default). It is an **absolute** window, never sliding: authentik implements `SESSION_SAVE_EVERY_REQUEST` nowhere, so activity does not extend it and no supported knob makes it one.
**`session_duration` is a STRING** in authentik's idiom (`"days=7"`, `"seconds=30"`, `"hours=12"`). A Python `timedelta` stores `"7 days, 0:00:00"`, which `authentik.lib.utils.time.timedelta_from_string` rejects with `ValueError` and login then crashes computing the expiry — so set it with **`scripts/authentik-set-login-session-duration.sh`** (validates the form first). Any non-zero value applies to *every* login (the "remember me" box only adds `remember_me_offset` on top) and a positive `set_expiry` also overrides `SESSION_EXPIRE_AT_BROWSER_CLOSE`, so closing the browser no longer logs you out; **existing sessions keep their old expiry** — delete the user's `authentik_core_session`/`…proxysession` rows and re-login to force it now. This is **DB state, not repo-managed**: a blueprint re-import or rebuild reverts the stage to `0`, so re-run the script after one. The other two login stages (`default-source-*`) drive external-source/enrollment logins and stay at the framework default.
## Guard-chain flip recipe (adding a guarded vhost)
Move a public vhost from `reverse_proxy <app>:<port>` onto the chain:
+2 -2
View File
@@ -30,9 +30,9 @@
},
{
"name": "open-notebook",
"category": "user",
"category": "admin",
"section": "AI",
"description": "Podcast generator — notes in, spoken episode out: script written by frank-zealot, voices rendered by frank-tts. Guarded twice: Authentik at the edge, then its own login.",
"description": "Podcast generator — notes in, spoken episode out: script written by frank-archon, voices rendered by frank-tts. Guarded twice: Authentik at the edge, then its own login. Single shared password, no per-user accounts — admin-only.",
"url": "http://notebook.franksdank.cloud",
"docs": "",
"probe": "127.0.0.1:8502",
+3 -1
View File
@@ -290,7 +290,9 @@ vhost = sys.argv[2]
cards = [s for s in data if s["name"] == "open-notebook"]
assert len(cards) == 1, f"expected exactly one open-notebook card, got {len(cards)}"
card = cards[0]
assert card["category"] == "user" and card["section"] == "AI", card
# category moved to "admin": single shared password, no per-user accounts, so it
# is positioned as an admin-side tool, not a user-facing app (see the vault page).
assert card["category"] == "admin" and card["section"] == "AI", card
assert card["probe"] == "127.0.0.1:8502", card["probe"]
assert card["method"] == "http"
# Deployed and guarded: the card is live and its link is the vhost, so the